この章では、Windows カーネルデバッグを使用して DoPDriver を動的解析することで、2 つ目の Flag を特定します。
5 章で確認した通り、2 つ目の Flag を特定するためには、checkImageFileName 関数(アドレス 0x140001000 の関数)の検証をパスできるイメージファイル名を持つプロセスを起動する必要があります。
本章では、カーネルデバッグで DoPDriver の動的解析を行いつつ、総当たり攻撃で正しいイメージファイル名を特定します。
なお、Windows カーネルドライバを仮想マシンにロードし、カーネルデバッグを行う環境のセットアップ方法については 1 章を参照してください。
もくじ
- DriverEntry 関数の動的解析を行う
- PsSetCreateProcessNotifyRoutine で登録したコールバック関数の動的解析を行う
- ブレークポイント設定で総当たり攻撃により Flag を特定する
- checkImageFileName 関数の動的解析を行う
- JavaScript ベースのデバッガスクリプトでイメージファイル名を特定する
- ページプールの情報を確認する
- プールタグからメモリブロックのアドレスを特定する
- PoolHitTag を使用してメモリの割り当てアドレスを特定する
- 6 章のまとめ
- あとがき
- 各章へのリンク
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] の設定を [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 関数の逆アセンブル結果を参照できます。
5 章で確認した通り、DriverEntry 関数ではまずはじめに DeviceName などを引数として IoCreateDevice 関数でデバイスオブジェクトを作成しています。
ここではまず、IoCreateDevice 関数の引数として受け渡される UNICODE_STRING
構造体のオブジェクトである DeviceName の値と、ドライバオブジェクトにディスパッチルーチンとして設定される関数のアドレスをデバッガで特定します。
解析を行うため、call qword [rel IoCreateDevice]
のコードを実行する DoPDriver+0x1229
まで実行を進めます。
特定のアドレスまで実行を進める方法はいくつかありますが、今回は pa DoPDriver+0x1229
コマンドを使用して DoPDriver+0x1229
まで一気にステップ実行を進めました。
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 が定義されていることをデバッガでも確認することができました。
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 文字ずつ取り出す処理にあたります。
ブレークポイントを設定した後に 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) が保存されたことを確認できます。
DoPDriver+0x1050
で取り出した文字に対して何らかの演算を行った結果をハードコードされた整数値と比較するコードは DoPDriver+0x114a
でした。
そのため、次はこのアドレスにブレークポイントを設定し、ゼロフラグを改ざんすることで上記の検証をバイパスできるか試します。
bp DoPDriver+0x114a
コマンドでブレークポイントを設定した後、g コマンドで実行を再開します。
整数値の検証を行った直後の状態で r zf
コマンドを実行すると、ゼロフラグが 0 であることを確認できるため、文字 H は正しい Flag となるイメージファイル名の 1 文字目ではないことがわかります。
このままプログラムの実行を再開すると、検証に失敗したことになりループ処理が終了してしまうため、実行を再開する前に r zf=1
でゼロフラグを 1 に改ざんしておきます。
ゼロフラグを改ざんした状態で g コマンドを入力してシステムの実行を再開すると、ループ処理が継続し、もう一度 DoPDriver+0x1050
のブレークポイントにヒットします。
この時、ループは 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.exe
や ZZZZZZZZZ.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 というタグを持つページプールは存在していないことを確認できたので、次は 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 関数の引数を参照します。
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 関数は、プールメモリを割り当て、割り当てられたブロックへのポインタを返します。
この時割り当てられたブロックには、64 bit OS の場合 16 バイトの POOL_HEADER
構造体が付与されています。
そのため、dt nt!_POOL_HEADER @RAX-0x10
コマンドで、ExAllocatePoolWithTag 関数が割り当てたブロックのアドレスより 16 バイト手前のアドレスにアクセスすると、プールタグ flag を含むヘッダ情報が存在していることを確認できます。
また、特定のプールの割り当てに関する情報を参照できる !pool コマンド 3 でこのメモリブロックの情報を参照することでも、プールヘッダのアドレスやプールタグを確認することができます。
このコマンドを実行すると、引数として受け取ったアドレスがプールのブロック内に存在するかを確認し、存在する場合はプールヘッダのアドレスやプールタグ、またはそのプールに保存されているコンテンツなどを表示できます。
ExAllocatePoolWithTag 関数によるページプールの作成を確認できたので、念のため RAX レジスタに保持されていたメモリブロックのポインタアドレス(今回は 0xffff828292b6b270)を記録した後、g コマンドでシステムの処理を再開します。
システムの処理が再開されると、DoPDriver は自身が作成したページプールの領域に 2 つめの Flag 文字列を保存するはずです。
実際に、g コマンドで処理を再開した後に再度システムを一時停止し、先ほど特定したメモリブロックのアドレスを対象に !pool 0xffff828292b6b270 1
コマンドを実行したところ、プールタグ flag が割り当てられた領域に書き込まれているデータを表示できます。
しかし、このままでは 2 つ目の Flag 文字列が本当に書き込まれているか判断しづらいので、対象アドレスの Unicode 文字列を表示する du コマンドを使用し、du ffff828292b6b270
を実行することで正解の Flag である FLAG{The_important_process_is_topsecret.exe}
がメモリブロックに書き込まれていることを確認できました。
これで、2 つ目の正しい Flag を特定することができました。
プールタグからメモリブロックのアドレスを特定する
ExAllocatePoolWithTag 関数が割り当てたメモリブロックのアドレスを参照することで 2 つ目の正しい Flag を特定できましたが、必ずしも今回のように静的解析で特定のプールタグと紐づくプールを割り当てるコードを特定できるとは限りません。
そのため、ここからは静的解析でメモリ割り当てを行う実行コードを特定する以外のアプローチで、特定のプールタグと紐づくメモリブロックのアドレスを特定することを試みます。
プールタグからページプールのアドレスを特定可能な方法の 1 つとして、!poolfind コマンド 4 があります。
!poolfind コマンドでは、ページプールや非ページプールの領域から、特定のプールタグを持つインスタンスをすべて検索することができます。
例えば、以下のように !poolfind -tag "Proc"
コマンドを実行すると、Proc というタグ名のインスタンスのアドレスをページプールから検索できます。
しかし、多くの場合 !poolfind によるプールタグの検索には極めて長い時間がかかります。
また、詳細は不明ながらデバッガのハングアップやプールタグの検出漏れに関する情報も散見されるため、!poolfind によるプールタグの検索はあまり効率的ではありません。
そこで、プールタグの検索ではなく、PoolHitTag を使用して特定のプールタグを使用するメモリ割り当てを検出することで、ExAllocatePoolWithTag 関数の戻り値からメモリブロックのアドレスを特定します。
PoolHitTag を使用してメモリの割り当てアドレスを特定する
ライブデバッグを行う場合、特定のプールタグ名と紐づくメモリアドレスを特定する方法として、PoolHitTag を利用することができます。5
PoolHitTag は ed nt!poolhittag
コマンドで設定できます。
例えば、プールタグ名 flag を指定したい場合は、ed nt!poolhittag 'galf'
のようにリトルエンディアン形式に合わせて逆順にした 4 文字のタグ名を指定します。
設定が正しく反映されている場合、db nt!poolhittag L4
コマンドで指定したプールタグ名が設定されていることを確認できます。
ちなみに、PoolHitTag の設定を解除する場合 ed nt!poolhittag 0
コマンドを使用します。
PoolHitTag を設定した後に topsecret.exe を仮想マシンで実行すると、以下のように ExAllocateHeapPool 関数の途中でシステムが一時停止します。
この時の RAX レジスタには、指定したプールタグ名である flag が格納されていることを確認できます。
また、PoolHitTag にヒットした箇所でスタックバックトレースを参照すると、DoPDriver がこのプールタグと紐づくメモリ割り当てを要求していることを確認できます。
そのため、関数の完了後までシステムを実行する gu コマンドを 2 回発行することで、DoPDriver が ExAllocatePoolWithTag 関数を実行した直後のレジスタを参照できるようになります。
ここから、前項と同じ手順で戻り値が格納される RAX レジスタよる、プールタグ名 flag と紐づけて割り当てたメモリブロックのアドレスを特定することができました。
このような PoolHitTag を利用したメモリの割り当て元の特定は、カーネル空間のメモリリークなどのトラブルシューティングを行う際にも有用です。
6 章のまとめ
本章では、カーネルデバッガを使用した動的解析によって総当たりで正しいイメージファイル名を特定し、2 つ目の Flag を特定することができました。
カーネルデバッグは、デバッグ対象となるメモリ空間は膨大で、難解な OS のアーキテクチャに対する一定の理解も必要となるため、かなりハードルの高い技術といえるでしょう。
しかし、本章で紹介したような最低限の知識と基本的なコマンドを使用するだけでも、OS やドライバの動作を理解する助けとなり、プール領域のメモリリークなどのトラブルシューティングにも応用できるようになります。
もちろん、実際のカーネルデバッグでは、本章では扱っていないプロセスやユーザセッションのコンテキストやオブジェクトマネージャ、I/O マネージャの動作など、様々な点を考慮する必要がありますが、本書の内容がそのようなより高度な技術を習得するための足掛かりの 1 つになれば幸いです。
あとがき
本書を最後までお読みいただき誠にありがとうございます。
本書では、前著である 「Magical WinDbg -雰囲気で楽しむ Windows ダンプ解析とトラブルシューティング-」に引き続き、WinDbg を使用したユーザモードとカーネルドライバのライブデバッグのテクニックについて紹介しました。
Windows のユーザモードデバッグに関するナレッジは比較的豊富に存在していますが、カーネルデバッグに関する情報や最新の WinDbg で利用できる強力なスクリプティング機能に関する情報はあまり多くはありません。
そのため、本書では特に JavaScript ベースのデバッガスクリプトによる解析の自動化や、カーネルデバッグの入門に役立つ情報を紹介することを目的に執筆しました。
本書が、少しでもこれから WinDbg や Windows のカーネルデバッグに関心を持ち、取り組む方の助けになることを願っております。
改めて、本書をお読みいただきありがとうございました。
各章へのリンク
- まえがき
- 1 章 環境構築
- 2 章 DoPClient と DoPDriver の表層解析
- 3 章 DoPClient の静的解析を行う
- 4 章 DoPClient を動的解析する
- 5 章 DoPDriver の静的解析を行う
- 6 章 DoPDriver を動的解析する
-
pa (アドレスまでステップ実行) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/pa—step-to-address-
↩ -
!poolused https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/-poolused
↩ -
!pool https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/-pool
↩ -
!poolfind https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/-poolfind
↩ -
カーネルデバッガーを使用したカーネルモードメモリリークの検出 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debugger/using-the-kernel-debugger-to-find-a-kernel-mode-memory-leak
↩