All Articles

Magical WinDbg VOL.2【4 章 DoPClient を動的解析する】

この章では、DoPClient の checkPassword 関数を WinDbg を使用して動的解析することで、1 つ目の Flag を特定します。

動的解析とは、プログラムを実際に実行した際の挙動やメモリ、レジスタなどの情報から解析を行う手法です。

WinDbg などのデバッガを利用してプログラムの実行を中断し、メモリやレジスタの情報を参照(または変更)しつつ解析を行う手法はもちろん、プログラム実行中のプロセスモニターログやネットワークトレースなどを取得することも動的解析に該当します。

なお、本書では主に WinDbg を使用して解析を行います。

もくじ

WinDbg でプログラム実行し、ブレークポイントを設定する

一般的に、WinDbg などのデバッガを使用してユーザモードプログラムのデバッグを行う際には、「デバッガを使用してプログラムを実行する」か「実行中のプロセスにデバッガをアタッチする」方法が使用されます。

今回は「デバッガを使用してプログラムを実行する」で解析を行います。

DoPClient.exe を配置した仮想マシンで WinDbg を起動したら、[ファイル]>[Start Debugging] から [Launch executable(advanced)] をクリックします。

[Executable] 欄には、仮想マシンに配置した DoPClient.exe のパスを指定します。

また、[Start Directory] にはプログラムの実行フォルダを指定します。

今回、1 つ目の Flag を特定する上では [Start Directory] の指定は必要ありませんが、解析対象のプログラムがプログラムの実装によっては [Start Directory] にプログラムの実行フォルダを適切に指定しておく必要があります。

WinDbg で DoPClient を起動する

上記の指定をした後、[Record with Time Travel Debugging] のチェックは解除したまま [Debug] ボタンをクリックすることでプログラムが起動し、デバッガがアタッチされます。

Time Travel Debugging(TTD) 1 はプロセスの実行トレースをキャプチャすることで、プログラム実行中のレジスタやメモリの状態を自由に解析することができる強力な機能ですが、本書では使用しません。

デバッガによりユーザモードアプリケーションが起動すると、ユーザモードのシステム DLL である ntdll.dll 内に存在するイメージローダ(Ldr)の途中で処理が一時停止し、デバッガにアタッチされます。2

この時点では、プログラムの main 関数内で実装されたコードはまだ実行されていません。

WinDbg によるデバッグ開始直後の画面

プログラムの実行を再開する前に、3 章で確認したパスワードを検証する checkPassword 関数の呼び出しアドレスにブレークポイントを設定しておきます。

14000198b  lea     rcx, [rsp+0x40 {inputText}]
140001990  call    checkPassword

WinDbg を使用する場合、ブレークポイントは様々な方法で設定可能 3 ですが、今回はシンプルに bp コマンドを使用して指定のアドレスにブレークポイントを設定します。

なお、ほとんどの Windows プログラムは実行時にプログラムの実行コードが再配置されるため、bp コマンドでブレークポイントを設定する際に指定可能な仮想アドレス(VA)が毎回変動します。

そのため、ブレークポイントを設定する場合は仮想アドレスを直接指定するのではなく、実行プログラムのイメージベースアドレスに相対仮想アドレス(RVA)を加算したものを指定する方が便利です。

例えば、既定の設定の Binary Ninja 上で 0x140001990 と表示されるアドレスにブレークポイントを設定したい場合は、以下のように DoPClient(または !DoPClient) でアクセスできるプログラムのイメージベースアドレスに相対仮想アドレス 0x1990 を加算したものを指定します。

bp DoPClient+0x1990

上記のコマンドでブレークポイントを設定した後に、g コマンドでプログラムの実行を再開すると、DoPClient がユーザにパスワードの入力を求める画面が表示されます。

そこで、3 章で確認した通り AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA のように 45 文字の文字列を入力して Enter キーを送信すると、DoPClient+0x1990 のアドレスで再び実行が中断され、デバッガからの操作が可能になることを確認できます。

checkPassword 関数の呼び出し箇所にブレークポイントを設定する

ちなみに、現在適用されているブレークポイントの設定は bl コマンド、または Breakpoints ウィンドウで確認することができます。

また、WinDbg の Disassembly ウィンドウを起動すると、現在の実行コードが黄色に、そしてブレークポイントとして設定したコードが赤くマーキングされるため、ここからもブレークポイントの設定を確認できます。

Disassembly ウィンドウからブレークポイント前後のコードを参照する

なお、実際に解析結果を見てみるとわかる通り、WinDbg の Disassembly ウインドウや u、ub コマンドなどで参照できる逆アセンブル結果は Binary Ninja などの解析ツールによるものよりも可読性が低いです。

そのため、WinDbg でシンボルのないプログラムのデバッグを行う際には、3 章で使用したような別の解析ツールを併用することをおすすめします。

レジスタとスタック、メモリの情報を読み取る

WinDbg で checkPassword 関数の呼び出しアドレスにブレークポイントを設定したところで、レジスタやスタック、メモリの情報を参照する方法を簡単に紹介します。

まずレジスタの状態ですが、r コマンド 4 で確認することが可能です。

r コマンドを実行すると、現在のスレッドコンテキストに紐づくレジスタの状態が表示されます。

また、r raxr zf コマンドで特定のレジスタ、フラグの状態のみを参照することや、r eax = 10 コマンドで特定のレジスタの値を任意に置き換えることも可能です。

0:000> r
rax=000000000000002d rbx=0000028219807970 rcx=00000087212ffa70
rdx=00007fff7cc0f490 rsi=0000000000000000 rdi=0000000000000000
rip=00007ff6dd091990 rsp=00000087212ffa30 rbp=0000000000000000
 r8=000000000000002e  r9=0000028219812fd0 r10=0000000000000058
r11=0000000000000246 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0         nv up ei pl zr na po nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
DoPClient+0x1990:
00007ff6`dd091990 e8cbf9ffff      call    DoPClient+0x1360 (00007ff6`dd091360)

0:000> r rax
rax=000000000000002d

ちなみに、ユーザモードプログラムのデバッグ中に他のスレッドコンテキストと紐づくレジスタの状態を参照したい場合、スレッド番号を使用して ~2 r コマンドを実行するか、~* r コマンドですべてのスレッドのレジスタの状態を参照できます。

次は、Display Memory(d、da、db、dc、dd、dD、df、dp、dq、du、dw) コマンド 5 で指定のアドレスのメモリの情報を参照してみます。

3 章で確認した通り、fgets 関数で受け取った入力文字列はスタック領域に格納されており、その先頭アドレスは RSP+0x40 です。

以下は、RSP+0x40 のアドレスを様々なコマンドで参照した場合の出力結果です。

RSP+0x40 のメモリアドレスを参照する

