Box: Plant Photographer · Platform: TryHackMe · Category: Web · Difficulty: Medium
Enumeration
Port / Service Fingerprint
Visiting http://<target>/ reveals a Flask portfolio site (Jay Green, Plant Photographer). Response headers confirm the stack immediately:
| |
Directory Enumeration
Running gobuster against the root surfaces three non-standard endpoints:
| |
/admin— returns “Admin interface only available from localhost!!!” for external requests./console— the Werkzeug interactive debugger, PIN-locked (EVALEX_TRUSTED: false)./download— acceptsserverandidquery parameters; returns “No file selected…” with no args.
The hidden sidebar in the page source already links to /admin:
| |
Flag 1 — API Key in Source
Triggering a deliberate error on the download endpoint leaks the full Flask source through the Werkzeug debug traceback. The key line:
| |
The app source at /usr/src/app/app.py (confirmed from the traceback) also reveals the complete download route logic:
| |
server is passed unsanitised directly to pycurl — classic SSRF.
Flag 1: THM{REDACTED}
Flag 2 — SSRF → Localhost Admin Bypass
The Admin Route
Reading the app source confirms what /admin does when the request comes from 127.0.0.1:
| |
The Flask dev server listens internally on port 8087 (confirmed from /proc/net/tcp: 0x1F97).
Fragment Trick to Hit /admin
The download route appends /public-docs-k057230990384293/<id>.pdf to the server parameter. By placing a URL fragment (#) in the server value, pycurl strips everything after the # before sending the HTTP request, letting us hit any path:
| |
pycurl resolves this as GET /admin to 127.0.0.1:8087 — the check passes, and the response is flag.pdf.
| |
Flag 2: thm{REDACTED}
Flag 3 — Werkzeug PIN Cracking → RCE
Collecting PIN Ingredients via file:// SSRF
pycurl supports the file:// scheme. Passing server=file:///path/to/file%23 (fragment drops the appended path) turns the download endpoint into an arbitrary file reader:
| File | Value |
|---|---|
/etc/passwd | Root user confirmed, Alpine Linux |
/proc/sys/kernel/random/boot_id | 6362c4aa-15d9-439a-be50-6399f7c2b5cb |
/sys/class/net/eth0/address | 02:42:ac:14:00:02 |
/proc/self/cgroup | Docker container ID: 77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca |
How Werkzeug 0.16 Generates the PIN
Werkzeug’s get_machine_id() checks /proc/self/cgroup first. If a /docker/<id> path is found, it returns only the container ID — no combination with the boot ID:
| |
The probably_public_bits and private_bits arrays:
| |
PIN calculation (md5, Werkzeug 0.16):
| |
Authenticating and Executing Commands
| |
The file listing reveals the hidden flag file:
| |
| |
Flag 3: THM{REDACTED}
Summary
| Step | Technique |
|---|---|
| Flag 1 | Werkzeug debug traceback exposes hardcoded API key in source |
| Flag 2 | SSRF via pycurl server param + URL fragment bypass → localhost admin returns flag.pdf |
| Flag 3 | file:// SSRF reads /proc/self/cgroup & MAC → Werkzeug PIN cracked → RCE via debug console |
The chain is a textbook SSRF-to-RCE: an unrestricted server parameter with pycurl enables both file:// LFI (to gather PIN ingredients) and HTTP SSRF (to reach the localhost-only admin), while the exposed Werkzeug debug console turns PIN knowledge into full code execution.