こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

UTCTF 2020 writeup - Wasm Fans Only

某CTFチームのAdvent Calendar 2020向けの記事です。

例年のごとく年末の締めくくりとして今年開催のWeb系のWriteupを読み漁っていたところ、WebAssemblyの問題にいくつか遭遇した。WebAssemblyは比較的Rev問に近く苦手意識があるものの解けるようになりたいと思っていた。そんな折にChromeのWebAssemblyのデバッグ機能が強化された記事を見かけた。

developers.google.com

これで少し手が出しやすくなってきた気がしたので、今年の過去問をサンプルに解いてみることにした。

問題

UTCTF 2020のWASM Fans Onlyを解く。

この大会は2020年3月開催であったにもかかわらず、嬉しいことにまだ問題サーバが生きている。
問題サーバはこちら。
https://wasmfans.ga/

また、githubに問題ファイルが公開されているのも嬉しい。
元のCのソースコードも公開されており、答え合わせをしながら解析できるため、初心者にピッタリ。
github.com

またWriteupもあるのも嬉しい。
https://ctftime.org/writeup/18640

なお、後のネタバレになってしまうが、実は問題サーバのホスト名(wasmfans.ga)がキーになっているため、もし上記のgithubリポジトリから落としてきて自分のサーバで試したい場合は、wasmfans.gaのホスト名で自サーバにアクセスできるよう/etc/hostsやC:\Windows\System32\drivers\etc\hostsを設定すること。

Writeup

HTML/JavaScript解析

まずURLにアクセスすると以下の画面が表示される。
f:id:graneed:20201221232702p:plain

UsernameとPasswordを適当に入力してLog inボタンを押下するとTry again!。
なおPasswordの項目IDはflagである。
f:id:graneed:20201221233402p:plain

Log inボタンを押下するとページ内のcheckFlag関数を呼び、checkFlag関数はModule._verify_flag();を呼んでいる。
f:id:graneed:20201221233058p:plain

_verify_flagverifyFlag.jsファイルに定義されており、更にWebAssemblyのverify_flag関数を呼んでいる。 f:id:graneed:20201221233147p:plain

早速ここからWebAssemblyの世界へ突入するが、その前にもう少しverifyFlag.jsファイルを見てみる。

verifyFlag.jsは非常に大きいファイルで正直わけわからん状態であるが、エラーメッセージの「Try again!」で検索すると、意味がわかるロジックが見つかる。これら3つの関数はWebAssemblyから呼ばれる関数なので覚えておく。 f:id:graneed:20201221233726p:plain

wasmのざっくり静的解析

verifyFlag.wasmファイルをverify_flagで検索するとすぐに関数が見つかる。
ザっと目につく処理を書き出してみる。

  1. 0x01826~0x018e8
    変数の宣言と初期化?

  2. 0x018ec~0x01985
    ループ処理で何かを55とXORしている
    f:id:graneed:20201221235418p:plain

  3. 0x019ac
    $env.getStringをcallしている
    f:id:graneed:20201221234925p:plain

  4. 0x019b8~0x01a23
    変数の宣言と初期化?

  5. 0x01a26~0x1abc
    ループ処理で何かの変数を96とXORしている
    f:id:graneed:20201221235443p:plain

  6. 0x01ae3
    $env.getStringをcallしている
    f:id:graneed:20201221235535p:plain

  7. 0x01b26~0x01bf3 変数の宣言と移送か何かをしている

  8. 0x01c3e~0x01de0

    • $func23を呼んでから巨大な処理ブロック内へ。
    • $func23の返り値が0の場合は$label6を抜けて$env.loseを呼ぶ。その後$label9を抜けて終了。→NG
    • $func23の返り値が0でない場合は$aes_encrypt_blockを呼ぶ。その後ループ内で何らかの配列を1文字ずつ比較。
      • 一致しない場合は$env.loseを呼ぶ。→NG
      • ループを抜けた=全ての変数が一致したら$env.winを呼ぶ。→OK
    • $func23の解説は省略。フラグ文字列の文字数とprefixとsuffixのチェックである。

8のうち、ポイントとなる処理だけ残して以下に転記した。

    call $func23
    local.set $var121
    block $label9
      block $label6
        local.get $var121
        i32.eqz
        br_if $label6
        (snip)
        local.get $var133
        local.get $var128
        local.get $var125
        call $aes_encrypt_block
        local.get $var2
        local.get $var122
        i32.store offset=12
        block $label7
          loop $label10
            i32.const 16
            (snip)
            local.get $var159
            local.get $var160
            i32.and
            local.set $var161
            block $label8
              local.get $var161
              i32.eqz
              br_if $label8
              call $env.lose
              br $label9
            end $label8
            (snip)
          end $label10
          unreachable
        end $label7
        local.get $var2
        i32.load offset=136
        local.set $var165
        local.get $var165
        call $env.win
        br $label9
      end $label6
      call $env.lose
    end $label9