特に、指定のアドレスの ASCII テキストを参照する da RSP+0x40 コマンドを実行した場合には、入力した 45 文字の文字列を取得できていることがわかります。

du RSP+0x40 コマンドを実行した場合には、指定のアドレスのデータは Unicode 文字列として解釈されています。(UTF-16 Encoding で 0x4141 の文字)

今回のような ASCII テキストではなく、Unicode テキストがメモリ内に存在する場合は、da ではなく du コマンドを使用します。

なお、今回の入力文字列はスタック領域に直接書き込まれています。

スタック領域の値は上記の Display Memory コマンドでも参照可能ですが、スタックに格納されている値については実行中のアーキテクチャのポインタサイズに合わせて取得する方が解析が容易です。

また、スタック領域にはしばしば実行コードへのポインタアドレスが格納されます。

そのため、スタック領域の参照には特定のメモリ内の値をポインタサイズに合わせた値と、解決可能な場合にはシンボル情報を表示できる dps コマンド 6 をよく使用します。

実際に dps rsp コマンドを実行してみると、RSP+0x40 以降の領域に文字 A(0x41) が格納されていることを確認できます。

0:000> dps rsp
000000f9`7ecff6d0  00007ff6`0000000a
000000f9`7ecff6d8  000002be`0c567970
000000f9`7ecff6e0  00007fff`7cc0f490 ucrtbase!iob
000000f9`7ecff6e8  00000000`00000000
000000f9`7ecff6f0  00000000`00000002
000000f9`7ecff6f8  00007fff`7cb41d56 ucrtbase!_set_new_mode+0x16
000000f9`7ecff700  00000000`00000000
000000f9`7ecff708  00000000`00000000
000000f9`7ecff710  41414141`41414141
000000f9`7ecff718  41414141`41414141
000000f9`7ecff720  41414141`41414141
000000f9`7ecff728  41414141`41414141
000000f9`7ecff730  41414141`41414141
000000f9`7ecff738  00000041`41414141
000000f9`7ecff740  00008243`49734515
000000f9`7ecff748  00000000`00000000

デバッガでプログラムの実行を再開する

設定したブレークポイントで一時停止したプログラムの処理は g コマンド 7 で再開することができます。

また、ステップ実行とトレース実行はそれぞれ p コマンド(または F10 キー) 8 と t コマンド(または F11 キー) 9 が対応しています。

p コマンドは WinDbg の GUI で操作する場合の [Step Over] の操作と対応しています。

そのため、例えば Call 命令の行で p コマンドを実行した場合には、Call 命令の次の行の命令まで実行を進めます。

一方で、t コマンドは [Step Into] の操作と対応しています。

Call 命令の行で実行した場合には、p コマンドとは異なり呼び出し先の関数の先頭アドレスで処理を停止します。

他にも、指定のアドレスまでステップ実行またはトレースを続ける pa、ta コマンドや、次の分岐、リターン命令まで実行を続ける ph、th、pt、tt、また次の Call 命令まで実行する pc、tc コマンドなど、状況に応じてステップ実行やトレースコマンドを使い分けられるようにしておくと便利です。

checkPassword 関数を静的解析する

最低限のデバッガコマンドを紹介したところで、いよいよ checkPassword 関数を解析して Flag を特定していきます。

まずは 3 章と同じ手順で、Binary Ninja を使用して checkPassword 関数のアドレスにアクセスして静的解析を行います。

そして、今度は Graph ビューではなく Linear ビューに表示を変更し、[Pseudo C] からデコンパイルされた疑似コードを確認します。

Binary Ninja で checkPassword 関数のデコンパイル結果を確認する

このデコンパイル結果から構造部分のみ抜粋したコードは以下の通りです。

int64_t checkPassword(char* arg1)

{
    void var_f8;
    int64_t rax_1 = (__security_cookie ^ &var_f8);
    char* r10 = arg1;
    int64_t rax_2 = -1;
    do
    {
        rax_2 = (rax_2 + 1);
    } while (arg1[rax_2] != 0);
    int32_t rcx = 0;
    if (rax_2 != 0x2d)
    {
        exit(0);
        /* no return */
    }

    int32_t i = 0;
    int128_t var_d8;
    int32_t* r11 = &var_d8;
    do
    {
        uint64_t rdx_1 = ((uint64_t)((int32_t)*(uint8_t*)r10));
        if (i > 0x16)
        {
            if (i > 0x22)
            {
                if (i == 0x29)
                {
                    rcx = ((int32_t)(rdx_1 + 0x5f6));
                }
                else if (i == 0x2a)
                {
                    rcx = ((rdx_1 * 0x21) + 0x23);
                }
                /* 中略 */
                switch (i)
                {
                    case 0x23:
                    {
                        rcx = ((rdx_1 * 0x35) + 0xdab8);
                        break;
                    }
                    /* 中略 */
                }
            }
            /* 中略 */
        }

        /* 中略 */

        __builtin_memcpy(&var_d8, "<ハードコードされたバイト列>", 0xb4);

        if (rcx != *(uint32_t*)r11)
        {
            printf("Password is Wrong\n", rdx_1);
            exit(0);
        }

        i = (i + 1);
        r10 = &r10[1];
        r11 = &r11[1];
    } while (i < 0x2d);
    __security_check_cookie((rax_1 ^ &var_f8));
    return 0;
}

0x14000138a から 0x140001392 までに実行されている以下のコードは 3 章で確認した文字数の検証コードとほとんど同じコードであることがわかります。

char* r10 = arg1;
int64_t rax_2 = -1;
do
{
    rax_2 = (rax_2 + 1);
} while (arg1[rax_2] != 0);

int32_t rcx = 0;
if (rax_2 != 0x2d)
{
    exit(0);
}

checkPassword 関数は、第 1 引数(arg1)としてスタックに格納された入力文字列のポインタアドレスを受け取っています。

この文字列の長さをループ処理で取得した後に、文字列の長さが 45(0x2d) 文字であることを再確認しています。

文字列の長さの検証を突破すると、0x14000139b から 0x1400017dd までの間に、複雑な条件分岐を含むループ処理が実装されていることがわかります。

int32_t i = 0;
int128_t var_d8;
int32_t* r11 = &var_d8;
do
{
    uint64_t rdx_1 = ((uint64_t)((int32_t)*(uint8_t*)r10));
    if (i > 0x16)
    {
        if (i > 0x22)
        {
            if (i == 0x29)
            {
                rcx = ((int32_t)(rdx_1 + 0x5f6));
            }
            /* 中略 */
        }
        /* 中略 */
    }
    /* 中略 */

    __builtin_memcpy(&var_d8, "<ハードコードされたバイト列>", 0xb4);
    if (rcx != *(uint32_t*)r11)
    {
        printf("Password is Wrong\n", rdx_1);
        exit(0);
    }

    i = (i + 1);
    r10 = &r10[1];
    r11 = &r11[1];

} while (i < 0x2d);

