【テクニカル・上級編】 メモリ上のファイルレスマルウェアの抽出と静的解析への引き渡し – インシデントレスポンス & フォレンジック(DFIR)防御ガイド

ファイルレスの幻想と、現場で対峙する「生のメモリ」

EDRのダッシュボードに「ファイルレスマルウェア検知」のアラートが赤々と点灯した瞬間、多くのジュニアアナリストは膝を屈する。ディスク上に不審なバイナリは存在せず、C:\Windows\System32\cmd.exe や powershell.exe の正当なプロセスツリーから、突如としてメモリ上でのみコードが実行された痕跡だけが残されるからだ。

しかし、セキュリティベンダが好んで使う「ファイルレス」という言葉は、フォレンジックの現場においては一種のミスディレクションにすぎない。物理的なストレージにPEヘッダを持つファイルとして存在しないというだけであって、CPUが実行する以上、コードとデータは必ず物理メモリ(RAM)のどこかに存在している。

私たちが挑むべき任務は明確だ。 volatileな混沌の海から、リフレクションによってロードされたDLLや、プロセスホロウイング(Process Hollowing)によって書き換えられた仮想アドレス空間の断片を回収し、逆アセンブラが解釈可能なバイナリとして再構築すること。今回は、この泥臭くも極めてロジカルなメモリ抽出から静的解析への架け橋を、実戦的なワークフローとともに紐解いていこう。

—

1. 揮発性メモリの構造的理解と、プロセスインジェクションの低レイヤ挙動

攻撃者がファイルレスを実現する手法として最も多用するのが、正当な親プロセスを利用したメモリ空間の改ざん、すなわちプロセスインジェクションである。

典型的なシナリオを見てみよう。サードパーティ製アプリケーションの脆弱性を突き、あるいはフィッシングによって初期侵入を果たしたアタッカーは、まずターゲットとなる安全なプロセス(例えば svchost.exe や explorer.exe)のプロセスハンドルを OpenProcess APIで取得する。次に、リモートプロセス上のメモリ領域を VirtualAllocEx で確保し、そこにペイロード(シェルコードやリフレクティブDLL)を WriteProcessMemory で書き込む。最後に CreateRemoteThread や NtQueueApcThread を用いて、ターゲットプロセスのコンテキスト内で実行スレッドを強制的に生み出すのだ。

この時、ターゲットプロセスの仮想メモリマップ(Virtual Address Space)の内部はどうなっているか。
通常、Windowsのローダ(ntdll.dll)が管理するPE構造体(PEB -> InLoadOrderModuleList など)には登録されていない、いわゆる「Unlinked」あるいは「Hidden」なメモリ領域が出現する。ディスク上のファイルをマッピングしたわけではないため、モジュール名もパスも存在しない。

この不可視の領域を正確に特定し、ダンプすることがDFIRエンジニアの第一歩となる。

—

2. ライブフォレンジックとVolatility3によるダンプの極意

現場では、ライブレスポンスツールを用いて物理メモリのイメージ(.raw や .dmp)を安全に取得することが大前提となる。だが、闇雲にメモリ全体をダンプするだけでは、ギガバイト単位のノイズに埋もれてしまう。

ここで Volatility 3 を用いた精度の高いトリアージが必要になる。例えば、不審なプロセスを特定するために windows.pslist や windows.pstree プラグインを実行し、親子の不整合や未知のプロセスを発見したならば、次に行うべきは windows.malfind プラグインの適用だ。

malfind は、プロセスの仮想アドレス空間内における「RWX(Read-Write-Execute)」属性を持つ不審なメモリ領域や、PEヘッダの魔法定数(MZ シグネチャ)が予期せぬオフセットに出現している箇所をスキャンする。

# Volatility 3を用いて、不審なプロセスのVAD(Virtual Address Descriptor)領域からプロセス空間をダンプする例
python3 vol.py -f suspicious_memory.raw windows.malfind.Malfind --pid 3456 --dump

このコマンドを実行すると、該当するプロセスの特定アドレス範囲(例: 0x7ffd0000 付近)のメモリダンプが .dmp ファイルとして出力される。しかし、ここで出力されたファイルは、そのままではIDA ProやGhidraに読み込ませてもセクションのアライメントやリロケーション情報が欠損しており、正確な逆アセンベーションが困難な場合が多い。

—

3. リフレクティブDLLの再構築とPEヘッダの修復

ダンプされたメモリ断片は、多くの場合、完全なPE(Portable Executable)ファイルではなく、単なるシェルコードの塊、あるいはリフレクティブローダによってメモリ上に展開された状態のDLLである。

これを静的解析ツールに引き渡すためには、以下のステップで「生きたバイナリ」として再構築(Reconstruction)する必要がある。

1. MZ・PEシグネチャの特定: ダンプデータの先頭、あるいはアライメントの境界にある MZ(0x4D5A)を探す。もしシグネチャが部分的に上書きされている場合は、既知のコンパイラ出力や類似サンプルから修復する。
2. セクションヘッダの再計算: .text、.data、.rdata などの各セクションの仮想サイズ(VirtualSize)と生データのサイズ(SizeOfRawData)、および仮想アドレス(VirtualAddress)の整合性を取る。メモリ上のダンプであるため、VirtualAddressとPointerToRawDataが一致していないケースが多々ある。
3. インポートアドレステーブル(IAT)の解決: メモリ上で動的に解決されたAPIのアドレス(インポート名が解決されず、関数ポインタだけが入っている状態)を、スタティックなハッシュや既知のAPI名に置き換えるか、ダンプ時のIAT構造を再構築する。

