はじめに
Pythonを使って、Windows用のデバッガを作成する方法を紹介します。
デバッガとは
デバッガはプログラムを調べるためのツールです。プログラムの実行を制御しながら内部の状態を調べることができます。
普段の開発でも、エラーが発生する箇所にブレイクポイントを置いて停止させたり、変数の値を確認したり、1行ずつステップ実行したりといった形で使っているかもしれません。
今回作成するデバッガは、いわゆる「開発用デバッグツール」とは異なります。
目的は自分のプログラムを調べることではなく、他人のプログラムを解析することです。
これはマルウェアの解析や、リバースエンジニアリングに使われます。
一般的にデバッガはC++で実装されますが、Pythonでも作成できます。
準備
どうやってデバッガを作るのか
Windowsにはデバッガを作るためのAPIが用意されています。これを使えば、プロセスにアタッチして実行を止めたり、メモリを書き換えたりといった操作が可能になります。
Pythonからは、Windows APIをpywin32やctypesを通して使えます。ただし、これらのAPIは非常に低レベルで扱いづらいため、覚悟が必要です。
ライブラリのインストール
pywin32をインストールします。pywin32はwindows APIを簡単に扱うためのライブラリです。
ドキュメント
pip install pywin32
ctypesはC言語の関数を使うためのライブラリです。標準ライブラリなのでインストールは必要ないです。
別にpywin32がなくても、ctypesだけでデバッガは作れます。ただし、ctypes だけだと API の呼び出しに細かい型定義や構造体の準備が必要なので大変です。
デバッグ対象プログラム
デバッガの対象は電卓やメモ帳など基本的にどんなWindowsアプリケーションでもいいです。ただし、今回はデバッグの動作確認がしやすいように、以下のシンプルなPythonプログラムを使います。
メッセージボックスを2回表示するだけのプログラムです。
import win32api
import win32con
for i in range(2):
win32api.MessageBox(
0, # 親ウィンドウのハンドル
"Hello, World!", # メッセージ本文
"メッセージボックス", # ウィンドウのタイトル
win32con.MB_OK | win32con.MB_ICONINFORMATION # ボタン&アイコン
)
このプログラムをデバッガで実行し、MessageBox関数が呼ばれた瞬間に検出できれば成功です。
コード解説
今回の目的は、MessageBox関数にブレイクポイントを設置して、その呼び出しを検出することです。
デバッグの準備
まずはデバッグ対象プロセスのPID(プロセスID)を取得します。PIDは操作したいプロセスを指定するのに使います。
今回は、ウィンドウのタイトルを使ってPIDを取得します。
window_title = "メッセージボックス"
# ハンドルを取得
handle = win32gui.FindWindow(None, window_title)
# プロセスID(PID)を取得
_, pid = win32process.GetWindowThreadProcessId(handle)
デバッガはループでデバッグイベントをチェックしているため、外部から「終了命令」や「一時停止」などの制御ができません。そのため、スレッド化して別スレッドでデバッガを実行し、メッセージキューを使って制御します。
control_queue = Queue()
# スレッド作成・開始
Thread(target=debug, args=[pid, control_queue]).start()
デバッグの開始
次に、デバッガスレッドである debug
関数の中身を見ていきます。
まずはデバッグ対象プロセスにアタッチ(DebugActiveProcess)して、デバッグを開始します。
DebugActiveProcess
は指定したプロセスに対してデバッガとしての操作を可能にします。
def debug(pid, control_queue: Queue):
# プロセスにアタッチ(デバッグ開始)
if kernel32.DebugActiveProcess(pid):
print("アタッチ成功")
else:
print("アタッチ失敗")
return
ブレイクポイントの設置
次にブレイクポイントを設置します。
今回はメッセージボックスを検出するのが目的なのでMesageBox
関数にブレイクポイントを仕掛けます。
デバッグ対象プログラムでは、pywin32のwin32api.MessageBox
を使ってメッセージボックスを表示しています。これは内部的にはWindows APIのuser32.dll
にあるMessageBoxExW
を呼び出しています。なので、func_resolve
でMessageBoxExW
のアドレスを取ってきて、set_breakpoint
でブレイクポイントを設置します。
# MessageBoxにブレイクポイントを設置
breakpoint_address = func_resolve("user32.dll", "MessageBoxExW") # MessageBoxExWのアドレスを取得
print("MessageBoxExWのアドレス:", hex(breakpoint_address))
original_byte = set_breakpoint(pid, breakpoint_address)
set_breakpoint
の中身
set_breakpoint
では主にメモリ保護状態の変更と割り込み命令の書き込みを行っています。
メモリに書き込む必要があるので、保護状態を読み書き・実行可能にします。
割り込み3(INT 3)命令を書き込むことでブレイクポイントを設置できます。
INT 3命令は0xCC
なので、これをブレイクポイントを引き起こしたいアドレスに書き換えます。
プロセスがINT 3命令を実行すると「例外(ブレイクポイント例外)」が発生します。これはデバッガが処理できるイベントとして飛んでくるので、WaitForDebugEvent
を使って拾うことができます。
def set_breakpoint(pid, address):
# プロセスのハンドルを取得(全アクセス権で開く)
h_process = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
# メモリの保護状態を変えてブレイクポイントを書き込めるようにする
change_protect(h_process, address)
original_byte = read_process_memory(h_process, address, 1) # 元々の内容を保存
# INT 3 命令(0xCC)を書き込んでブレイクポイントを設置
write_process_memory(h_process, address, b"\xCC", 1)
print("ブレイクポイントを設定 アドレス:", hex(address))
# ハンドルを閉じる
kernel32.CloseHandle(h_process)
return original_byte
デバッグループ
Windowsはプロセスで発生するさまざまな「デバッグイベント」(例外・スレッド作成・プロセス終了など)を、デバッガに通知してくれます。
WaitForDebugEvent
を使うことで、イベントを受け取ることができます。待ち時間を100ミリ秒にしたのは、一定間隔でcontrol_queueをチェックして欲しいからです。
# デバッグイベントループ
while True:
# 外部からのメッセージ処理(終了指示など)
if not control_queue.empty():
msg = control_queue.get()
if msg == "quit":
break
debug_event = DEBUG_EVENT()
if kernel32.WaitForDebugEvent(byref(debug_event), 100): # 100ミリ秒イベントを待つ
# イベントの処理(省略)
# イベントが来なかったら何もしない
else:
pass
イベント処理(デバッグインベント・例外・ブレイクポイント)
次はイベントの処理です。
プロセスが終了したらデバッグループを終了するようにします。
event_code = debug_event.dwDebugEventCode
thread_id = debug_event.dwThreadId
if event_code == EXIT_PROCESS_DEBUG_EVENT:
print("プロセス終了")
break
次は例外イベントの処理です。ブレイクポイント到達は例外イベント(EXCEPTION_DEBUG_EVENT
)として通知されます。
ここで発生した例外の種類と発生アドレスをチェックします。
elif event_code == EXCEPTION_DEBUG_EVENT:
exception_code = debug_event.u.Exception.ExceptionRecord.ExceptionCode
exception_address = debug_event.u.Exception.ExceptionRecord.ExceptionAddress
print(f"例外: {hex(exception_code)} / 発生アドレス: {hex(exception_address)}")
ブレイクポイント命中時の処理
次はプロセスがブレイクポイントに到達したときの処理です。
ブレイクポイントに到達したら、適当に何か出力してブレイクポイントを解除します。ブレイクポイント解除は、INT 3命令を元の内容に書き換えることでできます。
adjust_rip_back
は、INT 3(0xCC)命令の1バイト分、IP(64bitだとRIP)を戻す関数です。IPはプロセスが実行しているアドレスを示します。INT 3命令を実行した後、IPは1バイト進んでしまいます。IPを戻さないと、本来の命令を実行せずに次に進んでしまうため、プログラムがクラッシュします。
# ブレイクポイント命中
if exception_code == EXCEPTION_BREAKPOINT and exception_address == breakpoint_address:
print("★ ブレイクポイントに到達")
# ブレイクポイント解除
remove_breakpoint(pid, breakpoint_address, original_byte)
# IP(インストラクションポインタ)を戻す
adjust_rip_back(thread_id)
デバッグイベントの続行
イベントが発生するとプロセスが停止するので、ContinueDebugEvent
でプロセスの実行を再開させる必要があります。
# イベント処理を続行
windll.kernel32.ContinueDebugEvent(
pid,
thread_id,
DBG_CONTINUE
)
デバッグの終了
デバッグループを抜けたら、プロセスからデバッガをデタッチしてデバッグを終了します。
# デバッグ終了(デタッチ)
if kernel32.DebugActiveProcessStop(pid):
print("デタッチ成功")
else:
print("デタッチ失敗")
基本的には、デバッグ対象のプログラムが終了すれば、EXIT_PROCESS_DEBUG_EVENT
を受け取ってデバッグループが終了します。
しかし、一部のプログラムではうまくイベントが届きません。そのためデバッグを手動で終了させる必要があります。そういうときのために、外部からメッセージを送って終了させる仕組みを用意しています。
# デバッグ終了
control_queue.put("quit")
実行例
ステップ1:デバッグ対象プログラムを起動
まずはデバッグ対象プログラム(メッセージボックスを2回表示するプログラム)を起動します。デバッガとは別プロセスとして実行してください。
実行すると、以下のようなメッセージボックスが表示されます。
ステップ2:デバッガを起動
次に、デバッガのコードを実行して、アタッチとブレイクポイントの設置をします。
以下は出力例です。
この時点ではまだメッセージボックス表示にはヒットしていません。ここで発生している例外はWindowsによる初期ブレイクポイントです。
アタッチ成功
MessageBoxExWのアドレス: 0x7ffec7af2ff0
ブレイクポイントを設定 アドレス: 0x7ffec7af2ff0
例外: 0x80000003 / 発生アドレス: 0x7ffec87ddd10
ステップ3:ブレイクポイントに到達
次に、メッセージボックスのOKボタンか×ボタンを押します。すると、もう一度メッセージボックスが表示されます。その際、内部でMessageBoxExW
が呼び出されます。MessageBoxExW
にブレイクポイントが設置されているので、デバッガが反応します。
例外: 0x80000003 / 発生アドレス: 0x7ffec7af2ff0
★ ブレイクポイントに到達
ブレイクポイントを解除 アドレス: 0x7ffec7af2ff0
ステップ4:デバッガを終了
2回目のメッセージボックスを閉じるか、もしくは control_queue.put("quit") を送ってデバッガを終了しましょう。
まとめ
これでメッセージボックスの表示を検出できました!さらに発展させれば、ブレイク時に引数を読み取ったり、書き換えたりしてメッセージボックスのタイトルや本文を操作することもできます。
おわりに
Pythonだけでも簡単なデバッガを作って、好きな関数にブレイクポイントを仕掛けられることを紹介しました。
応用として、以下のようなことができます。
- ネットワーク通信を行う関数(winsock2のsendなど)にブレイクポイントを設置
→ 引数から送信内容を取得することで、「アプリがどんなデータを送ろうとしているか」を確認できる - 暗号化関数にブレイクポイントを設置
→ 関数実行前の平文のデータを取得できる(復号も同様)
これらはマルウェア解析や通信プロトコル解析などに使えます。
今回はPythonだけでやってみましたが…
Pythonだけでもデバッガは作れますが、構造体や定数の定義が大変なので、結局C++やRustと組み合わせるのがいいと思います。