このループ処理の実装をよく見ると、45(0x2d) 回のループ処理の中で、毎回 if (rcx != *(uint32_t*)r11) というコードで何かが検証され、一致しない場合は Password is Wrong という文字列が出力されることがわかります。

また、ここで比較されている RCX レジスタには rcx = ((int32_t)(rdx_1 + 0x5f6)); などの行で加工された rdx_1 の値が格納されているようです。

rdx_1 には uint64_t rdx_1 = ((uint64_t)((int32_t)*(uint8_t*)r10)) の行で、ループ処理ごとに 1 文字ずつ取得される入力文字列が格納されています。

また、r11 には、ハードコードされたバイト列 var_d8 から順番に取り出した int32 型の整数値が格納されています。

つまり、checkPassword 関数では、ループ処理の中で入力文字列を先頭から順に取り出し、複雑な条件分岐の後に何らかの演算を行った上で、ハードコードされたバイト列と一致するかを確認し、すべての文字の検証に成功した場合にのみ 0 を返却する関数であると推測できます。

checkPassword 関数を動的解析する

それでは、上記の推測が正しいか実際にデバッガを使用して確認してみましょう。

まずはコマンドウィンドウで以下のコマンドを順に実行します。

.restart
bp !DoPClient+0x13a2 ; bp !DoPClient+0x17ca ; g

.restart コマンドを実行すると、デバッグ対象のプログラムが再起動され、ブレークポイントなどの設定もリセットされます。

そして、bp !DoPClient+0x13a2 ; bp !DoPClient+0x17ca ; g では、ワンライナーで複数のブレークポイントを 2 つ設定した後、プログラムの実行を再開しています。

このように、WinDbg ではセミコロン(;)で連結することで複数のコマンドをまとめて実行できます。

上記のコマンドでは、ループ処理の中で入力文字から 1 文字を抜き出すコードと、入力文字に何らかの演算を行った結果とハードコードされた整数値を比較するコードの 2 箇所にブレークポイントを設定しています。

プログラムを実行して 45 文字の A(0x41) を入力すると、まずはじめに入力文字列から 1 文字を取り出すコード movsx edx,byte ptr [r10] の実行直前のタイミングでプログラムが一時停止します。

この時点では、 EDX レジスタには入力された文字ではなく 0x7cc0f490 という値が格納されています。

0:000> g
Breakpoint 0 hit
DoPClient+0x13a2:
00007ff6`dd0913a2 410fbe12  movsx   edx,byte ptr [r10] ds:000000bd`b9cffc80=41

0:000> r edx
edx=7cc0f490

ここで、p コマンドでプログラムをステップ実行すると、r10 に格納されているアドレスから 1 文字目の A(0x41) が EDX に格納されたことを確認できました。

0:000> p
DoPClient+0x13a6:
00007ff6`dd0913a6 4183f816        cmp     r8d,16h

0:000> r edx
edx=41

次に、もう 1 度 g コマンドでプログラムの実行を再開すると、2 つ目のブレークポイント DoPClient+0x17ca で実行が一時停止します。

0:000> g
Breakpoint 1 hit
DoPClient+0x17ca:
00007ff6`dd0917ca 413b0b          cmp     ecx,dword ptr [r11] ds:00000055`a5effc90=000021a7

この時点では、ECX レジスタには整数値 2076 が格納されているものの、比較対象の R11 レジスタが指すポインタアドレスには、整数値 8615 が格納されています。

0:000> r ecx
ecx=2076

0:000> dd r11 L1 ; ? $p
00000055`a5effc90  000021a7
Evaluate expression: 8615 = 00000000`000021a7

そのため、最初の入力文字が A(0x41) の場合には、if (rcx != *(uint32_t*)r11) のコードでの検証に失敗し、 Password is Wrong という出力とともにプログラムが終了してしまいます。

ちなみに、ここで実行している dd r11 L1 ; ? $p というコマンドは WinDbg の疑似レジスタ 10 を利用しています。

疑似レジスタとは、デバッガ内で特定の値を保持するために使用される値です。

そして、疑似レジスタ $p には、直前の Display Memory コマンドが出力した値が格納されます。

そのため、dd r11 L1 コマンドで取得した R11 レジスタが指す DWORD サイズの値を ? $p で評価させることで、0x21a7 を 10 進数の整数値 8615 として出力させています。

このような疑似レジスタを利用した記法は、ワンライナーコマンドやスクリプティングを行う際に応用できるので、覚えておくと便利です。

正しい文字列を入力した場合の動作を解析する

パスワード文字の検証を行っていると思われる箇所にブレークポイントをセットしたものの、AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA を入力文字とした場合には検証をパスすることができませんでした。

そこで次は、正しい Flag フォーマットを満たす 45 文字の文字列 FLAG{ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm} を入力値として再度プログラムを実行してみます。

すると、最初のブレークポイントのコード(movsx edx,byte ptr [r10])で EDX レジスタに格納される値が A(0x41) から F(0x46) に変化しました。

0:000> g
Breakpoint 0 hit
DoPClient+0x13a2:
00007ff6`dd0913a2 410fbe12        movsx   edx,byte ptr [r10] ds:0000001a`ed4ffaf0=46

0:000> p
DoPClient+0x13a6:
00007ff6`dd0913a6 4183f816        cmp     r8d,16h

0:000> r edx
edx=46

そして、2 つ目のブレークポイントに設定した行では、ECX レジスタの値と R11 レジスタが指す整数値が一致していることから、1 文字目が F(0x46) の場合には最初の検証をパスすることができることがわかりました。

0:000> g
Breakpoint 1 hit
DoPClient+0x17ca:
00007ff6`dd0917ca 413b0b          cmp     ecx,dword ptr [r11] ds:0000001a`ed4ff9d0=000021a7

0:000> r ecx
ecx=21a7

0:000> dd r11 L1 ; ? $p
0000001a`ed4ff9d0  000021a7
Evaluate expression: 8615 = 00000000`000021a7

このことからも、checkPassword 関数では 45(0x2d) 回のループ処理の中で、入力された文字列を 1 文字ずつ取り出して何らかの演算を行った値と、ハードコードされた整数値が一致するかどうかを検証しているという仮説が正しそうであることがわかります。

その後も処理を継続してみると、if (rcx != *(uint32_t*)r11) のコードで行われる検証に 5 回パスした後、6 回目の検証に失敗してプログラムが終了しました。

