【GOT Overwrite・Format String編】任意書込プリミティブで実行を奪う|CTF思考フレームワーク #61
こんにちは、アンペンです!
今回はFormat String Bug(FSB) と GOT Overwrite。printf(user_input)のような実装ミスを起点に、任意アドレスの読み込みと任意アドレスへの書き込みの両方を手に入れ、最終的に関数解決テーブル(GOT)を書き換えて実行を奪う技法です。
BoFやROPは『戻りアドレスを書き換える』のが基本でしたが、FSBは『戻りアドレスに触れずに、好きな場所を読み書きできる』のが恐ろしさ。Canary有り・PIE有りでも条件によっては突破できます。
今回の主役は、たった一行の“うっかり”です。printf(buf)——本来なら printf("%s", buf) と書くべきところを、つい横着してこう書いてしまう。たったこれだけで、攻撃者にプログラムの“読み書き自由パス”を渡してしまうんです。BoFのように派手に殴るのではなく、書式という“ペンの主導権”をそっと奪う。今日はその、静かで強力な攻撃を見ていきましょう。

printfの書式指定で何で攻撃できるの?

printfはスタック上の引数を順番に取り出して書式に当てはめるんだけど、書式自体を攻撃者が支配できると、『スタックの好きな位置を読む』ことも『%nで好きな場所に書く』こともできてしまう。これが任意R/Wプリミティブの正体。
FSBは『ユーザ入力を書式文字列としてprintfに渡す実装ミス』。%x/%p/%sで任意リーク、%n/%hn/%hhnで任意書込のプリミティブが得られます。これをGOT Overwrite(関数解決テーブル書き換え)に使い、次回printf呼出がsystem("/bin/sh")になるよう仕込むのが王道。Full RELROでGOTが読取専用ならこの経路は封じられ、代わりに__free_hookや_IO_FILE系を狙う応用へ進みます。守りは『書式は必ずリテラル+-Wformat-security+FORTIFY_SOURCE=2+Full RELRO』の4層。
この記事で分かること
- FSBの原理と
%nが書き込みになる理由 - 任意リーク・任意書込プリミティブの構築
- GOT/PLTの仕組みと書き換え経路
fmtstr_payloadで自動化する手順- FORTIFY_SOURCE/Full RELROによる封じ方
📖 はじめてのWebセキュリティ #61|GOT Overwrite・Format String編
任意書込プリミティブで実行を奪う手筋を扱います。 シリーズ一覧を見る →
⚠️ 大事なお約束
他者バイナリ・本番サービスへのexploitは違法。CTF・自作バイナリのみで確認してください。
なぜFormat Stringがバグになるのか
正しいprintfの使い方は第1引数を必ず開発者が決めたリテラルにし、データは第2引数以降に渡す形:printf("%s", user_input);。これを横着してprintf(user_input);と書くと、user_input自体が書式文字列になり、printfがuser_input内の%x等を解釈してスタックから値を取り出してしまうのです。
- NG実装:
printf(buf);← bufが攻撃者制御だと完全アウト - OK実装:
printf("%s", buf);← bufは『文字列データ』として安全に表示 - %x/%p:スタック上の値を順番に出力 → スタック内容リーク
- %s:指定位置の値を『アドレスとして』参照し、その先の文字列を出力 → 任意アドレスリーク
- %n:これまでの出力バイト数を、引数で指定したアドレスに書き込む → 任意書込
図解:%nがなぜ書き込みプリミティブになるか
%nはprintf仕様上『これまでの出力バイト数をint*に書き込む』指定子です。printf("AAAA%n", &count);を実行すると、count == 4 になります。出力長は%数字d等で調整できるので、『任意の値』を『任意のアドレス』に書き込む準備が整います。%hn(2バイト)、%hhn(1バイト)で精密制御も可能。
%n が、なぜ“書き込み”になるのか。ここがFSB最大のキモです。普通の %d や %s は『読んで表示する』だけですが、%n だけは異質で、『ここまでに何文字出力したか』をメモするように、指定したアドレスへ“書き込む”んです。つまり出力する文字数を %100d のように水増しすれば、書き込む数値も自由に操れる。表示係だと思っていた printf が、実は“こっそりメモを取る書記”でもあった、というわけです。

会社の請求書テンプレに『お客様の住所を空欄に書いてください』とあったとき、悪意のあるお客様が『金額は[攻撃者の口座]に振込む』という指示文を空欄に書き、それを会社が検査せずに自動処理に流したら、会社のお金が攻撃者へ流れてしまいます。Format String Bugもまったく同じ構造で、『テンプレ(printfの書式)』を『お客様の入力』にしてしまった瞬間、書式制御を奪われます。
ここで覚える用語:GOT / PLT(動的リンクの2点セット)
GOT(Global Offset Table)は『libc関数の本物のアドレス』を保持する配列。PLT(Procedure Linkage Table)は『GOTを参照してジャンプする』スタブです。バイナリ内でprintfを呼ぶと、まずPLTのprintfエントリへジャンプ、PLTがGOT[printf]を見て本物のlibc printfへ飛ぶ流れ。GOT[printf]をsystemのアドレスに書き換えると、次回からプログラム内のprintf("/bin/sh")が事実上system("/bin/sh")として動作し、シェルが起動します。
スタック位置の特定と任意リーク
printfは可変引数関数ですが、x86_64では最初の6引数がレジスタ(RDI/RSI/RDX/RCX/R8/R9)に乗ります。RDIは『書式文字列』そのものなので、追加引数はRSI(2nd)・RDX(3rd)・…・R9(6th)、それを超えるとスタックから読まれます。
実際にやるとき最初に困るのが『自分の入力が、スタックの何番目に置かれているか』です。これは“目印”を投げれば一発で分かります。AAAA%7$p のように送って、出力に 0x41414141(=AAAA)が顔を出した番号が、あなたの入力位置。あとはその位置に狙ったアドレスを仕込めば、読み書きの照準が合います。まず最初に座標を測る——どの攻撃でも共通の、“地味だけど大事”な一手です。
%2$p…%n$p:n番目の引数位置を直接指定して値を確認- 自分の入力文字列がスタックのどこに乗るか
AAAA%7$p等で探る(0x41414141が見つかった位置がオフセット) - そのオフセット位置にターゲットアドレスを置けば、
%sで任意アドレスの内容、%nで任意アドレスへの書き込みが可能

