In this challenge we are greeted with a web page:

Let’s go ahead and make an account and very quickly we can observe that we are able to make and post threads with markdown content.
The following source code we are given gives us a more granular look of things:
router.post('/threads/create', AuthMiddleware, async (req, res) => {
const {title, content, cat_id} = req.body;
if (cat_id == 1) {
if (req.user.user_role !== 'Administrator') {
return res.status(403).send(response('Not Allowed!'));
}
}
category = await db.getCategoryById(parseInt(cat_id));
if(category.hasOwnProperty('id')) {
try {
createThread = await db.createThread(req.user.id, category.id, title);
}
catch {
return res.redirect('/threads/new');
}
newThread = await db.getLastThreadId();
html_content = makeHTML(content);
return db.postThreadReply(req.user.id, newThread.id, filterInput(html_content))
.then(() => {
return res.redirect(`/threads/${newThread.id}`);
})
.catch((e) => {
return res.redirect('/threads/new');
});
} else {
return res.redirect('/threads/new');
}
});
router.post('/threads/preview', AuthMiddleware, routeCache.cacheSeconds(30, cacheKey), async (req, res) => {
const {title, content, cat_id} = req.body;
if (cat_id == 1) {
if (req.user.user_role !== 'Administrator') {
return res.status(403).send(response('Not Allowed!'));
}
}
category = await db.getCategoryById(parseInt(cat_id));
safeContent = makeHTML(filterInput(content));
return res.render('preview-thread.html', {category, title, content:safeContent, user:req.user});
});
So the makeHTML function turns our input into some HTML, then passed to some filter function before being stored. Those two functions are found in the MDHelper.js file:
const filterInput = (userInput) => {
window = new JSDOM('').window;
DOMPurify = createDOMPurify(window);
return DOMPurify.sanitize(userInput, {ALLOWED_TAGS: ['strong', 'em', 'img', 'a', 's', 'ul', 'ol', 'li']});
}
const makeHTML = (markdown) => {
return conv.makeHtml(markdown);
}
One of the allowed tags is for images, which means that if we include a markdown reference to an image, it won’t be sanitized.
Going to hacktricks and testing out some of the payloads lands me with this one which works fine:
)
We can use this as the body of a thread and then preview the page to trigger our XSS:

Now that we have this to go off of, we want to see if there is a way we can serve this self-XSS to a victim user. If we look into the code some more, we can find the cache key that is being used to determine if a response should be cached for re-use in the future.
The cache key is constructed like this:
const cacheKey = (req, res) => {
return `_${req.headers.host}_${req.url}_${(req.headers['x-forwarded-for'] || req.ip)}`;
}
So our key is a combination of the URL we request, the X-Forwarded-For header, and the IP we requested from in the form of the Host header. It would end up looking something like this for one of our earlier requests:
cachekey = _94.237.62.195:58671_/threads/preview?12345_94.237.62.195
So in order for us to actually pull off some cache poisoning, we need another user to have the same cache key as us - thankfully all these things we can change in the headers and the source code tells us what headers the admin might be producing in bot.js:
const visitPost = async (id) => {
try {
const browser = await puppeteer.launch(browser_options);
let context = await browser.createIncognitoBrowserContext();
let page = await context.newPage();
let token = await JWTHelper.sign({ username: 'moderator', user_role: 'moderator', flag: flag });
await page.setCookie({
name: "session",
'value': token,
domain: "127.0.0.1:1337"
});
await page.goto(`http://127.0.0.1:1337/report/${id}`, {
waitUntil: 'networkidle2',
timeout: 5000
});
await page.waitForTimeout(2000);
await browser.close();
} catch(e) {
console.log(e);
}
};
Now we know that we need to modify our headers to match the admin’s cache key, for example:
Host: 127.0.0.1:1337
X-Forwarded-For: 127.0.0.1
We still need the URL to match, but if we look around the site a bit more we can report posts to try and get the admin’s attention:
POST /api/report HTTP/1.1
Host: 94.237.62.195:58671
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://94.237.62.195:58671/threads/3
Content-Type: application/json
Content-Length: 15
Origin: http://94.237.62.195:58671
Connection: close
Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTEsInVzZXJuYW1lIjoiZ2FiZWN0ZiIsInJlcHV0YXRpb24iOjAsImNyZWRpdHMiOjEwMCwidXNlcl9yb2xlIjoiTmV3YmllIiwiYXZhdGFyIjoibmV3YmllLndlYnAiLCJqb2luZWQiOiIyMDI0LTA0LTExIDAyOjI2OjU1IiwiaWF0IjoxNzEyODAyNDIyfQ.HmGYWunnREBVBqWtLofxerzIVrpJmzGGhSeOAfCEcQU
{"post_id":"6"}
The post_id parameter in this request maps to the id parameter used to generate the admin’s cache key. We can change this parameter to direct the admin to the preview page where our self XSS is hosted.
Putting it all together, we first make a preview thread with a payload that sends the viewer’s document.cookie to our collaborator.
That XSS payload looks like this:

Now we just make a preview thread with this and change the Host and X-Forwarded-For headers to match what we need:

Next we just modify the post_id parameter in the /api/report request and the admin should get duped so long as we are swift about sending these two requests:

Then in a few short seconds we get an interaction on our collaborator:

If we weren’t in CTF land this token might let us get an active session as an administrator, but in this case we just get a flag out of it when we decode the token:

Recap: we found self XSS because the source code indicated that image tags were not sanitized in the same way the rest of the HTML would be. We then identified the cache key and took advantage of the report functionality to match the admin’s cache key. Ultimately allowing us to deliver an exploit to the admin that gave us their session cookie.