All Articles

Magical WinDbg VOL.2【6 章 DoPDriver を動的解析する】

この章では、Windows カーネルデバッグを使用して DoPDriver を動的解析することで、2 つ目の Flag を特定します。

5 章で確認した通り、2 つ目の Flag を特定するためには、checkImageFileName 関数(アドレス 0x140001000 の関数)の検証をパスできるイメージファイル名を持つプロセスを起動する必要があります。

本章では、カーネルデバッグで DoPDriver の動的解析を行いつつ、総当たり攻撃で正しいイメージファイル名を特定します。

なお、Windows カーネルドライバを仮想マシンにロードし、カーネルデバッグを行う環境のセットアップ方法については 1 章を参照してください。

もくじ

DriverEntry 関数の動的解析を行う

5 章に記載の通り、DoPDriver のようなカーネルドライバモジュールは起動時に DriverEntry 関数が実行されます。

しかし、カーネルドライバの場合はユーザモードプログラムとは異なり、プログラムをデバッガから起動することはできません。

また、DriverEntry 関数にブレークポイントを設定してデバッグを行うためには少し特別なアプローチが必要になります。

その理由は、ドライバのロードが行われるまで DoPDriver のルーチン名を利用できず、DriverEntry 関数に bp コマンドを使用してブレークポイントを設定できないためです。

そのため、DriverEntry 関数のデバッグを行うためには bu コマンドを使用して未解決のルーチン名に対して遅延ブレークポイントを設定するか、WinDbg の機能でモジュールのロード時のデバッグを有効化する必要があります。

bu コマンドで DriverEntry 関数に遅延ブレークポイントを設定する場合には、bu DoPDriver+0x11cc コマンドを使用します。

bu DoPDriver+0x11cc コマンドの実行後に DoPDriver がロードされると、ルーチン名が自動的に解決されて DriverEntry 関数でシステムが一時停止します。

なお、DriverEntry 関数のデバッグを行うために最も簡単は方法は bu コマンドによる遅延ブレークポイントの設定かと思いますが、本書ではあえてモジュールのロード時のデバッグを有効化する方法を使用することにします。

モジュールのロード時のデバッグを有効化する場合は、WinDbg をカーネルデバッガとして仮想マシンにアタッチした後に WinDbg の [File]>[Settings] を開き、[Events & Exception] の [Load module on all modules] の設定を [Break] に変更します。

Load module on all modules の設定変更

[Load module on all modules] の設定を [Break] に変更した後、g コマンドでシステムの実行を再開し、DoPClient により DoPDriver をシステムにロードさせます。

設定が正しく反映されている場合、このタイミングで仮想マシンのシステムが一時停止し、カーネルデバッガのコマンド操作が可能になります。

ドライバロード時にシステムが一時停止する

[Load module on all modules] のデバッガ設定によって DoPDriver のロード時にシステムを一時停止することで、DoPDriver のシンボルを使用してブレークポイントを設定することができるようになります。

そこで、DriverEntry 関数の解析を行うため、5 章で確認した DriverEntry 関数のアドレスである DoPDriver+0x11cc に対してブレークポイントを設定します。

WinDbg をカーネルデバッガとして使用する場合も、多くのコマンドはユーザモードプログラムのデバッグを行う際と共通しています。

そのため、bp DoPDriver+0x11cc ; g コマンドを実行することで DriverEntry 関数にブレークポイントを設定した上で、システムの実行を再開します。

ブレークポイントを設定してシステムの実行を再開すると、すぐに DoPDriver+0x11cc でシステムが一時停止します。

ここで、uf @rip などのコマンドを発行することで、DriverEntry 関数の逆アセンブル結果を参照できます。

DriverEntry の逆アセンブル結果を参照する

5 章で確認した通り、DriverEntry 関数ではまずはじめに DeviceName などを引数として IoCreateDevice 関数でデバイスオブジェクトを作成しています。