入力文字列のうち、FLAG{ という最初の 5 文字が正しいパスワードと一致したために、5 回の検証に成功し、6 回目で失敗した可能性が高そうです。

JavaScript ベースのデバッグスクリプトを使用する

正しい Flag を取得するため、DoPClient がパスワードを検証する際の動作についてもう少し詳しく調べてみましょう。

具体的には、Flag の検証が入力文字列の先頭から 1 文字ずつ行われているかどうかを特定したいです。

そのためには、デバッガを使用して以下の操作を行う必要があります。

  1. 入力文字列から 1 文字を取り出した直後の DoPClient+0x13a6 にブレークポイントを設定して、45 回のループ処理の中で文字列が先頭から取り出されているかを確認する。
  2. 入力文字に何らかの演算を行った結果を比較した直後の DoPClient+0x17cd にブレークポイントを設定して、検証にパスするかどうかを確認する。
  3. 入力文字で検証に失敗した場合は、プログラムを終了させずループ処理を継続するためにゼロフラグの値を改ざんする。

上記の操作を行うため、今回は WinDbg のコマンド操作を自動化してみます。

WinDbg では、デバッグ操作を自動化する方法はいくつか用意されていますが、まずは最新の WinDbg で利用できる JavaScript ベースのデバッガスクリプト 11 を使用します。

最新の WinDbg では既定で JavaScript ベースのデバッガスクリプトを使用できますが、念のためスクリプトプロバイダがデバッガにロードされていることを確認します。

現在起動中の WinDbg で .scriptproviders コマンドを実行し、JavaScript (extension '.js') が一覧に表示されている場合は、スクリプトプロバイダがデバッガにロードされていると判断できます。

0:000> .scriptproviders
Available Script Providers:
    NatVis (extension '.NatVis')
    JavaScript (extension '.js')

JavaScript ベースのデバッガスクリプトを使用する場合、事前に作成した JavaScript ファイルを、.scriptload または .scriptrun コマンドで読み取る必要があります。

実際に問題バイナリの解析用のスクリプトを作成する前に、以下のサンプルスクリプトを実行してみましょう。

"use strict";

function initializeScript()
{
    host.diagnostics.debugLog("RunCommands>; initializeScript was called \n");
}

function invokeScript()
{
    host.diagnostics.debugLog("RunCommands>; invokeScript was called \n");
}

function uninitializeScript()
{
    host.diagnostics.debugLog("RunCommands>; uninitialize was called\n");
}

function RunCommands(cmd)
{
	var ctl = host.namespace.Debugger.Utility.Control;   
	var output = ctl.ExecuteCommand(cmd);
	host.diagnostics.debugLog("RunCommands> Displaying command output \n");

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

	host.diagnostics.debugLog("RunCommands> Exiting RunCommands Function \n");
}

このサンプルスクリプトは、以下の Github リポジトリからもダウンロードできます。


RunCommands.js:

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


このサンプルスクリプトを C:\CTF\RunCommands.js として仮想マシンに保存したら、WinDbg で .scriptrun コマンドを実行してみましょう。

.scriptrun コマンドは、スクリプトをロードし、ルートコード、そして initializeScript と invokeScript 関数を順に実行するコマンドです。

そのため、.scriptrun C:\CTF\RunCommands.js を実行すると、以下のように initializeScript と invokeScript 関数内で定義した debugLog 関数が実行されるとともに、スクリプトがデバッガにロードされたことを .scriptlist コマンドで確認できるようになります。

0:000> .scriptrun C:\CTF\RunCommands.js
RunCommands>; initializeScript was called 
JavaScript script successfully loaded from 'C:\CTF\RunCommands.js'
RunCommands>; invokeScript was called

0:000> .scriptlist
Command Loaded Scripts:
    {中略}
    JavaScript script from 'C:\CTF\RunCommands.js'
Other Clients' Scripts:
    <None Loaded>

次に、.scriptunload C:\CTF\RunCommands.js コマンドでスクリプトをアンロードした後に、今度は .scriptload C:\CTF\RunCommands.js コマンドでスクリプトをロードしてみます。

.scriptunload C:\CTF\RunCommands.js コマンドが実行された場合には、アンロードルーチンとして uninitializeScript 関数が実行されます。

そして、.scriptload コマンドを実行した場合には、initializeScript 関数のみが実行されます。

0:000> .scriptunload C:\CTF\RunCommands.js
RunCommands>; uninitialize was called
JavaScript script unloaded from 'C:\CTF\RunCommands.js'

0:000> .scriptload C:\CTF\RunCommands.js
RunCommands>; initializeScript was called 
JavaScript script successfully loaded from 'C:\CTF\RunCommands.js'

スクリプトファイルのロードに成功したので、独自に実装した関数 RunCommands をデバッガから実行します。

この関数は引数として受け取ったデバッガコマンドを WinDbg で実行し、出力結果をコンソール上に表示する関数です。

function RunCommands(cmd)
{
	var ctl = host.namespace.Debugger.Utility.Control;   
	var output = ctl.ExecuteCommand(cmd);
	host.diagnostics.debugLog("RunCommands> Displaying command output \n");

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

	host.diagnostics.debugLog("RunCommands> Exiting RunCommands Function \n");
}

ロードした関数にデバッガからアクセスするには dx コマンドを使用します。

RunCommands 関数を実行するには、dx Debugger.State.Scripts.RunCommands.Contents.RunCommands("<コマンド>") を実行します。

以下は、RunCommands("lm")RunCommands("~k") を実行した結果です。

WinDbg のコマンドウィンドウから直接 lm や ~k コマンドを実行した場合の出力と同じ結果を得られることがわかります。

0:000> dx Debugger.State.Scripts.RunCommands.Contents.RunCommands("lm")
RunCommands> Displaying command output 
  start             end                 module name
  00007ff6`dd090000 00007ff6`dd099000   DoPClient C (no symbols)           
  00007fff`675f0000 00007fff`6760d000   VCRUNTIME140   (deferred)             
  00007fff`7cb20000 00007fff`7cc20000   ucrtbase   (deferred)             
  00007fff`7cc20000 00007fff`7cf16000   KERNELBASE   (deferred)             
  00007fff`7d080000 00007fff`7d0a7000   bcrypt     (deferred)             
  00007fff`7dc90000 00007fff`7ddb3000   RPCRT4     (deferred)             
  00007fff`7deb0000 00007fff`7df4e000   msvcrt     (deferred)             
  00007fff`7e610000 00007fff`7e6b0000   sechost    (deferred)             
  00007fff`7e970000 00007fff`7ea2d000   KERNEL32   (deferred)             
  00007fff`7eb90000 00007fff`7ec40000   ADVAPI32   (deferred)             
  00007fff`7f130000 00007fff`7f328000   ntdll      (pdb symbols)          C:\ProgramData\Dbg\sym\ntdll.pdb\1669C503FDE3540E0A2FBE91C81204361\ntdll.pdb
RunCommands> Exiting RunCommands Function 
Debugger.State.Scripts.RunCommands.Contents.RunCommands("lm")

0:000> dx Debugger.State.Scripts.RunCommands.Contents.RunCommands("~k")
RunCommands> Displaying command output 
   # Child-SP          RetAddr               Call Site
  00 00000024`05cff758 00007fff`7f1fca0e     ntdll!DbgBreakPoint
  01 00000024`05cff760 00007fff`7e987344     ntdll!DbgUiRemoteBreakin+0x4e
  02 00000024`05cff790 00007fff`7f1826b1     KERNEL32!BaseThreadInitThunk+0x14
  03 00000024`05cff7c0 00000000`00000000     ntdll!RtlUserThreadStart+0x21
RunCommands> Exiting RunCommands Function 
Debugger.State.Scripts.RunCommands.Contents.RunCommands("~k")

ちなみに、カスタム関数を実行するたびに Debugger.State.Scripts.RunCommands.Contents.RunCommands("<コマンド>) を実行するのは不便です。

そのため、dx コマンドでロードしたスクリプトの情報(Debugger.State.Scripts.RunCommands.Contents)をカスタム変数として登録すると便利です。

RunCommands.js を変数として登録するには、dx @$runCommand = Debugger.State.Scripts.RunCommands.Contents コマンドを実行します。

これで、dx @$runCommand.RunCommands("lm")dx @$runCommand.RunCommands("~k") を実行することで、カスタム関数 RunCommands を実行できるようになりました。

0:000> dx @$runCommand = Debugger.State.Scripts.RunCommands.Contents
@$runCommand = Debugger.State.Scripts.RunCommands.Contents                 : [object Object]
    host             : [object Object]

0:000> dx @$runCommand.RunCommands("lm")
RunCommands> Displaying command output 
  start             end                 module name
  00007ff6`dd090000 00007ff6`dd099000   DoPClient C (no symbols)           
  00007fff`675f0000 00007fff`6760d000   VCRUNTIME140   (deferred)             
  00007fff`7cb20000 00007fff`7cc20000   ucrtbase   (deferred)             
  00007fff`7cc20000 00007fff`7cf16000   KERNELBASE   (deferred)             
  00007fff`7d080000 00007fff`7d0a7000   bcrypt     (deferred)             
  00007fff`7dc90000 00007fff`7ddb3000   RPCRT4     (deferred)             
  00007fff`7deb0000 00007fff`7df4e000   msvcrt     (deferred)             
  00007fff`7e610000 00007fff`7e6b0000   sechost    (deferred)             
  00007fff`7e970000 00007fff`7ea2d000   KERNEL32   (pdb symbols)          C:\ProgramData\Dbg\sym\kernel32.pdb\B07C97792B439ABC0DF83499536C7AE51\kernel32.pdb
  00007fff`7eb90000 00007fff`7ec40000   ADVAPI32   (deferred)             
  00007fff`7f130000 00007fff`7f328000   ntdll      (pdb symbols)          C:\ProgramData\Dbg\sym\ntdll.pdb\1669C503FDE3540E0A2FBE91C81204361\ntdll.pdb
RunCommands> Exiting RunCommands Function 
@$runCommand.RunCommands("lm")

0:000> dx @$runCommand.RunCommands("~k")
RunCommands> Displaying command output 
   # Child-SP          RetAddr               Call Site
  00 00000024`05cffb08 00007fff`7f1fca0e     ntdll!DbgBreakPoint
  01 00000024`05cffb10 00007fff`7e987344     ntdll!DbgUiRemoteBreakin+0x4e
  02 00000024`05cffb40 00007fff`7f1826b1     KERNEL32!BaseThreadInitThunk+0x14
  03 00000024`05cffb70 00000000`00000000     ntdll!RtlUserThreadStart+0x21
RunCommands> Exiting RunCommands Function 
@$runCommand.RunCommands("~k")

スクリプトでデバッガ操作を自動化する

JavaScript ベースのデバッガスクリプトを利用できるようになったので、前述した以下の操作を自動化するスクリプトを作成します。

  1. 入力文字列から 1 文字を取り出した直後の DoPClient+0x13a6 にブレークポイントを設定して、45 回のループ処理の中で文字列が先頭から取り出されているかを確認する。
  2. 入力文字に何らかの演算を行った結果を比較した直後の DoPClient+0x17cd にブレークポイントを設定して、検証にパスするかどうかを確認する。
  3. 入力文字で検証に失敗した場合は、プログラムを終了させずループ処理を継続するためにゼロフラグの値を改ざんする。

上記の操作を自動化するため、以下の JavaScript を作成しました。

// .scriptrun C:\CTF\Autorun.js
// FLAG{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
"use strict";

let word = "";
let a1 = [];

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("DoPClient", 0x13a6);
	breakpoint.Command = "dx -r1 @$autoRun.CheckPoint1() ; g";

	breakpoint = ctl.SetBreakpointAtOffset("DoPClient", 0x17cd);
	breakpoint.Command = "dx -r1 @$autoRun.CheckPoint2() ; r zf = 1 ; g";

	return;
}

function Result() {
	for (let line of a1) {
		host.diagnostics.debugLog("", line, "\n");
	}
	return;
}

function CheckPoint1() {
	let context = host.namespace.Debugger.State.DebuggerVariables.curthread.Registers.User;
	let edxValue = context.edx;
	word = String.fromCodePoint(edxValue);
	return;
}

function CheckPoint2() {
	let context = host.namespace.Debugger.State.DebuggerVariables.curthread.Registers.User;
	var memory = host.memory;
	let edxValue = context.ecx;
	let r11Value = context.r11;
	let int32Value = memory.readMemoryValues(r11Value, 1, 4);
	a1.push("Result: Word is " + word + " EDX = " + edxValue.toString() + " R11 = " + int32Value.toString() + " IsValid = " + (parseInt(edxValue) == parseInt(int32Value)).toString());
	return;
}

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

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

このコードは、以下の Github リポジトリから Autorun.js としてダウンロードできます。


Autorun.js:

https://github.com/kash1064/ctf-and-windows-debug/blob/main/Autorun.js


このコードを .scriptrun C:\CTF\Autorun.js コマンドでロードすると、はじめに initializeScript 関数と invokeScript 関数が実行されます。

initializeScript 関数では、new host.apiVersionSupport(1, 7) にて使用する API のバージョンを宣言しています。

そして、invokeScript 関数では、@$autoRun オブジェクトの定義と、SetBreakPoints 関数の実行を行っています。

SetBreakPoints 関数では Debugger.Utility.Control の SetBreakpointAtOffset を使用して DoPClient+0x13a6DoPClient+0x17cd にブレークポイントを設定しています。

また、Command 要素を指定することで、ブレークポイントに到達した際に実行するコマンドを指定しています。

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

	breakpoint = ctl.SetBreakpointAtOffset("DoPClient", 0x13a6);
	breakpoint.Command = "dx -r1 @$autoRun.CheckPoint1() ; g";

	breakpoint = ctl.SetBreakpointAtOffset("DoPClient", 0x17cd);
	breakpoint.Command = "dx -r1 @$autoRun.CheckPoint2() ; r zf = 1 ; g";

	return;
}

