【実務・中級編】 Windowsにおけるページテーブル解析による物理メモリと仮想メモリの対応付け – インシデントレスポンス & フォレンジック(DFIR)防御ガイド

おい、新人くん。ちょっとこっちに来てくれ。
昨日、例のマルウェア感染端末から回収したダンプファイルの解析が終わったんだが……相変わらず「メモリ上のプロセスリストが見つからないからお手上げです」なんて言ってるようじゃ困るな。

高度な攻撃者は、カーネルの目をごまかすためにダイレクト・カーネル・オブジェクト・マニピュレーション(DKOM)を使って、ActiveProcessLinksのリストから自分自身のプロセス構造体をきれいに切り離していく。タスクマネージャーや、お決まりのツールでプロセス一覧を眺めているだけでは、奴らの尻尾すら掴めないのさ。

そこで重要になるのが、「ページテーブル解析による物理メモリと仮想メモリの対応付け」だ。
今回は、OSが隠そうとした隠しプロセスや、メモリの隙間に断片化して隠されたマルウェアの本体を、CPUのページテーブルエントリ(PTE)を直接叩いて暴き出す、現場のフォレンジック技術を叩き込んでやる。心して聞けよ。

—

なぜタスクマネージャーは嘘をつくのか?――仮想メモリとページテーブルの裏側

CPUが実際に触っているのは物理メモリ(RAM)だが、OSやアプリケーションが認識しているのは「仮想メモリ」だ。この二つを翻訳しているのが、CPUのメモリ管理ユニット(MMU)であり、その翻訳表がページディレクトリ(CR3レジスタが指す)やページテーブル(PTE)だ。

インシデントレスポンスの現場で、攻撃者がメモリ上にインジェクションしたコードや、ダンプから直接ファイルを復元(Carving)しようとするとき、単純なシグネチャースキャンだけでは意味がない。
なぜなら、メモリ上のデータは連続したアドレスに綺麗に存在しているとは限らず、ページ単位でバラバラに断片化(スキャッタード)しているからだ。

さらに悪いことに、攻撃者はルートキットを使って、OSのAPIが参照するデータ構造(プロセスリストなど)を書き換えている。しかし、ハードウェアレベルのページテーブル構造そのものを完全に消し去ることはできない。 CPUがコードを実行し続けるためには、物理アドレスと仮想アドレスの対応付けが絶対に必要だからだ。

つまり、俺たちがやるべきことは、OSの嘘(APIの戻り値)を信じるのをやめ、CPUの真実(PTEの構造)を直接読み解くことなのだ。

—

ページテーブル解析の仕組みとフォレンジックの現場

x64アーキテクチャのWindows環境において、仮想アドレスから物理アドレスへの変換は、4段階の階層構造(PML4 -> PDPT -> PD -> PT)で行われる。

1. CR3レジスタからPML4(Page Map Level 4)の物理アドレスを取得。
2. 仮想アドレスの上位ビットをインデックスとして使い、PML4エントリ(PML4E)をたどってPDPT(Page Directory Pointer Table)へ。
3. 同様に順次たどり、最終的なPTE(Page Table Entry)に到達する。
4. PTEに格納されている物理フレーム番号(PFN)と、仮想アドレスのオフセットを結合して、真の物理アドレスを割り出す。

この仕組みを理解していれば、OSが隠したプロセスの仮想アドレス空間であっても、物理メモリの生データ(.rawダンプ)からページテーブルのチェインを辿ることで、完全に再構築することができる。

—

【実務コード】PythonによるカスタムPTEトラバーサーの実装

市販のフォレンジックツールがブラックボックス化している今、アナリスト自身がメモリの構造をコードレベルで理解しておく必要がある。
以下に、Pythonを用いてダンプファイルからページテーブルを辿り、仮想アドレスから物理アドレスを逆引きしてデータを抽出する簡易的なフォレンジックツールのサンプルコードを示す。

実務でそのまま検証に使えるよう、例外処理やアライメントの計算ロジックを組み込んできた。よく読んでおけ。

import struct

# ページサイズ(通常は4KB = 0x1000)
PAGE_SIZE = 4096
PAGE_MASK = 0xFFFFFFFFFFFFF000

def parse_pte(pte_value):
    """
    PTE (Page Table Entry) から有効フラグと物理フレーム番号 (PFN) を抽出する
    """
    is_present = (pte_value & 0x1) != 0
    if not is_present:
        return None, False
    
    # PFNはPTEの上位ビットに格納されている(x64の一般的な構造)
    pfn = (pte_value & 0x000FFFFFFFFFF000) >> 12
    return pfn, True