ここではまず、IoCreateDevice 関数の引数として受け渡される UNICODE_STRING 構造体のオブジェクトである DeviceName の値と、ドライバオブジェクトにディスパッチルーチンとして設定される関数のアドレスをデバッガで特定します。

解析を行うため、call qword [rel IoCreateDevice] のコードを実行する DoPDriver+0x1229 まで実行を進めます。

特定のアドレスまで実行を進める方法はいくつかありますが、今回は pa DoPDriver+0x1229 コマンドを使用して DoPDriver+0x1229 まで一気にステップ実行を進めました。

IoCreateDevice の呼び出しアドレスまでステップ実行する

pa コマンド 1 はステップ実行コマンドの一種ですが、指定のアドレスまでステップ実行をまとめて行うことができるため、ブレークポイントを設定することなく指定のアドレスまで実行を進める際などに便利です。

ただし、pa コマンドではプログラムはステップ実行されるため、実行時のオーバーヘッドが大きくなります。

そのため、指定のアドレスまでのステップ数が多い場合には g コマンドなどを利用する方がよいでしょう。

pa コマンドによって IoCreateDevice 関数の呼び出し直前のアドレス(DoPDriver+0x1229)まで到達したので、次は r rcx,rdx,r8,r9 ; dps rsp L3 コマンドでレジスタとスタックの情報を参照します。

1: kd> r rcx,rdx,r8,r9 ; dps rsp L3
rcx=ffff818bb49eaa70 rdx=0000000000000000 r8=ffffbc0b8ff338a0 r9=0000000000000022
ffffbc0b`8ff33860  00000000`00000000
ffffbc0b`8ff33868  00000000`00000000
ffffbc0b`8ff33870  ffff818b`b71e83d0

Windows の x64 呼び出し規約では、引数がすべて整数値の場合、第 1 引数は RCX レジスタに、第 3 引数は R8 レジスタに格納されます。

つまり、IoCreateDevice 関数の第 1 引数として設定されるドライバオブジェクトへのポインタは RCX に、そして第 3 引数として渡される DeviceName は R8 レジスタが持つアドレスに格納されています。

実際に、db @R8 L0x10 コマンドで R8 レジスタが指すアドレスのデータを出力しても、DeviceName は文字列としては保存されていないことを確認できます。

しかし、dt nt!_UNICODE_STRING @R8 コマンドで R8 レジスタが指すアドレスを UNICODE_STRING 構造として解釈させると、\Device\DoPDriver という DeviceName が定義されていることをデバッガでも確認することができました。

IoCreateDevice の呼び出し時の第 3 引数を確認する

DeviceName を確認できたので、次はドライバオブジェクトの情報を参照し、ディスパッチルーチンに設定されている値を確認してみます。

ドライバオブジェクトのポインタは、IoCreateDevice 関数の第 1 引数である RCX レジスタに保持されています。

このアドレスに対して dt nt!_DRIVER_OBJECT @RCX コマンドを実行すると、DriverName が \Device\DoPDriver に設定されているドライバオブジェクトの情報を参照できます。

さらに MajorFunction をクリックすると、5 章で確認した通り DriverObject->MajorFunction[0(IRP_MJ_CREATE)]DriverObject->MajorFunction[2(IRP_MJ_CLOSE)] の 2 つのディスパッチルーチンが登録されており、それぞれ登録されている関数のアドレスを確認することができました。

ドライバオブジェクトのディスパッチルーチンを確認する

PsSetCreateProcessNotifyRoutine で登録したコールバック関数の動的解析を行う

次は、PsSetCreateProcessNotifyRoutine で登録したコールバック関数の動作をデバッガで確認してみましょう。

まずは、bp DoPDriver+0x12E0 ; g コマンドでブレークポイントを設定してシステムの実行を再開します。

すると、システムで何らかのプロセスの作成もしくは削除が行われるまで、システムは中断されず、デバッガコマンドが利用できない状態になることを確認できます。