このスクリプトが実行された後に bl コマンドを実行すると、コマンド付きのブレークポイントが定義されたことを確認できます。

スクリプトで設定したブレークポイント

なお、Debugger.Utility.Control.SetBreakpointAtOffset などの便利なコマンドについては、現在のところあまりドキュメントは充実していません。

しかし、WinDbg で dx コマンドを使用して dx -r1 Debugger.Utility.Control などによってオブジェクトの情報を参照することで、各コマンドについて詳細な説明を参照することができます。

0:000> dx -r1 Debugger.Utility.Control
              
ExecuteCommand   [ExecuteCommand(command) - Method which executes a debugger command and returns a collection of strings representing the lines of output of the command execution]

SetBreakpointAtSourceLocation [SetBreakpointAtSourceLocation(source_file, source_line, (opt) module_name) - Method which sets a breakpoint at a source line location and returns it as an object in order to be able to control its options]

SetBreakpointAtOffset [SetBreakpointAtOffset(function_name, function_offset, (opt) module_name) - Method which sets a breakpoint at an offset and returns it as an object in order to be able to control its options]

SetBreakpointForReadWrite [SetBreakpointForReadWrite(address, (opt) type, (opt) size) - Method which sets a breakpoint on read/write (default) at a certain address and returns it as an object in order to be able to control its options]