def virtual_to_physical_x64(dump_file, cr3_pa, target_va):
    """
    CR3レジスタと仮想アドレス(VA)から、メモリダンプファイル上の物理アドレス(PA)を算出する
    """
    # 各階層のインデックスを仮想アドレスから抽出
    pml4_idx = (target_va >> 39) & 0x1FF
    pdpt_idx = (target_va >> 30) & 0x1FF
    pd_idx   = (target_va >> 21) & 0x1FF
    pt_idx   = (target_va >> 12) & 0x1FF
    offset   = target_va & 0xFFF

    try:
        with open(dump_file, "rb") as f:
            # 1. PML4の取得
            f.seek(cr3_pa & PAGE_MASK)
            pml4_table = f.read(PAGE_SIZE)
            pml4e = struct.unpack("<Q", pml4_table[pml4_idx * 8 : (pml4_idx + 1) * 8])[0]
            
            pdpt_pfn, valid = parse_pte(pml4e)
            if not valid: return None

            # 2. PDPTの取得
            f.seek(pdpt_pfn * PAGE_SIZE)
            pdpt_table = f.read(PAGE_SIZE)
            pdpte = struct.unpack("<Q", pdpt_table[pdpt_idx * 8 : (pdpt_idx + 1) * 8])[0]
            
            pd_pfn, valid = parse_pte(pdpte)
            if not valid: return None

            # 3. PD (Page Directory) の取得
            f.seek(pd_pfn * PAGE_SIZE)
            pd_table = f.read(PAGE_SIZE)
            pde = struct.unpack("<Q", pd_table[pd_idx * 8 : (pd_idx + 1) * 8])[0]
            
            pt_pfn, valid = parse_pte(pde)
            if not valid: return None

            # 4. PT (Page Table) の取得
            f.seek(pt_pfn * PAGE_SIZE)
            pt_table = f.read(PAGE_SIZE)
            pte = struct.unpack("<Q", pt_table[pt_idx * 8 : (pt_idx + 1) * 8])[0]
            
            target_pfn, valid = parse_pte(pte)
            if not valid: return None

            # 最終的な物理アドレスの計算
            physical_address = (target_pfn * PAGE_SIZE) + offset
            return physical_address

    except Exception as e:
        print(f"[-] ページテーブル解析中にエラーが発生しました: {e}")
        return None

# --- 実行検証用のメイン処理 ---
if __name__ == "__main__":
    # 実際のインシデント調査では、Volatilityなどで特定したCR3の値と、
    # 調査対象のプロセス空間内の仮想アドレスを指定する
    sample_dump = "memory_dump.raw"
    sample_cr3 = 0x1A0000        # 例: 調査対象プロセスのCR3レジスタ値
    suspicious_va = 0x7FF7C0001000 # 例: 不審なコードが配置されている仮想アドレス

    print(f"[*] 仮想アドレス 0x{suspicious_va:X} の物理アドレスを解決しています...")
    pa = virtual_to_physical_x64(sample_dump, sample_cr3, suspicious_va)
    
    if pa:
        print(f"[+] 物理アドレスの特定に成功しました: 0x{pa:X}")
        with open(sample_dump, "rb") as f:
            f.seek(pa)
            dumped_bytes = f.read(64) # 先頭64バイトを抽出
            print(f"[+] 抽出されたバイデータ(先頭部): {dumped_bytes.hex()}")
    else:
            print("[-] 指定された仮想アドレスに対応する物理ページは存在しないか、スワップアウトされています。")

—

攻撃者がこのレイヤーで行う悪巧みと、現場での教訓

奴ら(攻撃者)は、EDRやアンチウイルスがユーザモードのAPIフックやカーネルの主要なコールバックを監視していることを知っている。だからこそ、物理メモリの直接操作や、今回解説したページテーブルの改ざん(Direct Page Table Manipulationなど)を仕掛けてくるんだ。

もし、君が開発するシステムやインフラが、万が一踏み台にされ、メモリ上のデータを巧妙に隠蔽されたとしても、「OSの報告をうのみにせず、低レイヤーの構造を疑う視点」を持っていれば、フォレンジック調査の初動で絶望的な遅れをとることはなくなる。

セキュリティエンジニアとしての腕の見せ所は、ツールが「何も検出しませんでした」と言った後に始まる。
「ツールが検知しないなら安全だ」なんてお花畑な考えは今すぐ捨てて、CPUが叩く生の構造体に向き合えるエンジニアになってくれ。期待しているぞ。

コメント

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