続けて、bc * コマンドですべてのブレークポイント設定をクリアした後に、今度は bp DoPDriver+0x131C "da @RAX ; g" コマンドで新しいコマンド付きブレークポイントを設定し、g コマンドでシステムの実行を再開します。

アドレス DoPDriver+0x131C は、5 章で確認したプロセスのイメージファイル名を返す PsGetProcessImageFileName 関数を実行した直後のアドレスです。

PsGetProcessImageFileName 関数は戻り値にプロセスのイメージファイル名を返すので、da @RAX コマンドを実行することで、取得したイメージファイル名をコンソールに出力できます。

実際にこのブレークポイントを設定することで、システム内で何らかのプロセスが作成される度に da @RAX コマンドが実行され、イメージファイル名がコンソールに出力されることを確認できました。

コールバック関数をトリガーしたプロセスのイメージファイル名

ブレークポイント設定で総当たり攻撃により Flag を特定する

それでは WinDbg を使用して checkImageFileName 関数(アドレス 0x140001000 の関数)の検証をパスできるイメージファイル名を特定していきます。

まずは、最もシンプルなブレークポイントを使用する方法で Flag を取得することを試みます。

例えば、以下の 2 つのブレークポイントを設定すると、何らかのプロセスが起動する度に、そのイメージファイル名をコンソール上に表示した後、checkImageFileName 関数の検証をパスしたか否かを出力できます。

bp DoPDriver+0x131C "da @RAX ; g"
bp DoPDriver+0x1333 ".if (@zf == 1) { .printf \"======> Correct\\n \" } .else { .printf \"======> Failed\\n \" ; g }"

2 つ目のブレークポイントでは、checkImageFileName 関数の戻り値を比較した後にゼロフラグが 1 であるか否かによって処理を分岐させています。

イメージファイル名が checkImageFileName 関数の検証をパスした場合、Correct という文字列を出力してシステムの実行を一時停止します。

一方で、イメージファイル名が検証をパスしなかった場合は、Failed という文字列を出力した後に g コマンドでプログラムの実行を再開します。

ブレークポイント設定を利用した総当たり攻撃

上記のブレークポイントを設定した後に Python スクリプトなどで 13 文字のイメージファイル名を持つプロセスを総当たり実行することで、理論上はフラグを特定することが可能です。

ただし、デバッガによるブレークポイントの評価はオーバーヘッドが大きく、特にデバッグ対象のシステムを中断することになるカーネルデバッグでは、上記のような繰り返しシステムの中断と再開を繰り返す操作には非常に長い時間を要します。

そのため、今回のように、表示可能な ASCII 文字で 13 文字のイメージファイル名を総当たりするには必要な試行回数が膨大となるため、数十時間以上かけても正しいイメージファイル名を特定することはできません。

そこで、checkImageFileName 関数の検証プロセスを動的解析することで、より効率的な Flag の特定方法を探っていくことにします。

checkImageFileName 関数の動的解析を行う

checkImageFileName 関数(アドレス 0x140001000 の関数)の動的解析を行うため、5 章の解析結果を振り返ります。

5 章で確認した通り、checkImageFileName は引数として受け取ったイメージファイル名の文字列から 1 文字ずつ取り出し、何らかの複雑な演算を行った結果をハードコードされた整数値と比較していました。

また、初期値が 0 のループカウンタが 13(0xd) 以上となった場合にループを抜けることから、正解となるイメージファイル名の長さは 13 文字であることもわかっています。

そこで、まずは動的解析で上記の解析結果が正しいかどうかを確認します。

bc * で既存のブレークポイントをクリアした後、最初に bp DoPDriver+0x1050 コマンドでブレークポイントを設定します。

このアドレスでは、movsx eax,byte ptr [r11] の処理が実行されます。

5 章で確認した通り、checkImageFileName 関数が引数として受け取ったイメージファイル名は R11 レジスタの保持するポインタアドレスに格納されています。

そして、DoPDriver+0x1050 で実行されるコードは、ループ内でこれを 1 文字ずつ取り出す処理にあたります。

