WinDbg: プログラムの実行時にメモリにパッチを当てて動作を変える方法

WinDbg を使ってプログラムの起動直後や実行中にメモリの内容を書き換えプログラムの動作を変える方法について説明します。例として、(1) メッセージを書き換える、(2) ロジックを書き換える、(3) 特定の関数の呼び出しを検出する、を行ってみます。

動作確認環境

  • Windows 11 Home 23H2
  • Visual Studio Community 2022 (Visual C++)
  • WinDbg 10.0

メッセージを書き換える

プログラムが表示するメッセージを変えてみます。
以下は、「Hello World!」というメッセージボックスを表示する Visual C++ のプログラム「Hello.c」です。「c:\tmp」に置いています。

#include <windows.h>

int WinMain()
{
    MessageBox(NULL, "Hello World!", "Hello", MB_ICONINFORMATION | MB_OK);
    return 0;
}

x86(32ビット)でも構いませんが、今回は x64(64ビット)でビルドしてみましょう。
「x64 Native Tools Command Prompt for VS 2022」のコマンドプロンプトを開いて、次のコマンドを入力します。「/Od」は最適化抑止、「/Zi」はデバッグ情報生成の意味です。

C:\tmp> cl /Od /Zi Hello.c user32.lib

Microsoft(R) C/C++ Optimizing Compiler Version 19.39.33523 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

Hello.c
Hello.c(4): warning C4026: 関数はパラメーター リストを使って宣言されています。
Microsoft (R) Incremental Linker Version 14.39.33523.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/out:Hello.exe
/debug
Hello.obj
user32.lib

「Hello.exe」が生成されました。
実行します。

C:\tmp> Hello.exe

「Hello World!」と表示されました。

これを「Hello Japan!」に変えてみましょう。それには、パッチを当てる場所を調べなければなりません。

まず、x64 版の WinDbg から Hello.exe を起動します(「path C:\Program Files (x86)\Windows Kits\10\Debuggers\x64;%path%」などとして、x64 版の WinDbg.exe がある場所にパスを通しておいてください)。

C:\tmp> windbg Hello.exe

[Command] ウィンドウから lm コマンドを実行して、ロードモジュールの一覧を見てみます。「lm」は「List Loaded Modules」の意味です。

0:000> lm
start             end                 module name
00007ff7`a4800000 00007ff7`a48a4000   Hello      (deferred)
00007ffb`58000000 00007ffb`58026000   win32u     (deferred)
00007ffb`58170000 00007ffb`5820a000   msvcp_win   (deferred)
00007ffb`58210000 00007ffb`58329000   gdi32full   (deferred)
00007ffb`58330000 00007ffb`586dc000   KERNELBASE   (deferred)
00007ffb`58750000 00007ffb`58861000   ucrtbase   (deferred)
00007ffb`58da0000 00007ffb`58e64000   KERNEL32   (deferred)
00007ffb`58e70000 00007ffb`58e99000   GDI32      (deferred)
00007ffb`5a860000 00007ffb`5aa0e000   USER32     (deferred)
00007ffb`5acd0000 00007ffb`5aee7000   ntdll      (pdb symbols) ......

