Python pty + Frida を用いたバイナリの動的解析 (Simple Flag Checker Writeup)
Python スクリプトで pty を用いて仮想端末上でバイナリを制御し, Frida を扱うことで動的解析を行う方法の覚え書き.
題材として AlpacaHack の Simple Flag Checker を解く.
Frida の Windows での基礎的な使用方法は 前回記事 で解説.
検証環境
- Windows 11 Home
- WSL2 Ubuntu 24.04
Frida について
- バイナリ計装ツール
- JavaScript ベースで関数のフック・処理の追加が可能
- Android, iOS, Windows バイナリにも使用可能
インストール
pipやuvでインストール可能
uv add frida-tools
Simple Flag Checker の問題設定の確認
今回は CTF の Rev 問題である Simple Flag Checker を解く過程で, frida を用いた glibc 関数 (ELF バイナリのライブラリ) のフックと処理の書き換えを行う
本問題ではフラグチェッカーのバイナリが渡される
Ghidra で逆コンパイルすると, 入力文字列 (
input_flag) に対して以下のように順番にupdate関数を掛けながらmemcmpでメモリ上のデータと比較を行っている
do { update(&update_param,input_flag[lVar3]); iVar2 = memcmp(&update_param,table + lVar3 * 0x10,0x10); bVar1 = (bool)(bVar1 & iVar2 == 0); lVar3 = lVar3 + 1; } while (lVar3 != 0x31);
update関数の中身は非常に複雑で, 静的解析は難しそうltraceで試しに Alpaca と入力してみると出力は以下のようになった
memcmp(0x7ffd398c6450, 0x64dedb131020, 16, 9) = 0 memcmp(0x7ffd398c6450, 0x64dedb131030, 16, 9) = 0 memcmp(0x7ffd398c6450, 0x64dedb131040, 16, 9) = 0 memcmp(0x7ffd398c6450, 0x64dedb131050, 16, 9) = 0 memcmp(0x7ffd398c6450, 0x64dedb131060, 16, 9) = 0 memcmp(0x7ffd398c6450, 0x64dedb131070, 16, 9) = 0 memcmp(0x7ffd398c6450, 0x64dedb131080, 16, 9) = 239 memcmp(0x7ffd398c6450, 0x64dedb131090, 16, 9) = 0xffffffd4 memcmp(0x7ffd398c6450, 0x64dedb1310a0, 16, 9) = 0xffffff74 memcmp(0x7ffd398c6450, 0x64dedb1310b0, 16, 9) = 15 memcmp(0x7ffd398c6450, 0x64dedb1310c0, 16, 9) = 0xffffff8f memcmp(0x7ffd398c6450, 0x64dedb1310d0, 16, 9) = 209 memcmp(0x7ffd398c6450, 0x64dedb1310e0, 16, 9) = 0xffffff7a memcmp(0x7ffd398c6450, 0x64dedb1310f0, 16, 9) = 16 memcmp(0x7ffd398c6450, 0x64dedb131100, 16, 9) = 36 memcmp(0x7ffd398c6450, 0x64dedb131110, 16, 9) = 28 memcmp(0x7ffd398c6450, 0x64dedb131120, 16, 9) = 0xffffffb2 memcmp(0x7ffd398c6450, 0x64dedb131130, 16, 9) = 34 memcmp(0x7ffd398c6450, 0x64dedb131140, 16, 9) = 0xffffffba memcmp(0x7ffd398c6450, 0x64dedb131150, 16, 9) = 0xfffffff9 memcmp(0x7ffd398c6450, 0x64dedb131160, 16, 9) = 16 memcmp(0x7ffd398c6450, 0x64dedb131170, 16, 9) = 0xffffffe1 memcmp(0x7ffd398c6450, 0x64dedb131180, 16, 9) = 0xffffffd2 memcmp(0x7ffd398c6450, 0x64dedb131190, 16, 9) = 27 memcmp(0x7ffd398c6450, 0x64dedb1311a0, 16, 9) = 0xffffff58 memcmp(0x7ffd398c6450, 0x64dedb1311b0, 16, 9) = 18 memcmp(0x7ffd398c6450, 0x64dedb1311c0, 16, 9) = 0xffffffe8 memcmp(0x7ffd398c6450, 0x64dedb1311d0, 16, 9) = 0xffffffad memcmp(0x7ffd398c6450, 0x64dedb1311e0, 16, 9) = 142 memcmp(0x7ffd398c6450, 0x64dedb1311f0, 16, 9) = 213 memcmp(0x7ffd398c6450, 0x64dedb131200, 16, 9) = 0xffffffa6 memcmp(0x7ffd398c6450, 0x64dedb131210, 16, 9) = 0xfffffff9 memcmp(0x7ffd398c6450, 0x64dedb131220, 16, 9) = 0xffffffd4 memcmp(0x7ffd398c6450, 0x64dedb131230, 16, 9) = 198 memcmp(0x7ffd398c6450, 0x64dedb131240, 16, 9) = 119 memcmp(0x7ffd398c6450, 0x64dedb131250, 16, 9) = 89 memcmp(0x7ffd398c6450, 0x64dedb131260, 16, 9) = 0xffffffc0 memcmp(0x7ffd398c6450, 0x64dedb131270, 16, 9) = 0xffffff9f memcmp(0x7ffd398c6450, 0x64dedb131280, 16, 9) = 43 memcmp(0x7ffd398c6450, 0x64dedb131290, 16, 9) = 0xffffffe6 memcmp(0x7ffd398c6450, 0x64dedb1312a0, 16, 9) = 68 memcmp(0x7ffd398c6450, 0x64dedb1312b0, 16, 9) = 0xffffffdd memcmp(0x7ffd398c6450, 0x64dedb1312c0, 16, 9) = 0xffffffcf memcmp(0x7ffd398c6450, 0x64dedb1312d0, 16, 9) = 85 memcmp(0x7ffd398c6450, 0x64dedb1312e0, 16, 9) = 183 memcmp(0x7ffd398c6450, 0x64dedb1312f0, 16, 9) = 0xffffff81 memcmp(0x7ffd398c6450, 0x64dedb131300, 16, 9) = 24 memcmp(0x7ffd398c6450, 0x64dedb131310, 16, 9) = 0xffffffef memcmp(0x7ffd398c6450, 0x64dedb131320, 16, 9) = 7
memcmpの返り値が 0 であれば成功であるため, 先頭 6 文字分正解であることが分かるフラグ文字数は比較の回数から 0x31 であるため, 上記の要領で 1 文字ずつ総当たりで解けば現実的な時間で解けることが想定される
本記事では,
ptyを用いて仮想端末を作成してプログラムを回し, frida を用いてmemcmpの結果について Python 側に送るようにして総当たりを自動化することを目標とする
pty の設定
- pty は仮想端末を作成することで, バイナリの実行と入出力の Python による制御を可能にする
端末の作成
openptyで仮想端末を開くことができる
import pty prime_fd, second_fd = pty.openpty()
- pty の master/slave などの関係については 参考記事 の図などを見るとイメージがつかみやすい
- ざっくりとした役割分担としては
second_fdをプログラムの実行時に渡すファイルディスクリプタとして,prime_fdに対して入出力を行う
プログラムの実行
subprocessを用いてバイナリを実行する
import subprocess proc = subprocess.Popen( ["./checker"], stdin=second_fd, stdout=second_fd, stderr=second_fd, close_fds=True, )
- 実行時に標準入出力を
second_fdに設定する
プロンプトが出現するまで文字列を読み出す
- 次に, プログラムでは
flag?というプロンプトが表示されるため, プロンプトまでの部分を読み出す機能 (pwntoolsのrecvuntilのようなもの) を実装する
import os def recv_prompt(fd, prompt): buf = b"" while True: tmp_buf = os.read(fd, 1) if not tmp_buf: print("end reading!") break buf += tmp_buf if prompt in buf: return buf
os.readを用いて primary のファイルディスクリプタから 1 バイトずつ読み出し,promptとして指定された文字列が出てくるまで読み込みを行う
文字列の送信
- 文字列の送信は
os.writeで行う
os.write(prime_fd, f"Alpaca\n".encode())
- 上の例では
Alpacaという文字列を送っている (pwntools のsendline相当にするため改行文字をつける)
残りの文字列をすべて読み込む
- 本問題のチェッカーでは一度入力を受け取るとプログラムの終了まで他に入力を行う場所はないので, 残りの文字列をすべて読み込むコードを書く
import select def recv_all(fd, timeout=2.0): buf = b"" t0 = time.time() while time.time() - t0 < timeout: r, _, _ = select.select([fd], [], [], 0.1) # if readable if r: tmp_buf = os.read(fd, 1) # this process is blocking I/O if not tmp_buf: break print(tmp_buf.decode(), end="") buf += tmp_buf return buf
r, _, _ = select.select([fd], [], [], 0.1)はタイムアウト 0.1 秒で読み込みが可能かどうかを調べる処理にあたるos.readのみの場合はプログラムの終了後に待機が終わらず Ctrl+C などをしないと処理が停止しない現象が生じたselectを用いることで読み込み可能であることを確認したうえで読み込みを行うことができる
frida の設定
基本的なスクリプトのアタッチ方法
- 今回は
subprocessを用いてバイナリを起動するため, プロセス ID を用いて frida をアタッチする
proc = subprocess.Popen( ["./checker"], stdin=second_fd, stdout=second_fd, stderr=second_fd, close_fds=True, ) session = frida.attach(proc.pid)
- 次に, 関数をフックするスクリプトとコールバック関数 (
on_message) をアタッチする (スクリプトとコールバックは後述)
script = session.create_script($SCRIPT) script.on("message", on_message) # add signal handler script.load()
frida のスクリプト
- frida のスクリプトは基本的に JavaScript で記述する
- Python では文字列としてスクリプトを保持して
script.load()で読み込む - 例としてバイナリで読み込まれている関数を列挙するスクリプトをアタッチする場合は以下となる
check_script = """ console.log("script loaded"); var main = Process.enumerateModules()[0]; // main binary main.enumerateImports().forEach(function(imp) { console.log("Import:", imp.name); }); """ session = frida.attach(proc.pid) script = session.create_script(check_script) script.load()
Javascript のため
console.logでも出力が可能上の状態でプログラムを走らせると読み込まれている関数 (
memcmpなど) が列挙される今回の問題では,
memcmpの比較があっているかどうかを調べたいため,memcmpについてフックする
hook_script = """ var main = Process.enumerateModules()[0]; // main binary main.enumerateImports().forEach(function(imp) { if (imp.name == "memcmp") { Interceptor.attach(imp.address, { onEnter: function(args) { this.s1 = args[0]; this.s2 = args[1]; this.n = args[2].toInt32(); }, onLeave: function(retval) { send({ type: "memcmp", ret: retval.toInt32(), n: this.n }); } }); } }); """
memcmpの出力について,onLeaveでsendを用いて Python 側に送ってあげることで, 結果 (成功ならば 0) を受け取ることができるようになる受け取る際に呼び出されるコールバック関数は以下のように定義する
hit_len = 0 def on_message(msg, data): global hit_len if msg["type"] != "send": return payload = msg["payload"] if payload["type"] == "memcmp": ret_val = payload["ret"] if ret_val == 0: hit_len += 1
retで受け取る値が 0 であれば文字列が一致したことになるため, 一致した数としてhit_lenをインクリメントする処理を書いた- 今回は既知のフラグと次の一文字を送り,
hit_lenが既知フラグの文字数より 1 大きい場合に成功とすることでブルートフォース攻撃を行う
結果・考察など
- 上記の手段で pty と frida を用いてブルートフォース攻撃を回すと, 数分程度で解決できた
- 途中でなぜかプログラムが止まることがあったが, 途中から処理を再開すれば正しく動いた (タイムアウトなどの関係で処理が止まったと考えられる)
- この問題について他の Writeup を見てみると
ltraceなど他の方法がたくさん紹介されていた - 以前 Binary Hacks Rebooted という本で読んだ
LD_PRELOADなどを使った方法も紹介されていたため試してみたい
コード全文
- 以下がコード全文
- プログラムが途中で止まった場合は
flagに既知文字列を入れることで途中から処理を再開できる
import frida import pty import subprocess import os import time import select import string def recv_prompt(fd, prompt): buf = b"" while True: tmp_buf = os.read(fd, 1) if not tmp_buf: print("end reading!") break buf += tmp_buf if prompt in buf: return buf def recv_all(fd, timeout=2.0): buf = b"" t0 = time.time() while time.time() - t0 < timeout: r, _, _ = select.select([fd], [], [], 0.1) # if readable if r: tmp_buf = os.read(fd, 1) # this process is blocking I/O if not tmp_buf: break print(tmp_buf.decode(), end="") buf += tmp_buf return buf hook_script = """ var main = Process.enumerateModules()[0]; // main binary main.enumerateImports().forEach(function(imp) { if (imp.name == "memcmp") { Interceptor.attach(imp.address, { onEnter: function(args) { this.s1 = args[0]; this.s2 = args[1]; this.n = args[2].toInt32(); }, onLeave: function(retval) { send({ type: "memcmp", ret: retval.toInt32(), n: this.n }); } }); } }); """ check_script = """ console.log("script loaded"); var main = Process.enumerateModules()[0]; // main binary main.enumerateImports().forEach(function(imp) { console.log("Import:", imp.name); }); """ flag = "Alpaca" for i in range(len(flag), 0x31): for char in (string.digits + r"{}_" + string.ascii_letters): hit_len = 0 def on_message(msg, data): global hit_len if msg["type"] != "send": return payload = msg["payload"] if payload["type"] == "memcmp": ret_val = payload["ret"] if ret_val == 0: hit_len += 1 prime_fd, second_fd = pty.openpty() proc = subprocess.Popen( ["./checker"], stdin=second_fd, stdout=second_fd, stderr=second_fd, close_fds=True, ) session = frida.attach(proc.pid) script = session.create_script(hook_script) script.on("message", on_message) # add signal handler script.load() prompt = recv_prompt(prime_fd, "flag?".encode()) print(prompt.decode()) os.write(prime_fd, f"{flag+char}\n".encode()) recv_all(prime_fd, timeout=0.1) if hit_len < i+1: continue else: flag += char break if len(flag) < i+1: print("no hit...") print("tmp flag:", flag) exit()