イメージファイル名を 1 文字ずつ取り出すループ処理

ブレークポイントを設定した後に g コマンドでシステムの実行を再開したら、仮想マシン内で任意のプログラムを実行します。

本書では、以下のリポジトリからダウンロードできる HelloWorld.exe をテストとして実行しました。


HelloWorld.exe:

https://github.com/kash1064/ctf-and-windows-debug/


このプログラムを実行すると、DoPDriver+0x1050 のブレークポイントにヒットして処理が一時停止します。

ここで da @R11 コマンドを実行すると、R11 レジスタが保持するポインタアドレスに HelloWorld.exe という文字列が保存されていることを確認できます。

さらに、ステップ実行により movsx eax,byte ptr [r11] の処理を行った後、EAX レジスタにイメージファイル名の先頭の文字である 0x48(H) が保存されたことを確認できます。

R11 レジスタから 1 文字を EAX レジスタに取り出す

DoPDriver+0x1050 で取り出した文字に対して何らかの演算を行った結果をハードコードされた整数値と比較するコードは DoPDriver+0x114a でした。

取り出した文字に何らかの演算を行った結果を整数値と比較する

そのため、次はこのアドレスにブレークポイントを設定し、ゼロフラグを改ざんすることで上記の検証をバイパスできるか試します。

bp DoPDriver+0x114a コマンドでブレークポイントを設定した後、g コマンドで実行を再開します。

整数値の検証を行った直後の状態で r zf コマンドを実行すると、ゼロフラグが 0 であることを確認できるため、文字 H は正しい Flag となるイメージファイル名の 1 文字目ではないことがわかります。

このままプログラムの実行を再開すると、検証に失敗したことになりループ処理が終了してしまうため、実行を再開する前に r zf=1 でゼロフラグを 1 に改ざんしておきます。

ゼロフラグを改ざんした状態で g コマンドを入力してシステムの実行を再開すると、ループ処理が継続し、もう一度 DoPDriver+0x1050 のブレークポイントにヒットします。

この時、ループは 2 周目であり、R11 レジスタのポインタアドレスもイメージファイル名の 2 文字目を指すようにシフトされていることを確認できます。

R11 レジスタのポインタがイメージファイル名の 2 文字目を指している

これで、checkImageFileName 関数がイメージファイル名を 1 文字ずつ検証していること、そしてアドレス DoPDriver+0x114a でゼロフラグを改ざんすることで検証結果をバイパスできることを確認できました。

つまり、DoPClient と同じく、1 回の試行につきイメージファイル名のすべての文字を同時に検証することができ、0x20 から 0x7E までの最大 95 回の総当たりで正しいイメージファイル名を特定できることがわかります。

JavaScript ベースのデバッガスクリプトでイメージファイル名を特定する

4 章では、正しい Flag を特定するために cdb.exe とデバッガコマンドスクリプトを利用しました。

今回は DoPClient のように総当たりを試行するたびにロードしたデバッガスクリプトが初期化されることはないため、JavaScript ベースの強力なデバッガスクリプトで総当たり攻撃を行うことができます。

正しいイメージファイル名を特定するため、以下カーネルデバッガにロードする JavaScript ファイルと、仮想マシンで実行し、イメージファイル名の総当たりを行う Python スクリプトファイルを使用します。

カーネルデバッガにロードする JavaScript は以下の通りです。

// .scriptrun C:\CTF\Autorun2.js
"use strict";

let word = "";
let correctImageFileName = new Array(13).fill("*");

function RunCommands(cmd) {
	let ctl = host.namespace.Debugger.Utility.Control;
	let output = ctl.ExecuteCommand(cmd);

	for (let line of output) {
		host.diagnostics.debugLog("  ", line, "\n");
	}

	return output;
}