GOT Overwriteの組み立て
標準4ステップ
- ①書き換え対象GOTを決める:後で再度呼ばれる関数(
printf/puts/exit)を狙う。アドレスはreadelf -r ./vuln | grep printfまたはobjdump -R - ②書き込む値を決める:libcベースを
%pでリーク→systemのlibc内オフセットを足してsystem実アドレスを得る - ③オフセット特定:
AAAA%7$pから始めて、書き込み引数がスタックの何番目にあるか確定 - ④fmtstr_payload自動生成:pwntools
fmtstr_payload(offset, {got_printf: system_addr})で%hnを組み合わせた最短ペイロードが手に入る
RELRO=Full ならGOTは読取専用で②自体が失敗します。その場合は__free_hook / __malloc_hook 等の書き込み可能領域(glibc 2.34以前)や、_IO_2_1_stdout_等のIO_FILE構造体を狙う応用が必要になります。
GOT書き換えの怖さは、“電話帳の番号をすり替える”のに似ています。プログラムは printf を呼ぶとき、毎回GOTという電話帳で『printfさんの本当の番号』を調べてかけます。その電話帳の一行を system の番号に書き換えておくと——本人は printf にかけたつもりが、つながる相手は system。次に printf("/bin/sh") 相当が呼ばれた瞬間、実質 system("/bin/sh") となってシェルが立ち上がる、という寸法です。

CTFでやってみよう:FSB+GOT Overwrite
printf(user_input)バグからシェルを取る
目的は『書式を奪う→リークでlibc計算→GOT書換→次回呼出でシェル』という一連の流れを体感することです。
- C言語で
char buf[256]; fgets(buf,256,stdin); printf(buf); printf("Bye\n");のバイナリを-fno-stack-protector -no-pie -Wl,-z,norelroでビルド checksecで RELRO=Partial を確認(これが大事)"AAAA%7$p\n"から順番に投げ、0x41414141が出る位置を特定(これがoffset)- 同じoffsetを使って
%pを並べ、libc関数(puts等)のアドレスをリーク libc_base = leak - libc.symbols['puts']→system_addr = libc_base + libc.symbols['system']readelf -r ./vuln | grep printfでgot_printfアドレス取得- pwntools:
payload = fmtstr_payload(offset, {got_printf: system_addr}) - 送信後、プログラムが続行して2回目の
printf("/bin/sh")相当の動作が起きればシェル(実際はnext call先と引数を工夫)
守る側:4層で完封
- printf(“%s”, input)──書式は必ずリテラル固定、ユーザ入力はデータ引数として渡す
- コンパイル時
-Wformat -Wformat-securityで静的検出。CIで警告ゼロを必須化 - FORTIFY_SOURCE=2(
-D_FORTIFY_SOURCE=2 -O2)で書式に%nが含まれる動的実行を検出してabort - Full RELRO(
-Wl,-z,relro -Wl,-z,now)でGOTを読取専用化 - glibc 2.34以降は
__free_hook/__malloc_hookが削除済み - 独自ロガーで
printf系を直接使わせず、テンプレ固定の関数経由に - 新規プロジェクトはRust(
format!マクロ)等で原理的にこのバグを排除

『printfの第一引数を可変にしない』だけでほぼ防げるんだね。

そう。GCCの-Wformat-securityは警告がデフォルトで出るので、警告を放置せず潰すのが第一歩。次回はHeap入門。tcacheとfastbinの世界に入るよ。
まとめ:『書式制御を奪われたら任意R/W』
- FSBは書式文字列を攻撃者が支配すると成立
%x/%p/%s=任意リーク、%n/%hn/%hhn=任意書込のプリミティブ- GOT書換で関数ポインタを差し替え→次回呼出が攻撃者指定の関数に化ける
- 守りは書式リテラル+
-Wformat-security+FORTIFY+Full RELROの4層
今日の持ち帰りは『書式は、絶対にユーザーに握らせない』。printf(buf) はわずか数文字の横着ですが、そこから任意の読み書き、そしてシェル奪取まで一直線です。逆に守りは拍子抜けするほど簡単で、『printf("%s", buf) と書く』『コンパイラ警告を潰す』だけでほぼ封じられる。攻撃の派手さと、対策の地味さのギャップこそが、この脆弱性の教訓です。
次回はHeap入門編。tcache・fastbinの仕組みから、glibc mallocを内部から読み解いていきます。