先頭に自作の Hello モジュールが見えます。「Hello World!」の文字列はこのモジュール内にあるはずです。
検索コマンド (s) を使って、Hello モジュール内(Hello 00007ff7`a48a4000)から “Hello World!” という ASCII 文字列 (-a) を探します。

0:000> s -a Hello 00007ff7`a48a4000 "Hello World!"
00007ff7`a4896008  48 65 6c 6c 6f 20 57 6f-72 6c 64 21 00 00 00 00  Hello World!....

「00007ff7`a4896008」に見つかりました。
ここで注意ですが、当該文字列はいつもこのアドレスに存在するわけではありません。OS の ASLR (アドレス空間配置のランダム化; Address Space Layout Randomization) 機能により、違う場所に配置される可能性があります。しかし、Hello モジュールの先頭アドレスからのオフセットであれば、常に同じになります。その値を引き算で計算します。

0:000> ? 00007ff7`a4896008-Hello
Evaluate expression: 614408 = 00000000`00096008

オフセットは「96008」でした。
Hello モジュールの先頭アドレスから 0x960008 バイト先に “Hello Japan!” と書き込んでみます。「ea」は「ASCII 文字列を入力する」の意味です。

0:000> ea Hello+96008 "Hello Japan!"

書き換わったことを確認します。「da」は 「ASCII 文字列で表示する」の意味です。

0:000> da Hello+96008
00007ff7`a4896008  "Hello Japan!"

問題なさそうです。
デバッガを切り離して(デタッチして)、プログラムの実行を継続します。「qd」は「Quit and Detach」の意味です。

0:000> qd

期待通り、「Hello World!」ではなく「Hello Japan!」と表示されました。

メモリの書き換えを自動化する

前記の操作を毎回手作業で行うのは手間なので、自動化します。

まずは自動実行したい命令をファイル化します。今回は次のテキストファイルを「c:\tmp\script.txt」として保存しました。

ea Hello+96008 "Hello Japan!"
qd

WinDbg から Hello.exe を起動する際に、-c パラメーターを利用してこのスクリプトを自動実行するよう指示します。

C:\tmp> windbg -c "$<c:\tmp\script.txt" Hello.exe

一瞬 WinDbg の画面が現れた後、「Hello Japan!」と表示されました。

さらに、WinDbg を明示的に指定しなくても済むようにします。

レジストリエディターを起動し、次の場所を開きます。

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options

この配下に実行したいプログラムの名前のキー(今回は hello.exe)を作り、その中に以下の値を書き込みます。

  • 名前: Debugger
  • 種類: REG_SZ
  • データ: デバッガのコマンド(今回は “C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\windbg.exe” -c “$<c:\tmp\script.txt”)

書き込み時の画面キャプチャーです。

この設定をして Hello.exe を起動すると、レジストリに書き込んだデバッガが自動的にアタッチされ、スクリプトによりメモリが書き換えられた後、プログラムが実行されます。
試してみましょう。

C:\tmp> Hello.exe

Hello.exe を実行しただけなのに、メモリ書き換え後の「Hello Japan!」が表示されました。

ロジックを書き換える

さきほどはメッセージを変えましたが、今度はロジックを変えてみます。手順はほとんど同じです。

サンプルとして、1 以上 100 以下の乱数をひとつ生成し、100 だったら「当たり!!!」、それ以外だったら「はずれ」と表示するプログラムを用意しました。当選確率 1% です。

#include <windows.h>

int WinMain()
{
    DWORD dwSeed;
    LARGE_INTEGER ticks;
    DWORD dwRand;

    // 現在時刻と PC の起動時間から乱数の種を作る
    dwSeed = time(NULL);
    QueryPerformanceCounter(&ticks);
    dwSeed ^= ticks.HighPart;
    dwSeed ^= ticks.LowPart;
    srand(dwSeed);

    // 1 から 100 までの乱数を生成する
    dwRand = (rand() % 100) + 1;

    // 当たり判定
    if (dwRand == 100)
    {
        MessageBox(NULL, "当たり!!!", "rand", MB_ICONINFORMATION | MB_OK);
    }
    else
    {
        MessageBox(NULL, "はずれ", "rand", MB_ICONINFORMATION | MB_OK);
    }

    return 0;
}

x64 版の Visual C++ でビルドします。「/Od」は最適化抑止、「/Zi」はデバッグ情報生成の意味です。

C:\tmp> cl /Od /Zi rand.c user32.lib

Microsoft(R) C/C++ Optimizing Compiler Version 19.39.33523 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

rand.c
rand.c(4): warning C4026: 関数はパラメーター リストを使って宣言されています。
Microsoft (R) Incremental Linker Version 14.39.33523.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/out:rand.exe
/debug
rand.obj
user32.lib

「rand.exe」が生成されました。

繰り返し実行したところ、54 回連続で「はずれ」が表示された後、

55 回目にしてようやく「当たり!!!」が表示されました。

これを、100% 当たりが出るよう改造してみましょう。それには、パッチを当てる場所を調べなければなりません。

まず、WinDbg から rand.exe を実行します。

C:\tmp> windbg rand.exe

WinDbg のメニューから [File]-[Open Source File…] を選択し、ソースファイル「rand.c」を開きます。

当選確率 100% にするには、変数 dwRand に固定値 100 を代入するか、当たり判定 if (dwRand == 100) を書き換えればよさそうです。後者のほうが簡単そうなので「if (dwRand == 100)」の行に [F9] キーでブレークポイントを仕掛けます。

[F5] キーで実行継続すると、指定の行で止まりました。
[View]-[Disassembly] で逆アセンブルのウィンドウを開きます。

ブレークポイント前後の C 言語とアセンブリ言語を見比べると、アドレス「00007ff6`e36e720d」にある jne 命令(Jump if not equal; 等しくない場合はジャンプ)を無視すればうまくいきそうなことが分かります。
このアドレスは ASLR 機能により変わる可能性がありますが、rand.exe モジュールの先頭アドレスからのオフセットであれば常に同じになるため、その値を引き算で計算します。

0:000> ? 00007ff6`e36e720d-rand
Evaluate expression: 29197 = 00000000`0000720d

オフセットは「720d」でした。
rand.exe のオフセット 0x720d から 2 バイト分(jne 命令の長さ分)を、何もしない命令である NOP 命令に書き換えます。「eb」は「バイト値を入力する」の意味、「90」は NOP のマシン語です。

0:000> eb rand+720d 90 90

[Disassembly] ウィンドウを見ると、命令が書き換わったことが確認できます。

デバッガを切り離して(デタッチして)、プログラムの実行を継続します。「qd」は「Quit and Detach」の意味です。

0:000> qd

想定通り、「当たり!!!」が表示されました。

Hello.exe のときと同様に、この操作を自動化します。
次のスクリプトを「c:\tmp\script.txt」として保存します。

eb rand+720d 90 90
qd

WinDbg から rand.exe を起動する際に、-c パラメーターを利用してこのスクリプトファイルを自動実行するよう指示します。

C:\tmp> windbg -c "$<c:\tmp\script.txt" rand.exe

一瞬 WinDbg の画面が現れた後、「当たり!!!」が表示されました。

さらに、WinDbg を明示的に実行しなくても済むようにします。

レジストリに次の値を書き込みます。

rand.exe を実行します。

C:\tmp> rand.exe

「当たり!!!」が表示されました。しかし、100 発 100 中のクジはおもしろくはないですね。

特定の関数の呼び出しを検出する

最後に、特定の関数の呼び出しを検出してみます。具体的には、実行中のメモ帳のメモリを書き換え、Windows API の ExitProcess() 関数の呼び出しを捕捉してみましょう。ExitProcess() は、自分自身を終了させるための関数です。

メモ帳 (Notepad.exe) を起動します。

x64 版の WinDbg を起動し、メニューから [File]-[Attach to a Process…] を選択して Notepad.exe にアタッチします。

[Command] ウィンドウから ExitProcess() 関数の開始アドレスを探します。「x」はシンボルを探すコマンド、「*」は「任意のモジュール」の意味です。

0:015> x *!ExitProcess

なぜか見つかりません(途中で検索を止めるには [Ctrl]+[Break])。
関数名の末尾にワイルドカード「*」を付加して再度探してみます。

0:015> x *!ExitProcess*
00007ffd`7ecd7fa0 KERNEL32!ExitProcessImplementation (ExitProcessImplementation)

見つかりました。調べてみると、ExitProcess() 関数の呼び出しは、この ExitProcessImplementation() 関数に読み替えられるようです。

当該関数の先頭を u コマンドで逆アセンブルします。

0:015> u KERNEL32!ExitProcessImplementation
KERNEL32!ExitProcessImplementation:
00007ffd`7ecd7fa0 4883ec28        sub     rsp,28h
00007ffd`7ecd7fa4 48ff153ddf0600  call    qword ptr [KERNEL32!_imp_RtlExitUserProcess (00007ffd`7ed45ee8)]
......