function SetBreakPoints() {
	let ctl = host.namespace.Debugger.Utility.Control;
	let breakpoint;

	breakpoint = ctl.SetBreakpointAtOffset("DoPDriver", 0x1054);
	breakpoint.Command = "dx -r1 @$autoRun2.CheckPoint1() ; g";

	breakpoint = ctl.SetBreakpointAtOffset("DoPDriver", 0x114a);
	breakpoint.Command = "dx -r1 @$autoRun2.CheckPoint2() ; r zf = 1 ; g";

	return;
}

function Result() {
	host.diagnostics.debugLog("correctImageFileName is: ");

	for (let w of correctImageFileName ) {
		host.diagnostics.debugLog(w);
	}

	host.diagnostics.debugLog("\n");
	return;
}

function CheckPoint1() {
	// EAX レジスタからイメージファイル名に含まれる 1 文字を取得
	let context = host.namespace.Debugger.State.DebuggerVariables.curthread.Registers.User;
	let eaxValue = context.eax;
	word = String.fromCodePoint(eaxValue);

	return;
}

function CheckPoint2() {
	let context = host.namespace.Debugger.State.DebuggerVariables.curthread.Registers.User;
	let r9Value = context.r9d;
	let r11Value = context.r11;
	let zeroFlagValue = context.zf;
	
	// ゼロフラグの値から検証に成功したかどうかを判断
	if (zeroFlagValue == 1) {
		correctImageFileName[parseInt(r9Value, 16)] = word;
	}
	
	return;
}

function initializeScript() {
	return [
		new host.apiVersionSupport(1, 7),
	];
}

function invokeScript() {
	RunCommands("dx @$autoRun2 = Debugger.State.Scripts.Autorun2.Contents");
	RunCommands("dx @$autoRun2.SetBreakPoints()");
	RunCommands("g");
	return;
}

また、仮想マシン側で実行する Python スクリプトは以下の通りです。

import os
import subprocess

exe_path = "C:\\CTF\\HelloWorld.exe"
for i in range(0x20,0x7F):
	if chr(i) in ["\\","?","<",">",":","*","|","\"",".","/"]:
		continue

	new_exe_path = "C:\\CTF\\{}.exe".format(chr(i)*9)
	os.rename(exe_path,new_exe_path)
	exe_path = new_exe_path
	proc = subprocess.Popen([exe_path])
	proc.kill()

このスクリプトは以下のリポジトリから、それぞれ Autorun2.js および Stage2_Solver.py としてダウンロードできます。


Autorun2.js と Stage2_Solver.py

https://github.com/kash1064/ctf-and-windows-debug/


カーネルデバッガにロードする JavaScript の構成は 4 章で使用した Autorun.js とほとんど同じです。

このスクリプトでは、R11 レジスタの保持するアドレスからイメージファイル名を 1 文字取り出した直後のアドレスである DoPDriver+0x1054 にブレークポイントを設定し、CheckPoint1 関数を使用して評価対象となる文字を変数 word に確保します。

続けて、評価結果を確認できる DoPDriver+0x114a に設定したブレークポイントで CheckPoint2 関数を実行することで、ゼロフラグの値から word に保存した文字が正しいイメージファイル名のものと一致するかを確認しています。

このスクリプトにより、AAAAAAAAA.exeZZZZZZZZZ.exe などのプロセスを実行することで正しいイメージファイル名の各文字を総当たりすることができます。

そして、ファイル名の総当たりを行うためのスクリプトは Stage2_Solver.py です。

このスクリプトでは、文字列 Hello World を出力するだけのプログラムである C:\CTF\HelloWorld.exe をリネームして実行することで、イメージファイル名の総当たりを行っています。

この 2 つのスクリプトを使用することで、0x20 から 0x7E までの 95 回の試行で正しいイメージファイル名を特定することができます。

なお、ここで使用するプログラムは任意の EXE ファイルで代用が可能ですが、文字列 “Hello World” をコンソールに出力するだけのプログラムである HelloWorld.exe を以下のリポジトリからダウンロードしてリネームすることで使用することもできます。

