ディスクに痕跡を残さないサイバー攻撃を追い詰める:メモリ上の不正スレッド実行とコールスタック解析の深層
教科書通りのセキュリティガイドラインをなぞっているだけでは、現代の巧妙なインシデントを防ぐことはできません。
先月、私が指揮を執ったあるエンタープライズ企業のインシデントレスポンスで、極めて興味深い事例がありました。エンドポイント検出・応答ツール(EDR)のアラートは静まり返り、ディスク上のログやファイル整合性監視(FIM)にも一切の変更痕跡がない。しかし、社内ネットワークからは不審な外部IPアドレスへの暗号化通信が継続的に発生していたのです。
原因は、正常なプロセスのメモリ空間内に注入(インジェクション)され、既存のシステムDLLに化けた「メモリ内でのみ動作する不正スレッド」でした。
攻撃者はすでに、ファイルレスマルウェアやプロセスインジェクション、さらには「コールスタック偽装(Thread Stack Spoofing)」といった、メモリレベルで追跡を逃れるテクニックを日常的に駆使しています。
今回は、数々の泥臭いフォレンジック現場で培った知見をもとに、メモリ上の不審なスレッド実行コンテキストをどのように炙り出し、コールスタックから難読化された関数の実体を特定するのか。そして、開発者やインフラエンジニアが「今すぐ実装すべきセキュアな防御コードと設定」について、徹底的に解説します。
—
1. 攻撃者はメモリ空間の「闇」をどう利用するか?
まず、敵が何を行っているのかを正しく理解しましょう。伝統的なマルウェアは、.exe ファイルをディスクに保存し、プロセスとして起動します。しかし、これはセキュリティ製品に捕捉されるリスクが高いため、攻撃者はメモリ空間を直接操作する手法にシフトしました。
プロセスインジェクションの基本的な流れ
攻撃者が他人のプロセス(例: explorer.exe や svchost.exe)のメモリ空間に侵入し、コードを実行させる一般的な手順は以下の通りです。
1. OpenProcess: ターゲットとなる正常プロセスのハンドルを取得する。
2. VirtualAllocEx: ターゲットプロセスのメモリ空間に、実行権限(PAGE_EXECUTE_READWRITE など)を持つ領域を確保する。
3. WriteProcessMemory: 確保した領域にシェルコードやDLLを書き込む。
4. CreateRemoteThread や NtQueueApcThread: ターゲットプロセス内で新しいスレッドを作成し、書き込んだコードの先頭アドレス(エントリーポイント)へ実行権限を渡す。
ここで重要なのが、「どのモジュールにも紐付いていないメモリ領域(Unbacked Executable Memory)」でコードが動くという点です。
通常、プロセス内で動くスレッドのコードは、kernel32.dll や app.dll といった「ディスク上のファイル」にマッピングされたメモリ領域に存在します。しかし、上記の手法で注入されたシェルコードは、メモリ上に突如現れた「ファイル背景を持たない無名の領域(Unbacked Memory)」から実行されます。
—
2. 実戦:コールスタック解析による不正スレッドの特定
インシデントが発生した際、メモリダンプ(.raw や .dmp)から不審なスレッドを特定するために最も強力なアプローチが「スレッドの実行コンテキストおよびコールスタック(Stack Trace)の解析」です。
正常なコールスタックと異常なコールスタックの差
コールスタックとは、関数が別の関数を呼び出す際に「処理が終わったらどこに戻るべきか(リターンアドレス)」を記録したスタック領域の履歴です。
正常なスレッドの場合、コールスタックを下から順(古い順)に辿っていくと、必ずOSの標準モジュール(ntdll.dll や kernel32.dll)から始まり、アプリケーションの正規DLL(libexample.dll など)を経由しています。
正常なコールスタック例:
#0 0x00007fff8a123456 in ntdll!NtWaitForSingleObject ()
#1 0x00007fff88345678 in KERNELBASE!WaitForSingleObjectEx ()
#2 0x00007fff70123456 in C:\App\bin\service.dll!WorkerThreadFunction ()
#3 0x00007fff88901234 in KERNEL32!BaseThreadInitThunk ()
#4 0x00007fff8a098765 in ntdll!RtlUserThreadStart ()
しかし、メモリインジェクションを受けたスレッドのコールスタックを覗くと、異様な光景を目にすることになります。
異常なコールスタック例(Unbacked Memory):
#0 0x00007fff8a129999 in ntdll!NtDelayExecution ()
#1 0x0000021a8f901050 in ??? () <-- ディスク上のモジュールに紐付かない謎のアドレス!
#2 0x00007fff88901234 in KERNEL32!BaseThreadInitThunk ()
#3 0x00007fff8a098765 in ntdll!RtlUserThreadStart ()
#1 のアドレス 0x0000021a8f901050 に注目してください。シンボル名が存在せず、??? と表示されています。このアドレスを含むメモリページの保護属性を確認すると、PAGE_EXECUTE_READWRITE (RWX) や PAGE_EXECUTE_READ (RX) に設定されており、かつファイルがマッピングされていない状態(VAD: Virtual Address Descriptor 上で File: N/A)になっています。
これこそが、攻撃者のシェルコードが息を潜めて動いている動かぬ証拠です。
—
3. Pythonによる自動解析スクリプト(Volatility 3 カスタムプラグイン的アプローチ)
実務で何百ものプロセスや数千のスレッドを1つずつ手作業で調べるのは不可能です。ここで、メモリダンプやライブプロセスから「ファイルに紐付かない実行可能メモリ領域で動作しているスレッド」を検出するPythonスクリプト例を紹介します。
このスクリプトは、Windows APIの VirtualQueryEx を利用してスレッドの命令ポインタ(RIP/EIP)およびコールスタック上のフレームを調査し、不審なスレッドを自動的に割り出します。
import ctype
import ctypes
from ctypes import wintypes
# Windows APIの定数定義
PROCESS_ALL_ACCESS = 0x1F0FFF
MEM_COMMIT = 0x1000
PAGE_EXECUTE = 0x10
PAGE_EXECUTE_READ = 0x20
PAGE_EXECUTE_READWRITE = 0x40
PAGE_EXECUTE_WRITECOPY = 0x80
EXECUTE_FLAGS = (
PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
)
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", wintypes.LPVOID),
("AllocationBase", wintypes.LPVOID),
("AllocationProtect", wintypes.DWORD),
("PartitionId", wintypes.WORD),
("RegionSize", ctypes.c_size_t),
("State", wintypes.DWORD),
("Protect", wintypes.DWORD),
("Type", wintypes.DWORD),
]
def inspect_process_memory(pid):
"""
指定したPIDのプロセス内で、ファイル背景を持たない実行可能メモリ(Unbacked Executable Memory)
が存在するかをスキャンし、不正なスレッドやコード領域の候補を特定する。
"""
kernel32 = ctypes.windll.kernel32
# ターゲットプロセスのオープン
h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
if not h_process:
print(f"[-] PID: {pid} のプロセスを開けませんでした。")
return
address = 0
mbi = MEMORY_BASIC_INFORMATION()
print(f"[*] PID: {pid} のメモリマップのスキャンを開始します...")
suspicious_regions = []
# メモリ空間のウォーキング
while kernel32.VirtualQueryEx(h_process, ctypes.c_void_p(address), ctypes.byref(mbi), ctypes.sizeof(mbi)):
# コミットされたメモリで、かつ実行権限が付与されているかチェック
if mbi.State == MEM_COMMIT and (mbi.Protect & EXECUTE_FLAGS):
# モジュール情報(Mapped File)の取得を試みる
buf = ctypes.create_unicode_buffer(1024)
res = kernel32.GetMappedFileNameW(h_process, mbi.BaseAddress, buf, 1024)
# ディスク上のファイルに紐付いていない実行可能メモリ領域領域を検出
if res == 0:
print(f"[!] 警告: ファイルに紐付かない実行可能メモリ領域を発見!")
print(f" アドレス範囲: 0x{mbi.BaseAddress:016X} - 0x{(mbi.BaseAddress + mbi.RegionSize):016X}")
print(f" メモリ保護属性: 0x{mbi.Protect:X}")
print(f" 領域サイズ: {mbi.RegionSize} bytes\n")
suspicious_regions.append((mbi.BaseAddress, mbi.RegionSize))
# 次のメモリブロックへ進む
address += mbi.RegionSize
kernel32.CloseHandle(h_process)
return suspicious_regions
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python analyze_memory.py <PID>")
sys.exit(1)
target_pid = int(sys.argv[1])
inspect_process_memory(target_pid)
このツールを走らせて出力されるアドレス領域でスレッドが動作していれば、99%の確率で何らかのコードインジェクション(Cobalt StrikeのReflective Loaderや、カスタムシェルコード)が実行されています。
—
4. 防御編:不正スレッドインジェクションを根絶するセキュア実装
インシデントレスポンスで攻撃を検知・解析する技術も重要ですが、最も価値が高いのは「そもそも自社のプロダクトやサーバ上でコードインジェクションを成功させないアーキテクチャ」を構築することです。
開発者やシステム運用エンジニアが実務で組み込むべき、具体的な堅牢化の手法を解説します。
(1) Windows APIを活用したプロセス保護:Arbitrary Code Guard (ACG) の有効化
Windowsには、「動的に新しい実行可能メモリを生成させない」および「既存の実行可能メモリを書き換えさせない」という強力なセキュリティ機能「ACG(Arbitrary Code Guard)」が存在します。
アプリケーションの初期化処理(C# や C++)でACGを有効化しておくことで、攻撃者が VirtualAllocEx で PAGE_EXECUTE_READWRITE を割り当てようとした瞬間に、OSレベルで処理を強制終了させることができます。
以下は、C# アプリケーションの起動時にACGおよびコンパイル済みコードのみの実行を許可するポリシーを適用するセキュア実装のサンプルです。
using System;
using System.Runtime.InteropServices;
namespace SecureApplication
{
class Program
{
// Windows APIの宣言
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetProcessMitigationPolicy(
int policy,
ref PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY lpBuffer,
int dwLength
);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetProcessMitigationPolicy(
int policy,
ref PROCESS_MITIGATION_DYNAMIC_CODE_POLICY lpBuffer,
int dwLength
);
// ミティゲーションポリシーの定数
private const int ProcessDynamicCodePolicy = 2;
private const int ProcessSignaturePolicy = 8;
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_MITIGATION_DYNAMIC_CODE_POLICY
{
public uint Flags; // ProhibitDynamicCode などのフラグ設定
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY
{
public uint Flags; // MicrosoftSignedOnly などのフラグ設定
}
static void Main(string[] args)
{
// 1. 動的コード生成(JIT以外の未署名実行メモリ生成)を無効化 (ACG)
PROCESS_MITIGATION_DYNAMIC_CODE_POLICY dynamicCodePolicy = new PROCESS_MITIGATION_DYNAMIC_CODE_POLICY();
// ProhibitDynamicCode = 1 (ビット0を立てる)
dynamicCodePolicy.Flags = 0x00000001;
bool result = SetProcessMitigationPolicy(
ProcessDynamicCodePolicy,
ref dynamicCodePolicy,
Marshal.SizeOf(dynamicCodePolicy)
);
if (result)
{
Console.WriteLine("[+] 成功: Arbitrary Code Guard (ACG) を有効化しました。");
}
else
{
Console.WriteLine("[-] 警告: ACGの有効化に失敗しました。");
}
// アプリケーションの本番ロジックを実行...
RunApplicationLogic();
}
private static void RunApplicationLogic()
{
Console.WriteLine("[*] アプリケーションはセキュアなコンテキストで実行中です。");
// 処理を継続...
}
}
}
このコードを組み込むだけで、万が一アプリケーションに未知のメモリ破損脆弱性(Buffer Overflow等)が存在したとしても、攻撃者がシェルコードをメモリ上に配置して新しい実行可能スレッドを生成する攻撃シナリオを根本から遮断できます。
—
(2) Sysmonによる不審なスレッド生成・メモリ割り当ての全社監視
インフラ・SecOps側での防御としては、Sysmon(System Monitor)を活用して「リモートスレッドの作成」や「他プロセスへの書き込み」をログとして記録・監視することが不可欠です。
以下のSysmon設定ファイル(sysmonconfig.xml)の定義を追加することで、攻撃者が利用する代表的なインジェクションAPIの挙動を監視できます。
<Sysmon schemaversion="4.90">
<EventFiltering>
<!-- イベントID 8: CreateRemoteThread(別プロセスへの不審スレッド注入の検知) -->
<RuleGroup level="include" default="exclude">
<CreateRemoteThread onmatch="exclude">
<!-- 正常なWindowsシステムの挙動(ノイズ)を除外 -->
<SourceImage condition="is">C:\Windows\system32\csrss.exe</SourceImage>
</CreateRemoteThread>
<CreateRemoteThread onmatch="include">
<!-- ターゲットが主要な標準プロセスの場合は厳重監視 -->
<TargetImage condition="image">lsass.exe</TargetImage>
<TargetImage condition="image">svchost.exe</TargetImage>
<TargetImage condition="image">spoolsv.exe</TargetImage>
<TargetImage condition="image">explorer.exe</TargetImage>
</CreateRemoteThread>
</RuleGroup>
<!-- イベントID 10: ProcessAccess(メモリ読み書きハンドルの取得検知) -->
<RuleGroup level="include" default="exclude">
<ProcessAccess onmatch="include">
<!-- PROCESS_VM_WRITE (0x0020) や PROCESS_VM_OPERATION (0x0008) を含むアクセスを監視 -->
<GrantedAccess condition="contains any">0x0020;0x0008</GrantedAccess>
</ProcessAccess>
</RuleGroup>
</EventFiltering>
</Sysmon>
この設定を本番環境のWindowsサーバ群に適用しておくことで、SIEM(SplunkやElasticsearch等)側で「リモートスレッド作成イベント(Event ID 8)」をリアルタイム検知し、即座に該当マシンを隔離するような自動一次対応が可能となります。
—
5. チーフエンジニアとして伝えたいこと
セキュリティの仕事をしていると、「WAFを入れたから大丈夫」「EDRを導入したから安心」という言葉をよく耳にします。しかし、現場の泥臭いインシデント解析を行っていれば、それらのツールが「完璧な銀の弾丸」ではないことは一目瞭然です。
攻撃者はセキュリティ製品のチェックを回避するために、メモリ空間の奥深くへと潜り込み、正常なスレッドのコールスタックを偽装し、OSの標準機能を悪用します。
我々セキュリティエンジニアや開発者に求められているのは、以下の3つの原則です。
1. メモリレベルの挙動を理解する: スレッド、コールスタック、VAD、メモリ保護属性(RX/RWX)といった、OSの低レイヤー構造への理解を怠らないこと。
2. 多層防御をコードレベルで組み込む: アプリケーション開発段階で SetProcessMitigationPolicy や最小権限原則を意識し、メモリを攻撃者に渡さない構造を作ること。
3. 可視性を確保する: ディスク上のファイルだけでなく、メモリインジェクションの兆候(Sysmon ID 8/10など)を常時監視できるインフラを整えること。
「見えない攻撃」を恐れる必要はありません。メモリの仕組みと正しく向き合い、堅牢な設計と適切なフォレンジック技術を身につけていけば、どのような巧妙な侵入者であっても確実に追い詰めることができます。
日々の運用やコード開発に、ぜひ今回の知見を取り入れてみてください。
コメント