ここで、実務で使えるシンプルなPythonスクリプトによるPEヘッダ修復のプロトタイプを見てみよう。このスクリプトは、メモリダンプから取得した不完全なバイナリのセクションアライメントを調整し、Ghidra等で読み込み可能な形式へ整形するための下処理を行うものだ。

import sys
import struct

def repair_pe_header(dump_path, output_path):
    """
    メモリダンプから切り出された不完全なPEデータのセクションアライメントを簡易修復するスクリプト
    """
    try:
        with open(dump_path, 'rb') as f:
            data = bytearray(f.read())
        
        # MZシグネチャの確認
        if data[0:2] != b'MZ':
            print("[-] エラー: 有効なMZシグネチャが見つかりません。")
            return

        # PEヘッダの位置を取得(e_lfanew のオフセットは 0x3C)
        e_lfanew = struct.unpack('<I', data[0x3C:0x40])[0]
        
        if data[e_lfanew:e_lfanew+4] != b'PE\x00\x00':
            print("[-] エラー: 有効なPEシグネチャが見つかりません。")
            return

        print(f"[+] PEヘッダを検出しました。オフセット: 0x{e_lfanew:X}")

        # ディスク上とメモリ上のアライメント差異を調整するため、
        # ここでオプショナルヘッダやセクションヘッダの値をパッチするロジックを挿入する
        # (実務ではセクションの PointerToRawData を VirtualAddress に合わせ込む処理が主となる)
        
        # サンプルとして、セクションの生データポインタを仮想アドレスに同期させる処理
        # マジックナンバーによるセクション数取得(PEヘッダから +6 バイトの位置)
        num_sections = struct.unpack('<H', data[e_lfanew+6:e_lfanew+8])[0]
        section_headers_offset = e_lfanew + 24 + struct.unpack('<H', data[e_lfanew+20:e_lfanew+22])[0]

        for i in range(num_sections):
            sec_offset = section_headers_offset + (i * 40)
            # VirtualAddress の値を取得
            virtual_address = struct.unpack('<I', data[sec_offset+12:sec_offset+16])[0]
            # PointerToRawData を VirtualAddress と同値に書き換え、静的解析ツールでのマッピングズレを防ぐ
            data[sec_offset+20:sec_offset+24] = struct.pack('<I', virtual_address)
            print(f"[*] セクション {i} の PointerToRawData を 0x習得 {virtual_address:X} に調整しました。")

        with open(output_path, 'wb') as out_f:
            out_f.write(data)
        
        print(f"[+] 修復完了: {output_path} を出力しました。")

    except Exception as e:
        print(f"[-] 処理中に例外が発生しました: {str(e)}")

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: python repair_dump.py <input_dump> <output_pe>")
        sys.exit(1)
    
    repair_pe_header(sys.argv[1], sys.argv[2])

—

4. 静的解析ツール(Ghidra / IDA Pro)へのブリッジと高度な解析手法

上記の手順で修復したバイナリを Ghidra や IDA Pro にロードする際、忘れてはならない重要なポイントがある。それは 「ベースアドレス(Image Base)の指定」 だ。

メモリ上からダンプされたコードは、元々アロケートされていた仮想アドレス(例: 0x00007FFB12340000 など)を前提として相対ジャンプやアドレス参照を行っている。もし解析ツール側でデフォルトのベースアドレス(例: 0x00400000)のまま読み込ませてしまうと、関数ポインタのクロスリファレンス(Xrefs)が完全に崩壊し、制御フローグラフ(CFG)がグチャグチャになる。

解析時のチェックポイント

1. 正しいベースアドレスの設定: VolatilityのVAD情報やPEヘッダ内の ImageBase フィールドを確認し、解析ツールのリベース(Rebase)機能を用いてロード時のメモリアドレスに一致させる。
2. インポート関数の手動解決: 動的API解決(GetProcAddress や LdrLoadDll を用いた難読化パターン)を行っているコードブロックを発見した場合、レジスタに格納されたAPIハッシュ値を特定し、既知のAPI名(例: VirtualAlloc, InternetOpenA)にコメントを付与していく。
3. 難読化・暗号化レイヤの剥ぎ取り: 近年の高度なマルウェア(特にランサムウェアやバンキング木馬)は、ペイロードの主要部をスタックストリングやAES/RC4で暗号化している。メモリダンプの最大の強みは、「暗号化が復号され、APIによってメモリ上に展開された直後の状態」をキャプチャできる点にある。つまり、ディスク上では厳重に難読化されていたコードが、メモリ上では「裸の姿」で露わになっているのだ。

—

5. まとめにかえて:ファイルレスの脅威に立ち向かうDFIRの哲学

ファイルレスマルウェアの抽出と静的解析への引き渡しは、単なるツールの操作手順の習得ではない。それは、OSのメモリ管理機構(VAD、PEB、スレッドコンテキスト)の深淵を理解し、攻撃者がシステムのリソースをどのようにハックして隠れようとしているかを逆手に取る知的な攻防戦である。

EDRがどれほど進化しようとも、最終的にCPUを動かすのはメモリ上に展開された物理的なバイト列に他ならない。揮発性の海から真実をすくい上げ、バイナリの息吹を再びコードとして蘇らせる技術——それこそが、最前線に立つセキュリティエンジニアに求められる最も気高く、実践的なスキルなのだ。

コメント

タイトルとURLをコピーしました