ダウンロードした HelloWorld.exe は、仮想マシンの C:\CTF フォルダ直下に配置しておきます。


HelloWorld.exe:

https://github.com/kash1064/ctf-and-windows-debug/blob/main/HelloWorld.exe


すべての準備が完了したら、総当たりを行うためにカーネルデバッガで以下のコマンドを順に実行します。

これで、既存のブレークポイントをフラッシュした上で Autorun2.js のスクリプトをロードできます。

bc *
.scriptrun <Autorun2.js のフルパス>

Autorun2.js をデバッガにロードしたら、仮想マシン側で Stage2_Solver.py を実行します。

試行回数はたったの 95 回ですが、カーネルデバッガでブレークポイントにヒットした場合、システムそのものの動作が一時停止することもあり、すべての処理が終了するまで 10 分以上の時間がかかる場合があります。

Stage2_Solver.py の実行が完了したら、Autorun2.js の correctImageFileName 配列に正しいイメージファイル名が記録されているはずですので、デバッガで dx -r1 @$autoRun2.Result() コマンドを実行します。

これで、正しいイメージファイル名が topsecret.exe であることを特定できました。

イメージファイル名を総当たりで特定する

ページプールの情報を確認する

正しいイメージファイル名が topsecret.exe であることを総当たり攻撃で特定できたので、ExAllocatePoolWithTag で確保された領域に本当に Flag が格納されるかを確認してみましょう。

確認を行う前に、既存のページプールをクリアするために一度仮想マシンを再起動しておきます。

仮想マシンを再起動したら、DoPClient を起動する前にカーネルデバッガをアタッチし、!poolused コマンド 2 を実行します。

この拡張コマンドをオプション無しで実行すると、ページプールおよび非ページプールのプールタグとメモリ使用量を一覧として出力できます。

カーネルデバッガでページプールの情報を参照する

DoPDriver が作成するページプールのプールタグは flag でしたので、オプション引数にタグ名を使用し、!poolused 0 flag コマンドを実行します。

しかし、DoPDriver を使用していないこの時点ではまだ flag というタグを持つページプールは存在していないため、何の情報も表示されません。

タグが flag のページプールの情報を参照する

現在のところシステムに flag というタグを持つページプールは存在していないことを確認できたので、次は DoPClient を起動し、パスワード FLAG{You_can_use_debug_script_in_WinDbg_Next} を入力して再び DoPDriver をシステムにロードします。

そして、WinDbg で bp DoPDriver+0x135e コマンドを実行し、DoPDriver+0x135e にブレークポイントを設定します。

このアドレスは、call qword [rel ExAllocatePoolWithTag] にて ExAllocatePoolWithTag 関数が呼び出されるアドレスです。

先ほど特定した topsecret.exe が正しいイメージファイル名に間違いない場合、checkImageFileName 関数の検証をパスしてこのコードが実行されるはずです。

仮想マシンで topsecret.exe にリネームした任意の実行ファイルを起動すると、DoPDriver+0x135e のアドレスで処理が一時停止します。

ここで、r ECX,EDX,R8d コマンドにより ExAllocatePoolWithTag 関数の引数を参照します。

ExAllocatePoolWithTag 関数の引数を参照する

5 章で確認した通り、ExAllocatePoolWithTag 関数は以下の 3 つの引数を取り、割り当てられたメモリへのポインタを戻り値として返します。

PVOID ExAllocatePoolWithTag(
  [in] __drv_strictTypeMatch(__drv_typeExpr)POOL_TYPE PoolType,
  [in] SIZE_T                                         NumberOfBytes,
  [in] ULONG                                          Tag
);

ECX に保存されている値は 1 でしたが、これは PoolType を PagedPool に指定しています。

また、R8d に保存されている 0x67616c66 は、プールタグ flag を逆順に並べた文字列 galf を 16 進数変換したものです。(ExAllocatePoolWithTag 関数でプールタグを指定する場合、最大 4 文字のタグ名を逆順に並び替えた値を使用します)