関数の先頭の命令は「sub rsp,28h」ですが、これを「int 3」(マシン語で 0xCC)に書き換え、関数が呼ばれた瞬間にブレークポイント例外が発生するようにします。

0:015> eb KERNEL32!ExitProcessImplementation cc

再度逆アセンブルして、命令が書き換わったことを確認します。

0:015> u KERNEL32!ExitProcessImplementation
KERNEL32!ExitProcessImplementation:
00007ffd`7ecd7fa0 cc              int     3
00007ffd`7ecd7fa1 83ec28          sub     esp,28h
00007ffd`7ecd7fa4 48ff153ddf0600  call    qword ptr [KERNEL32!_imp_RtlExitUserProcess (00007ffd`7ed45ee8)]
......

デバッガを切り離して(デタッチして)、プログラムの実行を継続します。

0:015> qd

ExitProcessImplementation() 関数が呼び出されない限り、メモ帳は普通に動作します。「やや低レイヤー研究所」と入力してみました。

[閉じる]ボタンを押し、

続いて保存の確認メッセージボックスで[保存しない]ボタンを押し、メモ帳を終了させます。

メモ帳の終了を指示すると ExitProcessImplementation() 関数が呼び出され、int 3 のブレークポイント例外が発生します。
本 PC では未捕捉の例外が発生したら C:\tmp にダンプファイルを生成するよう設定しているため、「c:\tmp\Notepad.exe.*.dmp」が生成されました。このダンプファイルを調べることで、何が起きたのかを事後解析できます。

PC によっては、Visual Studio の Just-In-Time デバッガーが起動したり、WinDbg が起動したりすると思います。未捕捉の例外発生時の環境設定については本稿の趣旨ではないため省略しますが、必要であれば、「レジストリの AeDebug」「Windows Error Reporting」「WinDbg -I」「Visual Stduio の [ツール]-[オプション]-[デバッグ]-[Just-In-Time]」などをキーに調べてください。

さて、これまでと同様、パッチを当てる処理を自動化します。
次のスクリプトを「c:\tmp\script.txt」として保存します。

eb KERNEL32!ExitProcessImplementation cc
qd

メモ帳を起動します。

タスクマネージャーで、メモ帳のプロセス ID を調べます。今回はたまたま「8888」でした。
次のコマンドで、プロセス ID 8888 に対してパッチを当てます。

C:\tmp> windbg -p 8888 -c "$<c:\tmp\script.txt"

パッチを当てた後もメモ帳は普通に使用できますが、メモ帳を終了しようとするとブレークポイント例外が発生し、ダンプファイルが生成されるなど環境に応じた動作をします。

おわりに

プログラムの実行時にメモリの内容を書き換えて動作を変える方法について見てきました。もとのファイルを書き換えずに済むのが長所です。