何となく処理の流れは掴めたので、あとはChromeのDebuggerで変数の値を確認しながら実際に動かしてみる。

wasmの動的解析

※この解析には冒頭で紹介したChromeのWebAssemblyのデバッグ機能を使いたいためChromeのCanary版を使用している。(2020/12/22現在)

verify_flagの先頭にブレイクポイントを設定し、usernameにaaaaaaaaaaaaaaaa、Passwordにbbbbbbbbbbbbbbbbを入力して実行する。
f:id:graneed:20201222004404p:plain

そして、Chromeの新機能を発動するべく右側のペインのenv.memoryで右クリックしてInspect memoryを選択する。
f:id:graneed:20201222004505p:plain

下側のペインにメモリの状態が表示された!ASCII表示もあるぞ!
これまで右側のペインで縦に並んだ変数1つずつしか見れなかったのに、これは嬉しい。
f:id:graneed:20201222004558p:plain

また、変数にカーソル当てると、変数の中がホバーで表示されるようになった!
「え、今までそうじゃなかったの?」と疑問に思う人がいるかもしれないが、2020/12/22現在の安定板のChromeではできない。
f:id:graneed:20201222005231p:plain

このホバーで表示された変数をコピーし、先ほどのメモリインスペクタのアドレス欄に貼り付ける。 (10進数を貼り付けると、自動的に16進数に変換してくれる。) すると、何かのバイトデータが格納されていることがわかる。読める、読めるぞ。
f:id:graneed:20201222005635p:plain

上図は最初の変数の初期化が終わるあたりまで処理を進めた後の状態であるが、次に55とXORをとっているループ処理を何周か進めてみると、JavaScriptのDOMへのアクセスっぽい文字列が出てきた。
f:id:graneed:20201222010230p:plain

ループを最後まで進めると、HTMLのflag項目(Passwordのテキストボックスに入力した文字列)を取得するためのJavaScript呼出し文字列であるdocument.getElementById("flag").valueが出現した。
f:id:graneed:20201222010413p:plain

そして、その次の$env.getStringにこのJavaScript文字列を渡し、Passwordに入力した文字列を取得しメモリに格納された。
f:id:graneed:20201222010947p:plain

同じように次の$env.getStringまで処理を進めると、window.location.hostnameの文字列を渡してホスト名wasmfans.gaをメモリに格納していることを確認できた。
f:id:graneed:20201222011240p:plain

$func23まで進めると、返り値0が返ってきてそのままlose処理へ。

$func23を突破するため、24文字かつprefixがutflag{、suffixが}の条件を満たす文字列をPassword項目に入力してリトライ。
f:id:graneed:20201222012546p:plain

無事に突破して$aes_encrypt_blockの呼出しまで進め、渡している変数を確認する。
ここでも変数のホバー表示とメモリインスペクションが活躍する。
f:id:graneed:20201222012639p:plain

渡している3つの変数は以下のとおり。

  • Password項目に入力したutflag{AAAAAAAAAAAAAAAA}のA×16文字の部分。
    f:id:graneed:20201222013051p:plain
  • いつの間にかメモリに格納されていたnasmfans.gaという文字列。どうやら静的解析の7の処理で生成していたようだ。AESの鍵にあたる。
    f:id:graneed:20201222013028p:plain
  • 0x00。たぶんAESのIV。
    f:id:graneed:20201222013137p:plain

そろそろゴールは近い。

入力文字列をAES暗号化したデータとどのデータと比較しているかだが、ステップ実行しながら変数とメモリインスペクションを確認していくと、以下のバイトデータと比較していそうなことがわかる。 f:id:graneed:20201222013652p:plain

残念ながらメモリインスペクションからコピペができないため、手動で書き出す。
0f ae f8 59 84 b1 28 67 28 18 88 17 64 d3 25 2a

あとはAES復号するだけ。

from Crypto.Cipher import AES

encrypted = [0x0f, 0xae, 0xf8, 0x59, 0x84, 0xb1, 0x28, 0x67, 0x28, 0x18, 0x88, 0x17, 0x64, 0xd3, 0x25, 0x2a]
key = []

for c in 'nasmfans.ga':
    key.append(ord(c))
while len(key) != 16:
    key.append(0)

cipher = AES.new(bytes(key), AES.MODE_ECB)
print(cipher.decrypt(bytes(encrypted)))

実行する。

root@kali:/mnt/hgfs/CTF/Contest/wasm# python3 solve.py
b'fPRv38aICAz31Ix7'

フラグ文字列感は無いが、試しに画面入力してみる。prefixとsuffixは忘れずに。

f:id:graneed:20201222020602p:plain

初めてのWebAssembly問ということで、元のCのソースやWriteupがあるにもかかわらず時間をかけたが、おかげで多少慣れた。直近のChromeデバッグ機能追加が無ければ心が折れたかもしれない。今後の機能強化も期待したい。