次に、p コマンドでステップ実行を行い、ExAllocatePoolWithTag 関数によるページプールの割り当てを完了させます。

ExAllocatePoolWithTag 関数によるページプールの割り当てに成功した場合、戻り値が格納される RAX レジスタには割り当てたメモリ領域のポインタアドレスが保存されます。

また、再度 !poolused 0 flag コマンドを実行してみると、先ほどは存在しなかった flag というタグのページプールが 1 つ割り当てられていることを確認できます。

ExAllocatePoolWithTag 関数で割り当てたページプールのアドレスを参照する

ExAllocatePoolWithTag 関数は、プールメモリを割り当て、割り当てられたブロックへのポインタを返します。

この時割り当てられたブロックには、64 bit OS の場合 16 バイトの POOL_HEADER 構造体が付与されています。

そのため、dt nt!_POOL_HEADER @RAX-0x10 コマンドで、ExAllocatePoolWithTag 関数が割り当てたブロックのアドレスより 16 バイト手前のアドレスにアクセスすると、プールタグ flag を含むヘッダ情報が存在していることを確認できます。

プールヘッダの情報を参照する

また、特定のプールの割り当てに関する情報を参照できる !pool コマンド 3 でこのメモリブロックの情報を参照することでも、プールヘッダのアドレスやプールタグを確認することができます。

このコマンドを実行すると、引数として受け取ったアドレスがプールのブロック内に存在するかを確認し、存在する場合はプールヘッダのアドレスやプールタグ、またはそのプールに保存されているコンテンツなどを表示できます。

!pool コマンドで割り当てたブロックの情報を参照する

ExAllocatePoolWithTag 関数によるページプールの作成を確認できたので、念のため RAX レジスタに保持されていたメモリブロックのポインタアドレス(今回は 0xffff828292b6b270)を記録した後、g コマンドでシステムの処理を再開します。

システムの処理が再開されると、DoPDriver は自身が作成したページプールの領域に 2 つめの Flag 文字列を保存するはずです。

実際に、g コマンドで処理を再開した後に再度システムを一時停止し、先ほど特定したメモリブロックのアドレスを対象に !pool 0xffff828292b6b270 1 コマンドを実行したところ、プールタグ flag が割り当てられた領域に書き込まれているデータを表示できます。

!pool コマンドで割り当てたブロックのヘッダとコンテンツを参照する

しかし、このままでは 2 つ目の Flag 文字列が本当に書き込まれているか判断しづらいので、対象アドレスの Unicode 文字列を表示する du コマンドを使用し、du ffff828292b6b270 を実行することで正解の Flag である FLAG{The_important_process_is_topsecret.exe} がメモリブロックに書き込まれていることを確認できました。

メモリブロック内の Flag を参照する

これで、2 つ目の正しい Flag を特定することができました。

プールタグからメモリブロックのアドレスを特定する

ExAllocatePoolWithTag 関数が割り当てたメモリブロックのアドレスを参照することで 2 つ目の正しい Flag を特定できましたが、必ずしも今回のように静的解析で特定のプールタグと紐づくプールを割り当てるコードを特定できるとは限りません。

そのため、ここからは静的解析でメモリ割り当てを行う実行コードを特定する以外のアプローチで、特定のプールタグと紐づくメモリブロックのアドレスを特定することを試みます。

プールタグからページプールのアドレスを特定可能な方法の 1 つとして、!poolfind コマンド 4 があります。

!poolfind コマンドでは、ページプールや非ページプールの領域から、特定のプールタグを持つインスタンスをすべて検索することができます。

例えば、以下のように !poolfind -tag "Proc" コマンドを実行すると、Proc というタグ名のインスタンスのアドレスをページプールから検索できます。

!poolfind によるプールタグの検索

しかし、多くの場合 !poolfind によるプールタグの検索には極めて長い時間がかかります。

また、詳細は不明ながらデバッガのハングアップやプールタグの検出漏れに関する情報も散見されるため、!poolfind によるプールタグの検索はあまり効率的ではありません。