ChangeRegisterContext [ChangeRegisterContext(inheritUnspecifiedValues, pc, sp, [fp], [regCtx]) | ChangeRegisterContext(inheritUnspecifiedValues, regCtx) - Changes the active register context with a given abstract pc, sp, and optional fp or an optional object which contains named registers and values.  This is largely equivalent to having done .cxr in the debugger.  It does *NOT* change the register values of the thread/processor, only the debugger's current view of them]

WalkStackForRegisterContext [WalkStackForRegisterContext(inheritUnspecifiedValues, pc, sp, [fp], [regCtx]) | WalkStackForRegisterContext(inheritUnspecifiedValues, regCtx) - Performs a stack walk given the incoming abstract pc, sp, and optional fp or an optional object which contains named registers and values.  The returned stack walk is of the same form as <ThreadObject>.Stack]

このデバッガスクリプトをロードした後、プログラムを実行して FLAG{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} のような 45 文字のダミー Flag を入力すると、最初のブレークポイント DoPClient+0x13a6 に到達し、dx -r1 @$autoRun.CheckPoint1() ; g が実行されます。

ここで実行される CheckPoint1 関数では、一時停止したタイミングの EDX レジスタの値を ASCII 文字としてグローバル変数 word に格納します。

本章でここまでに解析した結果が正しければ、この word にはユーザが入力したパスワード文字列が 1 文字ずつ格納されるはずです。

function CheckPoint1() {
	let context = host.namespace.Debugger.State.DebuggerVariables.curthread.Registers.User;
	let edxValue = context.edx;
	word = String.fromCodePoint(edxValue);
	return;
}

最初のブレークポイントに設定しているコマンド dx -r1 @$autoRun.CheckPoint1() ; g では、上記の CheckPoint1 関数を実行した後 g コマンドでプログラムの実行が再開されるため、プログラムは一時停止しません。

そのため、すぐに 2 つ目のブレークポイントの DoPClient+0x17cd に到達し、dx -r1 @$autoRun.CheckPoint2() ; r zf = 1 ; g が実行されます。

そして、次に実行される CheckPoint2 関数では以下の通り、DoPClient+0x17cd 時点の ECX レジスタの値と、R11 レジスタが指すアドレスの 32 bit 整数をそれぞれ取得し、その結果を a1 配列に追加します。

また、JavaScript ベースのデバッガスクリプトでは、特定のメモリアドレスに存在する値は、host.memory.readMemoryValues(location, numElements, [elementSize], [isSigned], [contextInheritor]) 12 で取得できます。

つまり、memory.readMemoryValues(r11Value, 1, 4) では、R11 レジスタに格納されているポインタアドレスから 4 バイト分の値を 1 つの要素として取得しています。

function CheckPoint2() {
	let context = host.namespace.Debugger.State.DebuggerVariables.curthread.Registers.User;
	var memory = host.memory;
	let edxValue = context.ecx;
	let r11Value = context.r11;
	let int32Value = memory.readMemoryValues(r11Value, 1, 4);
	a1.push("Result: Word is " + word + " EDX = " + edxValue.toString() + " R11 = " + int32Value.toString() + " IsValid = " + (parseInt(edxValue) == parseInt(int32Value)).toString());
	return;
}

先ほど確認した通り、2 つ目のブレークポイントでは、CheckPoint2 関数が実行された後に r zf = 1 コマンドが実行されます。

これによって、直前の cmp ecx, dword [r11] での比較結果に関わらずゼロフラグに 1 がセットされるため、入力文字が正しいかどうかに関わらず最後までループが継続されます。

実際に、プログラムの実行が完了した後に dx -r1 @$autoRun.Result() を実行してみると、以下のように CheckPoint2 関数で収集した情報が出力されます。

スクリプト実行結果

この結果から、ユーザが入力した文字列 FLAG{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA} が先頭から 1 文字ずつ検証されており、かつ先頭の FLAG{ までと末尾の } のみがパスワードの検証に成功していることを確認できます。

つまり、入力文字を先頭から 1 文字ずつ総当たりし、cmp ecx, dword [r11] での検証に成功する文字をデバッガで特定することで、正しい Flag を特定できそうだということがわかります。

総当たり攻撃で正しい Flag を特定する

ここまでの解析結果から、正しいパスワード文字列の長さは 45 文字であることがわかっています。

また、Flag フォーマットが ^FLAG\{[\x20-\x7E]+\}$ であることから、先頭から順に総当たりで Flag を特定する場合に必要な総当たりの回数は、最大で 39 文字 × 出力可能な ASCII 文字の数(0x7E-0x20) で 3666 回程度になることが予想されます。

当然これだけの回数のデバッガ操作を手動で行うことは現実的ではありませんので、デバッガ操作による総当たり攻撃を自動化したいです。

しかし、今回のようにパスワードの総当たりのためにプログラムの再実行が必要な操作は、前項の JavaScript ベースのデバッガスクリプトなど、WinDbg にロードするタイプのスクリプトとは相性が悪いです。

いくつか方法はありますが、今回は外部から Python スクリプトでプログラムの実行とデバッガ操作を自動化することにしました。

本書ではプログラムの実行とデバッガ操作を自動化するため、少々力業ですが、専用のインターフェースではなく、コマンドラインで利用できる cdb.exe というデバッガを Python スクリプトから利用します。

cdb.exe は Windows SDK に含まれる CLI ベースのデバッガで、WinDbg と同じデバッガコマンドを実行することができます。

DoPClient から総当たりで Flag を取得するため、以下の Python スクリプトを作成しました。

import subprocess

cdb_path = "C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x64\\cdb.exe"
exe_path = "C:\\CTF\\DoPClient.exe"
flag_path = "C:\\CTF\\input.txt"
script_path = "C:\\CTF\\script.txt"

dbg_cmd = f"$$< {script_path}"

with open(script_path,"w") as f:
    cmd = ""
    cmd += "g ;"
    cmd += "p ;"
    cmd += ".if (@zf == 1) { .printf \"Solver: R8 is %d\\n\", @r8 ; " + f"$$< {script_path}" + " } .else { .echo \"Fail.\" ; .kill }"
    f.write(cmd)

command = f"\"{cdb_path}\" -G -o -kqm -c \"bp !DoPClient+0x17ca ; {dbg_cmd}\" \"{exe_path}\" < \"{flag_path}\""

i = 4
flag = r"FLAG{"
while(i < 44):
    for j in range(0x20,0x7e):
        with open(flag_path,"w") as f:
            word = ""
            word += flag
            word += chr(j)
            word += "A"*(45 - (len(flag)+1))
            word += "\n"
            f.write(word)

        process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = process.communicate()

        print("")
        print("============================================================")

        for line in stdout.decode("utf-8").split("\n"):
            if line.startswith("Solver:"):
                print(line)
            
            if line.startswith("Solver: R8 is"):
                t = int(line.split(" ")[-1])

        print("============================================================")

        if t > i:
            i = t
            flag += chr(j)
            print(f"Flag: {flag}, i: {i}")
            break
        
print(flag)

このスクリプトは、以下のリポジトリから Stage1_Solver_1.py としてダウンロードできます。


Stage1_Solver_1.py

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


このスクリプトを実行すると Python の標準ライブラリ関数である subprocess.Popen を使用して、はじめに以下のコマンドが実行されます。

"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe" -G -o -kqm -c "bp !DoPClient+0x17ca ; $$< C:\CTF\script.txt" "C:\CTF\DoPClient.exe" < "C:\CTF\input.txt"

このコマンドでは、-G -o -kqm をオプション引数 13 として cdb.exe を起動し、C:\CTF\DoPClient.exe のデバッグを開始します。(総当たりするパスワードは "C:\CTF\input.txt" からプログラムにリダイレクトしています)

また、-c "bp !DoPClient+0x17ca ; $$< C:\CTF\script.txt" では実行時に !DoPClient+0x17ca にブレークポイントを設定するとともに、デバッガスクリプト C:\CTF\script.txt を実行します。

ここで使用しているデバッガスクリプトは、前項の JavaScript ベースのデバッガスクリプトとは異なり、手動で実行可能なデバッガコマンドをスクリプト化したものです。

WinDbg や CDB では複数のデバッグコマンドを記述したデバッガスクリプトをファイルからロードして実行できる機能があります。14

上記の Python スクリプトでは、以下のデバッグコマンドを C:\CTF\script.txt として保存した後、$$< C:\CTF\script.txt コマンドでスクリプトを自動実行しています。

g ;p ;.if (@zf == 1) { .printf "Solver: R8 is %d\n", @r8 ; $$< C:\CTF\script.txt } .else { .echo "Fail." ; .kill }

このコマンドでは、まず g により DoPClient+0x17ca のブレークポイントに到達するまでプログラムの実行を継続します。

DoPClient+0x17ca では、入力文字に対して何らかの演算を行った結果が格納されている ECX レジスタの値と、R11 レジスタが指すアドレスにハードコードされている 32bit の整数値が比較されます。

ここで、入力文字が正しい文字の場合には検証に成功してゼロフラグが 1 となります。

1400017ca  cmp     ecx, dword [r11]
1400017cd  jne     0x1400017fa

1400017cf  inc     r8d
1400017d2  inc     r10
1400017d5  add     r11, 0x4
1400017d9  cmp     r8d, 0x2d
1400017dd  jl      0x1400013a2

その後に実行される .if (@zf == 1) { 中略 } .else { 中略 } の構文では、この検証を行った直後のゼロフラグが 1 であるかを比較しています。

正しい文字が入力されている場合は、cmp ecx, dword [r11] の実行後にゼロフラグが 1 になるため、.printf "Solver: R8 is %d\n", @r8 ; $$< C:\CTF\script.txt というデバッガコマンドが実行されます。

このコマンドでは、R8 レジスタの値を %d に代入し、Solver: R8 is %d\n という文字列を表示した後、もう一度デバッガスクリプト C:\CTF\script.txt で定義したコマンドが実行されます。

つまり、プログラムの実行を再開して次の文字の検証結果を確認する操作を、 45 文字すべての検証に成功するか、誤った文字が入力されるまで繰り返し実行することになります。

前述の通り、この Python スクリプトではデバッガコマンドを指定して cdb.exe によるデバッグを繰り返し行うことで入力文字列を先頭から総当たりしています。

正しいパスワード文字の検証に成功した場合には Solver: R8 is から始まる文字列が標準出力に与えられるため、この文字列の出力有無から正しい入力文字を特定することができます。

実際にこのスクリプトを実行すると、完了まで非常に長い時間がかかるものの、最終的に FLAG{You_can_use_debug_script_in_WinDbg_Next} が正しい 1 つ目の Flag であることを特定することができます。

Solver を実行して Flag を特定する

このスクリプトで特定した Flag(正しいパスワード)をコマンドプロンプトから DoPClient に入力してみると、Clear Stage1 という文字列が表示されるコードにたどり着くことができました。

そのため、ここで特定した 1 つ目の Flag は正しい Flag で間違いないと判断できます。

DoPClient に正しいパスワードを入力する

なお、上記の画面では Clear Stage1 という文字列は表示されているものの、OpenSCManager 関数を使用してのドライバの登録には失敗しているようです。(DoPClient の動作については 3 章を参照してください)

1 章に記載の手順で仮想マシンでテスト署名モードを有効化し、DoPClient.exe と DoPDriver.sys を同じフォルダ内に配置した上で、管理者権限で起動したコマンドプロンプトから DoPClient を実行することで、以下のように正しいパスワードを入力した際に DoPDriver がシステムにロードできるようになります。

DoPClient がドライバをロードすることを確認する

総当たり攻撃を行うスクリプトを高速化する

前項で使用した Stage1_Solver_1.py を使用することで正しい Flag を取得できましたが、このスクリプトで Flag を完全に特定するためには非常に長い時間がかかります。

実際に総当たりに要する時間は環境に依存しますが、私の環境で実行した際には概ね 40 分程度の時間を要しました。

最終的に正しい Flag を特定できるのであれば実行に要する時間は問題ではないと思われるかもしれませんが、CTF では Flag を取得するためのスクリプトの実行時間が長いとライバルに先を越されてしまったり、スクリプトのバグに気づくのが遅れる要因になったりするなど、様々なデメリットがあります。

そのため、このように総当たりで Flag を特定する必要がある問題を解く場合には、スクリプトの実行時間を短縮するためのより良いアルゴリズムを検討することも重要です。

例えば、前項で使用した Stage1_Solver_1.py では、先頭から 1 文字ずつ総当たりで検証することで正しいパスワードを特定しています。

Flag のフォーマットは ^FLAG\{[\x20-\x7E]+\}$ であり、正しいパスワードの長さは 45 文字であることがわかっているので、総当たりに必要な最悪の検証回数は 3705 回になります。15

今回のスクリプトでは 1 回の総当たりごとに DoPClient プログラムを再起動する必要があり、1 文字の検証当たりのオーバーヘッドがかなり大きいです。

そのため、少しでも検証回数を減らすことができれば、実行時間の大幅な短縮につながりそうです。

そこで、総当たりの実行を高速化するために、スクリプトを以下のように書き換えました。

import subprocess

cdb_path = "C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x64\\cdb.exe"
exe_path = "C:\\CTF\\DoPClient.exe"
flag_path = "C:\\CTF\\input.txt"
script_path = "C:\\CTF\\script.txt"

dbg_cmd = f"$$< {script_path}"

with open(script_path,"w") as f:
    cmd = ""
    cmd += "g ;"
    cmd += "p ;"
    cmd += ".if (@zf == 1) { .printf \"Solver: Correct word in %d\\n\", @r8 ; " + f"$$< {script_path}" + " } .else { r zf=1 ; " + f"$$< {script_path}" + "}"
    f.write(cmd)

command = f"\"{cdb_path}\" -G -o -kqm -c \"bp !DoPClient+0x17ca ; {dbg_cmd}\" \"{exe_path}\" < \"{flag_path}\""


flag = ["" for i in range(45)]

print("============================================================")

for i in range(0x20,0x7e):
    with open(flag_path,"w") as f:
        word = chr(i)*45
        word += "\n"
        f.write(word)

    process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()

    for line in stdout.decode("utf-8").split("\n"):          
        if line.startswith("Solver: Correct word"):
            t = int(line.split(" ")[-1])
            flag[t] = chr(i)
            print(f"flag[{t}] is {chr(i)}")

print("============================================================")

print("".join(flag))

このスクリプトは、以下のリポジトリから Stage1_Solver_2.py としてダウンロードできます。


Stage1_Solver_2.py

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


新しく作成したスクリプトでは、デバッガでゼロフラグの値を 1 に改ざんすることでパスワードの検証コードをバイパスできることを利用し、最大 95 回(Printable な ASCII 文字の数)の実行で正しい Flag を取得できるように処理が効率化されています。

このスクリプトでは、実行するデバッガコマンドスクリプトには以下のコマンドを以下の通り変更しています。

g ;p ;.if (@zf == 1) { .printf "Solver: Correct word in %d\n", @r8 ; $$< C:\CTF\script.txt } .else { r zf=1 ; $$< C:\CTF\script.txt}

cmp ecx, dword [r11] の実行後のゼロフラグが 1 の場合(入力文字が正しい場合)の処理は前回とほぼ同じですが、ゼロフラグが 0 の場合(入力文字が正しくない場合)のコマンドが大きく変更されています。

前項では、入力文字が正しくなかった場合には .kill コマンドでプログラムのデバッグを終了し、もう一度新しい総当たりのテストを開始していました。

一方で、今回のスクリプトでは r zf=1 コマンドでゼロフラグを 1 に改ざんすることで、入力文字の検証結果に関わらず、プログラムの処理を最後まで継続させることができます。

つまり、1 度のプログラムの実行につき、特定の文字が正しいパスワードに合致するか否かを、45 文字すべてに対してまとめて検証することができます。

これにより、前項で使用したスクリプトでは総当たりのために最悪 3705 回のプログラムの実行が必要だったのに対し、今回のスクリプトでは最大 95 回の実行で正しい Flag を特定できるようになります。

実際にこのスクリプトを実行すると、およそ 1 分程度で正しい Flag を特定でき、前項で使用したスクリプトと比較して数十倍の高速化に成功しました。

高速化した Solver を実行して Flag を特定する

このように、デバッガコマンドを利用してプログラムの動作を任意に変更することで、簡単な変更で総当たりなどの攻撃を効率化できる場合があります。

4 章のまとめ

この章では、JavaScript ベースのデバッグスクリプトや cdb.exe を利用した外部スクリプトからのデバッガコマンド実行を使用して、総当たり攻撃で未知のパスワードを特定しました。

ここまではユーザモードアプリケーションである DoPClient の解析を行ってきましたが、5 章からはカーネルドライバモジュールである DoPDriver の解析に踏み込んでいきます。

各章へのリンク


  1. Time Travel Debugging https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/time-travel-debugging-overview

  2. インサイド Windows 第 7 版 上 P.171 (Pavel Yosifovich, Alex Ionescu, Mark E. Russinovich, David A. Solomon 著 / 山内 和朗 訳 / 日系 BP 社 / 2018 年)

  3. ブレークポイントを制御するメソッド https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debugger/methods-of-controlling-breakpoints

  4. r (レジスタ) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/r—registers-

  5. d、da、db、dc、dd、dD、df、dp、dq、du、dw (メモリの表示) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/d—da—db—dc—dd—dd—df—dp—dq—du—dw—dw—dyb—dyd—display-memor

  6. dds、dps、dqs (ワードとシンボルの表示) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/dds—dps—dqs—display-words-and-symbols-

  7. g (実行) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/g—go-

  8. p (ステップ実行) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/p—step-

  9. t (トレース) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/t—trace-

  10. 疑似レジスタの構文 https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/pseudo-register-syntax

  11. JavaScript デバッガーのスクリプト https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debugger/javascript-debugger-scripting

  12. JavaScript 拡張機能のネイティブデバッガオブジェクト https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debugger/native-objects-in-javascript-extensions-debugger-objects

  13. CDB のコマンドライン オプション https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debugger/cdb-command-line-options

  14. <<、><、<<、><、$$ >a< (スクリプト ファイルの実行) https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debuggercmds/-----------------------a---run-script-file-

  15. 95(Printable な ASCII 文字の数) x 39(45 文字から FLAG{} の 6 文字を引いた数) = 3705