本章では、DoPClient の静的解析により Flag の特定に必要な情報を収集します。
静的解析とは、プログラムを実際に実行することなく解析を行う手法のことです。
2 章で実施したファイルのメタデータなどの調査も定義によっては静的解析に分類されますが、本書ではプログラムを逆アセンブルして詳細な解析を行う手法を静的解析と呼称しています。
なお、本書では逆アセンブルを行うためのツールとして Binary Ninja を使用します。
もし、IDA や Ghidra など、他に使い慣れたツールがあればそちらを利用して問題ありません。
もくじ
- DoPClient を Binary Ninja で逆アセンブルする
- 関数の名前を変更する
- 条件分岐のコードを読む
- ループ処理のコードを読む
- 入力文字列の検証を行う
- カーネルドライバのロードを行う
- ドライバオブジェクトにアクセスする
- 3 章のまとめ
- 各章へのリンク
DoPClient を Binary Ninja で逆アセンブルする
まずは Binary Ninja を使用して DoPClient の逆アセンブルを行います。
「逆アセンブル」とは、機械語で表現されているプログラムを人間が読みやすいソースコード(アセンブリ)に復元する手法です。
現代では、人間がプログラムを作成する際は 0 と 1 のデータ列からなる機械語を直接入力して実行ファイルを作成することはまずありません。
通常は、人間が C 言語やアセンブリ言語などのソースコードをファイルとして保存し、コンパイラやリンカを用いてコンピュータが実行可能な機械語のファイルをビルドしています。
このようにして作成されたプログラムを再びソースコードに戻して人間が読めるようにする手法を「逆アセンブル」と呼びます。
コンパイラやリンカがプログラムをビルドする際には最適化のために実行コードの効率化やコメントの削除などが行われます。
そのため、ビルド後のプログラムを逆アセンブルして復元できるコードは、元のソースコードと完全に一致するものではない点に注意が必要です。
なお、本書では扱わない C 言語のソースコードから実行可能なプログラムをビルドする際の詳しいステップや、基本的な逆アセンブルの手法やアルゴリズムについては「実践バイナリ解析 バイナリ計装、解析、逆アセンブリのためのLinuxツールの作り方」1 にて詳しく解説されているため参考になります。
Binary Ninja を使用して逆アセンブルをする方法は非常に簡単で、インストールした Binary Ninja を起動して解析したいプログラムの実行ファイルをドラッグ&ドロップするだけです。
DoPClient.exe を Binary Ninja にロードすると、以下のような画面が表示されます。
DoPClient の逆アセンブル結果を表示するために、画面中央のプルダウンから、表示モードを [High Level IL] ではなく [Disassembly] に変更します。
これで、ビューに表示される解析結果が逆アセンブルされたアセンブリコードになりました。
次に、プログラムの main 関数のアドレスを特定し、逆アセンブル結果を参照します。
画面左側の [Symbols] ウィンドウで main 関数を探し、ダブルクリックすることで main 関数の逆アセンブル結果を参照できます。
最後に、プログラムの分岐などを追いやすくするため、表示モードを Linear モードから Graph モードに変更します。
画面中央のプルダウンから、表示モードを [Linear] ではなく [Graph] に変更することで、main 関数の逆アセンブル結果を Graph ビューで参照できるようになります。
関数の名前を変更する
main 関数の逆アセンブル結果を先頭から読んでみると、冒頭部分で sub_140001008
関数が繰り返し呼び出されていることがわかります。
sub_140001008
関数の呼び出し直前には、.data セクションから RCX レジスタに文字列がロードされています。
また、左側の [Cross Reference] ウィンドウの情報からも、sub_140001008
関数が何らかのメッセージテキストを引数として実行されていることがわかります。
ここから、sub_140001008
関数は printf のような関数である可能性が高いと予想することができます。
実際に sub_140001008
をダブルクリックしてsub_140001008
関数のアドレスにジャンプすると、コードの中で __stdio_common_vfprintf
が呼び出されていることがわかります。
__stdio_common_vfprintf
については詳細な情報がありませんが、名前から推測する限り、CRT ライブラリ関数で printf 関数と対応している vprintf 関数と関連がありそうです。
vprintf 関数:
https://learn.microsoft.com/ja-jp/cpp/c-runtime-library/vprintf-functions?view=msvc-170
また、実際にライブラリ関数 printf を含む C 言語のソースコードを Visual Studio でビルドすると sub_140001008
関数と類似の処理が呼び出されることを確認できます。
このことからも、sub_140001008
関数は printf 関数であり、RCX に格納している文字列を出力する処理を行っている可能性が高いと判断できます。
そこで、Binary Ninja 上で sub_140001008
関数を右クリックして [Rename Symbol] を選択し、関数名を printf に変更しておきます。
これで、Binary Ninja の UI 上で sub_140001008
関数が printf に置き換えられ、逆アセンブル結果を解析しやすくなります。
このように、シンボルのないファイルの解析を行う場合には、ツール上で関数や変数のリネームを行っていくと解析がスムーズになります。
条件分岐のコードを読む
printf 関数のリネームを行ってコードが読みやすくなったところで、引き続き冒頭部分のコードを読み進めていきます。
main 関数の冒頭から最初の条件分岐である 0x140001945 までのアセンブリコードは以下の通りです。
main:
mov qword [rsp+0x8 {__saved_rbx}], rbx
push rdi {__saved_rdi}
sub rsp, 0x80
mov rax, qword [rel __security_cookie]
xor rax, rsp {var_88}
mov qword [rsp+0x70 {var_18}], rax
xor eax, eax {0x0}
lea rcx, [rel data_14000342c]
xorps xmm0, xmm0
mov qword [rsp+0x60 {var_28}], rax {0x0}
movups xmmword [rsp+0x40 {var_48}], xmm0
mov dword [rsp+0x68 {var_20}], eax {0x0}
movups xmmword [rsp+0x50 {var_38}], xmm0
mov word [rsp+0x6c {var_1c}], ax {0x0}
mov byte [rsp+0x6e {var_1a}], al {0x0}
call printf
lea rcx, [rel data_140003430] {"DoP -The dream of a pumpkin-\n\n"}
call printf
{中略}
lea rcx, [rel data_1400036e0] {"Password: "}
call printf
xor ecx, ecx {0x0}
call qword [rel __acrt_iob_func]
mov edx, 0x2f
lea rcx, [rsp+0x40 {var_48}]
mov r8, rax
call qword [rel fgets]
xor edi, edi {0x0}
test rax, rax
je 0x140001a8b
まず、前半の部分は関数のプロローグとプログラムのタイトルや ASCII アートを出力するだけの処理ですので無視できます。
以降のコードで着目したいのは、0x140001919 の lea rcx, [rel data_1400036e0] {"Password: "}
から、0x140001945 の je 0x140001a8b
までの処理です。
ここでは、Password:
という文字列を出力した後に入力を待機し、fgets 関数で標準入力から 47(0x2f) バイト分の入力を受け取っていることがわかります。
fgets で受け取った文字列は lea rcx, [rsp+0x40 {var_48}]
で RCX に格納したスタック領域のアドレスに書き込まれます。
その後、fgets 関数の戻り値が格納される RAX レジスタを使用して以下の条件分岐が実装されています。
test rax, rax
je 0x140001a8b
これは、if-else の条件分岐を持つコードをコンパイルすると頻繁に登場するアセンブリコードです。
この 2 行のコードでは、RAX の値が 0 か否かを比較し、RAX が 0 の場合は je が指すアドレスにジャンプします。2
まず、test 命令は、2 つのオペランドの AND を取り、その結果でフラグレジスタを書き換える命令です。
test rax, rax
の命令を実行した場合、RAX が 0 であればフラグレジスタのゼロフラグ(ZF)が 1 になり、それ以外の場合は 0 になります。
続く je はゼロフラグ(ZF) が 1 の場合に指定のアドレスにジャンプする命令なので、ここでは fgets 関数の戻り値が 0 の場合(= fgets 関数による入力値の受け取りに失敗した場合)に 0x140001a8b の命令を実行するという条件分岐が実装されていることになります。
以上を踏まえた上で、Binary Ninja の Graph ビューを見てみます。
この条件分岐で 0x140001a8b の命令にジャンプした場合、Good Bye
という文字列が出力された後、exit 関数でプログラムが終了します。
つまり、この条件分岐では fgets 関数による入力値の受け取りに成功したかどうかを確認し、失敗した場合にはプログラムを終了することがわかりました。
プログラムの動作を解析する場合には、このように条件分岐とその前後の動作に着目し、どのような操作によってどのような処理が実行されるのかを調べていくことが 1 つの重要なポイントといえます。
ループ処理のコードを読む
続けて、fgets 関数で入力値の受け取りに成功した後の処理、つまり 0x14000194b 以降のコードを読んでいきます。
Binary Ninja の Graph ビューは以下の通りです。
まずはじめに、0x14000194b では lea rcx, [rsp+0x40 {var_48}]
により RCX レジスタに RSP+0x40 のアドレスを格納しています。
すでに確認した通り、SP+0x40 のアドレスが指すスタック領域には fgets 関数で受け取った入力値が格納されています。
そのため、ローカル変数 var_48 を inputText などの名前に変更しておくと解析がスムーズになると思います。
続く命令では、この関数内で最初のループ処理が実行されています。
14000194b lea rcx, [rsp+0x40 {inputText}]
140001950 or rax, 0xffffffffffffffff
// 以下、ループ処理
140001954 inc rax
140001957 cmp byte [rcx+rax], dil {inputText}
14000195b jne 0x140001954
このアセンブリコードは少々トリッキーに思えるかもしれませんが、この中では以下のようなループ処理が行われています。
- はじめに、RCX に 0 を格納します。(RAX を OR 演算で 0xffffffffffffffff に置き換えてから 1 回インクリメントすることで、ループ処理開始時の RCX レジスタに格納される値が 0 になります)
- 次に、cmp 命令で
RCX+RAX
、つまり inputText[i] の値が dil の値(ここでは NULL) と一致するかどうかを比較します。 - inputText[i] の値が dil の値と一致しない場合は、0x140001954 の処理にジャンプして RAX レジスタの値をインクリメントします。
このループ処理では、inputText[i] の値が NULL 文字になるまで RAX をインクリメントし続ける処理を行っています。
つまり、このループ処理が終わった後には、「fgets 関数で受け取った入力値のサイズ + 1」分の値が RAX に格納されることになります。
ここまで読めたところで、以降の処理と合わせてもう一度ループ処理を含むアセンブリコードを見てみましょう。
14000194b lea rcx, [rsp+0x40 {inputText}]
140001950 or rax, 0xffffffffffffffff
// ループ処理の開始
140001954 inc rax
140001957 cmp byte [rcx+rax], dil {inputText}
14000195b jne 0x140001954
// ループ処理が終了した際、RAX には「fgets 関数で受け取った入力値のサイズ + 1」分の値が格納されている
14000195d dec rax
140001960 cmp rax, 0x2f
140001964 jae 0x140001aa0
14000196a mov byte [rsp+rax+0x40 {inputText}], dil {0x0}
0x140001954 から 0x14000195b にかけての処理が「fgets 関数で受け取った入力値のサイズ + 1」分の値になるまで RAX をインクリメントし続ける処理であることが理解できれば、この一連の処理が以下のようなシンプルなコードで表現できることも理解できると思います。
inputText[strlen(inputText) - 1] = 0;
このコードでは、fgets 関数で受け取った入力値の最後の 1 バイトを NULL 文字に置き換えています。
つまり、ここでは、fgets 関数で受け取った入力値から改行文字を削除していることがわかります。
ちなみに、Binary Ninja の解析モードをデコンパイル(Pseudo C)に変更すると、この一連のコードは以下のようなループを含む疑似コードに置き換えられました。
do
{
i = (i + 1);
} while (*(uint8_t*)(&inputText + i) != 0);
if ((i - 1) >= 0x2f)
{
_lockexit();
breakpoint();
}
*(uint8_t*)(&inputText + (i - 1)) = 0;
なお、同様の構造はこの直後に実行される 0x14000196a から 0x140001985 までのコードにも存在していることがわかります。
14000196a mov byte [rsp+rax+0x40 {inputText}], dil {0x0}
14000196f lea rcx, [rsp+0x40 {inputText}]
140001974 or rax, 0xffffffffffffffff
140001978 inc rax
14000197b cmp byte [rcx+rax], dil {inputText}
14000197f jne 0x140001978
140001981 cmp rax, 0x2d
140001985 jne 0x140001a76
14000198b lea rcx, [rsp+0x40 {inputText}]
140001990 call sub_140001360
0x14000196a から 0x14000197f までのループ処理では、先ほどと同じく fgets 関数で受け取った文字列の長さをカウントしています。
そして、0x140001981 からのコードでは、受け取った文字列の長さが 45(0x2d) 文字であるかを確認し、条件分岐を行っています。
入力文字列の検証を行う
入力文字列の長さが 45 文字の場合、RCX レジスタに格納した入力文字を引数として sub_140001360
関数を呼び出します。
Binary Ninja の Graph ビューで前後のコードを参照すると、sub_140001360
関数の戻り値が格納される RAX レジスタの値を test eax, eax
で検証し、RAX に 0 が格納されている場合には Password is Correct
という文字列を出力することがわかります。
つまり、sub_140001360
関数は fgets 関数で受け取った入力文字列(パスワード)を検証し、正しいパスワードの場合には 0 を返す関数であると予想できます。
そこで、sub_140001360
関数を checkPassword 関数にリネームし、さらに詳細な解析を行うことにします。
Binary Ninja の Graph ビューで sub_140001360
関数(checkPassword 関数)をダブルクリックすると、対象の関数のコードを参照することができます。
checkPassword 関数の全体像を Graph ビューで参照すると、処理が複雑に分岐しており、一見して解析が容易ではなさそうな印象を受けます。
もちろんこの一見複雑なコードについても、逆アセンブル結果やデコンパイルした疑似コードの解析に慣れている場合には、静的解析のみで詳細な動作を容易に解析できます。
しかし、checkPassword 関数についてはより解析をスムーズに行うため、4 章でデバッガを使用して動的解析を行うことにします。
そのため、この章では一旦 checkPassword 関数の解析は飛ばして、引き続き main 関数の静的解析を続けていくことにします。
カーネルドライバのロードを行う
checkPassword 関数で入力文字列の検証に成功した場合、DoPClient は Password is Correct
と Clear Stage1
という文字列を順に出力します。
このことから、1 つ目の Flag は checkPassword 関数の検証を突破できる正しいパスワードであると考えられます。
checkPassword 関数の解析と Flag の特定は 4 章で行います。
checkPassword 関数によるパスワードの検証に成功した後のコードを参照すると、0x1400019b5 で sub_140001148
関数を実行した後、戻り値が格納されている RAX レジスタの値を検証して条件分岐を行っていることがわかります。
sub_140001148
関数の戻り値が NULL ではない場合の分岐では Driver loaded
という文字列が出力され、もう一方の分岐では Driver load failed
が出力された後にプログラムが終了するようです。
ここから、sub_140001148
関数はカーネルドライバをシステムにロードする関数であり、ドライバのロードに成功すると戻り値として NULL 以外の値が返却される可能性が高いと推測できます。
そのため、sub_140001148
関数を loadDriver などの名前に変更しておきます。
loadDriver 関数の詳しい実装については DoPClient と DoPDriver を解析して Flag を取得する上では把握する必要はあまりありませんが、Binary Ninja のデコンパイル結果を参照して簡単に参照しておくことにします。
以下は、loadDriver 関数のデコンパイル結果の抜粋です。
SC_HANDLE loadDriver()
{
void var_4a8;
int64_t rax_1 = (__security_cookie ^ &var_4a8);
SC_HANDLE rax_2 = OpenSCManagerW(nullptr, nullptr, 2);
/* 中略 */
void var_438;
uint32_t rax_4;
int64_t rdx_2;
rax_4 = GetModuleFileNameA(nullptr, &var_438, 0x104);
/* 中略 */
char* rax_5 = strrchr(&var_438, 0x5c);
/* 中略 */
enum ENUM_SERVICE_TYPE var_488 = 0x40003390;
void lpBinaryPathName;
sub_14000105c(&lpBinaryPathName, 0x208, "%s\%s", &var_438);
SC_HANDLE hSCObject = OpenServiceA(rax_2, "DoPDriver", 0xf003f);
/* 中略 */
PSTR var_468;
__builtin_memset(&var_468, 0, 0x28);
uint32_t* lpdwTagId;
PSTR lpDependencies;
PSTR lpServiceStartName;
PSTR lpPassword;
SC_HANDLE rax_6 = CreateServiceA(rax_2, "DoPDriver", "DoPDriver", 0x10030, SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, SERVICE_ERROR_IGNORE, &lpBinaryPathName, var_468, lpdwTagId, lpDependencies, lpServiceStartName, lpPassword);
/* 中略 */
BOOL rax_8;
int64_t rdx_4;
rax_8 = StartServiceW(rax_6, 0, nullptr);
if (rax_8 != 0)
{
printf("Service started successfully\n", rdx_4);
CloseServiceHandle(rax_2);
__security_check_cookie((rax_1 ^ &var_4a8));
return rax_6;
}
printf("StartService failed (%d)\n", ((uint64_t)GetLastError()));
/* 中略 */
}
いくつか詳細が不明な関数がありますが、OpenSCManager 関数 3 でサービスコントロールマネージャデータベースへのハンドル(SC_HANDLE rax_2
)を取得し、OpenService 関数 4、CreateService 関数 5 で DoPDriver というサービスを登録しているようです。
Windows システムにカーネルドライバをロードする場合は、ユーザモードのサービスと同じく CreateService API を利用する 6 ため、この関数では恐らく DoPDriver.sys ファイルを DoPDriver サービスとして登録していると思われます。
カーネルドライバの実装やロードに関する詳しい内容は、「Windows Kernel Programming, Second Edition」6 などの書籍が参考になります。
CreateService 関数の戻り値として取得した登録したサービスのハンドル(SC_HANDLE rax_6
)は、StartServiceW 関数 7 の引数として与えられ、DoPDriver サービスの起動に成功した場合には、サービスのハンドルが戻り値として返却されることがわかります。
ドライバオブジェクトにアクセスする
以下は DoPDriver のロードに成功した後に実行されるコードです。
0x140001a06 では、\\\\.\\DoPDriver
というパスや、その他いくつかの値を引数として CreateFileW 関数 8 を実行しています。
CreateFileW 関数は以下の値を引数として受け取り、指定されたファイルやデバイスなどのハンドルを戻り値として返します。
HANDLE CreateFileW(
[in] LPCWSTR lpFileName,
[in] DWORD dwDesiredAccess,
[in] DWORD dwShareMode,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
[in] DWORD dwCreationDisposition,
[in] DWORD dwFlagsAndAttributes,
[in, optional] HANDLE hTemplateFile
);
Windows のユーザモードプロセスはカーネルドライバのデバイスオブジェクトに直接アクセスすることができません。
そのため、ユーザモードで動作するアプリケーションからカーネルドライバにアクセスできるようにする必要がある場合、カーネルドライバは \GLOBAL\??
ディレクトリ内にシンボリックリンクを作成し、\Device
ディレクトリ内のデバイスオブジェクトの名前とリンクさせます。9
カーネルドライバがドライバオブジェクトのシンボリックリンクを \GLOBAL\??
ディレクトリ内に登録していることは、WinObj などのツールで確認することが可能です。
ユーザモードプログラムでは、このシンボリックリンクと CreateFileW 関数を利用してドライバオブジェクトへのハンドルを取得することでカーネルドライバへのアクセスが可能になります。
CreateFileW 関数でデバイスオブジェクトに対するシンボリックリンクを指定する場合には、I/O マネージャがカレントフォルダ内の DoPDriver というファイルだと誤認しないように、\\\\.\\DoPDriver
のように先頭に \\\\.\\
を付与する必要があります。10
CreateFileW 関数により DoPDriver のハンドルを取得した後に実行されるコードは以下です。
call qword [rel CreateFileW]
mov rdi, rax
cmp rax, 0xffffffffffffffff
je 0x140001a28
lea rcx, [rel data_140003760] {"Please input key to close.\n"}
call printf
call _getch
mov rcx, rdi
call qword [rel CloseHandle]
{省略}
ここで、プログラムは Please input key to close
という文字列を出力した後、ユーザの入力を待機するようです。
そして、getch 関数で任意の入力を受け取ると、取得したハンドルをクローズした後、ロードしたカーネルドライバの削除を行い、プログラムの実行を終了します。
CreateFileW 関数で取得したデバイスオブジェクトについてはその後使用していないため、2 つ目の Flag を取得するには DoPDriver を解析する必要がありそうです。
DoPDriver の解析は 5 章と 6 章で扱います。
3 章のまとめ
3 章では、ユーザモードプログラムである DoPClient の静的解析を行いました。
Binary Ninja を使用してプログラムを逆アセンブルした結果から、DoPClient は以下のような動作を行うことを確認しました。
- fgets 関数で標準入力からパスワードの文字列を受け取ります。
- 入力された文字列が 45 文字か検証します。
- 入力されたパスワードの文字列を checkPassword 関数(
sub_140001360
) で検証します。このとき、checkPassword 関数による検証をパスする正しいパスワードが 1 つ目の Flag になりそうです。 - 正しいパスワードが入力された場合、DoPDriver.sys をカーネルドライバとしてシステムにロードします。
- DoPDriver のドライバオブジェクトのハンドルを CreateFileW 関数で取得します。
- ユーザからの任意の入力を待機し、プログラムを終了します。
4 章では、WinDbg を使用して動的解析で checkPassword 関数の挙動を解析し、1 つ目の Flag となる正しいパスワードを特定していきます。
各章へのリンク
- まえがき
- 1 章 環境構築
- 2 章 DoPClient と DoPDriver の表層解析
- 3 章 DoPClient の静的解析を行う
- 4 章 DoPClient を動的解析する
- 5 章 DoPDriver の静的解析を行う
- 6 章 DoPDriver を動的解析する
-
実践バイナリ解析 バイナリ計装、解析、逆アセンブリのためのLinuxツールの作り方(Dennis Andriesse 著 / 株式会社クイープ,遠藤美代子 訳 / KADOKAWA / 2022 年)
↩ -
詳解セキュリティコンテスト CTF で学ぶ脆弱性攻略の技術 P.375 (梅内 翼, 清水 祐太郎, 藤原 裕大, 前田 優人, 米内 貴志, 渡部 裕 著 / マイナビ出版 / 2021 年)
↩ -
OpenSCManagerW 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/winsvc/nf-winsvc-openscmanagerw
↩ -
OpenServiceA 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/winsvc/nf-winsvc-openservicea
↩ -
CreateServiceA 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/winsvc/nf-winsvc-createservicea
↩ -
Windows Kernel Programming, Second Edition P.27 (Pavel Yosifovich 著 / Independently published / 2023 年)
↩ -
StartServiceW 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/winsvc/nf-winsvc-startservicew
↩ -
CreateFileW 関数 https://learn.microsoft.com/ja-jp/windows/win32/api/fileapi/nf-fileapi-createfilew
↩ -
インサイド Windows 第 7 版 上 P.558 (Pavel Yosifovich, Alex Ionescu, Mark E. Russinovich, David A. Solomon 著 / 山内 和朗 訳 / 日系 BP 社 / 2018 年)
↩ -
Windows Kernel Programming, Second Edition P.52 (Pavel Yosifovich 著 / Independently published / 2023 年)
↩