そこで、プールタグの検索ではなく、PoolHitTag を使用して特定のプールタグを使用するメモリ割り当てを検出することで、ExAllocatePoolWithTag 関数の戻り値からメモリブロックのアドレスを特定します。

PoolHitTag を使用してメモリの割り当てアドレスを特定する

ライブデバッグを行う場合、特定のプールタグ名と紐づくメモリアドレスを特定する方法として、PoolHitTag を利用することができます。5

PoolHitTag は ed nt!poolhittag コマンドで設定できます。

例えば、プールタグ名 flag を指定したい場合は、ed nt!poolhittag 'galf' のようにリトルエンディアン形式に合わせて逆順にした 4 文字のタグ名を指定します。

設定が正しく反映されている場合、db nt!poolhittag L4 コマンドで指定したプールタグ名が設定されていることを確認できます。

PoolHitTag を設定する

ちなみに、PoolHitTag の設定を解除する場合 ed nt!poolhittag 0 コマンドを使用します。

PoolHitTag を設定した後に topsecret.exe を仮想マシンで実行すると、以下のように ExAllocateHeapPool 関数の途中でシステムが一時停止します。

この時の RAX レジスタには、指定したプールタグ名である flag が格納されていることを確認できます。

また、PoolHitTag にヒットした箇所でスタックバックトレースを参照すると、DoPDriver がこのプールタグと紐づくメモリ割り当てを要求していることを確認できます。

PoolHitTag でプールタグ flag の割り当てを検出する

そのため、関数の完了後までシステムを実行する gu コマンドを 2 回発行することで、DoPDriver が ExAllocatePoolWithTag 関数を実行した直後のレジスタを参照できるようになります。

ここから、前項と同じ手順で戻り値が格納される RAX レジスタよる、プールタグ名 flag と紐づけて割り当てたメモリブロックのアドレスを特定することができました。

割り当てたメモリブロックのアドレスを特定する

このような PoolHitTag を利用したメモリの割り当て元の特定は、カーネル空間のメモリリークなどのトラブルシューティングを行う際にも有用です。

6 章のまとめ

本章では、カーネルデバッガを使用した動的解析によって総当たりで正しいイメージファイル名を特定し、2 つ目の Flag を特定することができました。

カーネルデバッグは、デバッグ対象となるメモリ空間は膨大で、難解な OS のアーキテクチャに対する一定の理解も必要となるため、かなりハードルの高い技術といえるでしょう。

しかし、本章で紹介したような最低限の知識と基本的なコマンドを使用するだけでも、OS やドライバの動作を理解する助けとなり、プール領域のメモリリークなどのトラブルシューティングにも応用できるようになります。

もちろん、実際のカーネルデバッグでは、本章では扱っていないプロセスやユーザセッションのコンテキストやオブジェクトマネージャ、I/O マネージャの動作など、様々な点を考慮する必要がありますが、本書の内容がそのようなより高度な技術を習得するための足掛かりの 1 つになれば幸いです。

あとがき

本書を最後までお読みいただき誠にありがとうございます。

本書では、前著である 「Magical WinDbg -雰囲気で楽しむ Windows ダンプ解析とトラブルシューティング-」に引き続き、WinDbg を使用したユーザモードとカーネルドライバのライブデバッグのテクニックについて紹介しました。

Windows のユーザモードデバッグに関するナレッジは比較的豊富に存在していますが、カーネルデバッグに関する情報や最新の WinDbg で利用できる強力なスクリプティング機能に関する情報はあまり多くはありません。

そのため、本書では特に JavaScript ベースのデバッガスクリプトによる解析の自動化や、カーネルデバッグの入門に役立つ情報を紹介することを目的に執筆しました。

本書が、少しでもこれから WinDbg や Windows のカーネルデバッグに関心を持ち、取り組む方の助けになることを願っております。

改めて、本書をお読みいただきありがとうございました。

各章へのリンク