August 12, 2026
DOM XSS: The Bug Your WAF Will Never Catch
Whatβs up everyone! Nitin here π

By Nitin yadav
2 min read
Here's the thing about DOM XSS that makes it so underrated: the payload can execute entirely in the browser β it may never hit the server at all. Which means the WAF never sees it, the server logs never record it, and half the hunters chasing reflected XSS in Burp Repeater walk right past it. In modern SPAs (React, Vue, Angular, and piles of legacy JS), this is where a lot of the XSS actually lives now. Let's go find it.
Sources and sinks: the whole mental model
DOM XSS is a data-flow bug. Attacker-controlled data enters through a source and flows, unsanitized, into a dangerous sink that executes it. Learn these two lists cold (the diagram above has them side by side):
Sources (attacker-controllable):
location.hash / location.search / location.href
document.referrer
window.name
postMessage (event.data)location.hash / location.search / location.href
document.referrer
window.name
postMessage (event.data)Sinks (execute or render):
element.innerHTML / outerHTML / insertAdjacentHTML
eval() / Function() / setTimeout(string) / setInterval(string)
document.write()
location = / location.href = / .assign()
jQuery: $(...).html(), $(sink)element.innerHTML / outerHTML / insertAdjacentHTML
eval() / Function() / setTimeout(string) / setInterval(string)
document.write()
location = / location.href = / .assign()
jQuery: $(...).html(), $(sink)Find a path where a source reaches a sink with no sanitization, and you've got DOM XSS. That's the entire game.
Step 1: Map the sinks in the JS bundle
Open DevTools β Sources, pull the app's JS, and search for the sinks above. Every innerHTML =, every eval(, every document.write( is a candidate. Modern bundles are minified β use the pretty-print ({}) button and search across all files. For each sink hit, ask: what variable feeds it, and can I control that variable from a source?
Step 2: Trace source β sink
Work backwards from the sink. Set a breakpoint on the line, then interact with the app (change the URL hash, navigate, send a message) and watch what flows in. If you can get location.hash or postMessage data into that innerHTML unescaped, it's exploitable. The classic hash-based one:
<https://target.com/page#><img src=x onerror=alert(document.domain)><https://target.com/page#><img src=x onerror=alert(document.domain)>If the app reads location.hash and drops it into innerHTML, that fires with zero server involvement.
Step 3: The postMessage goldmine
This is the part most hunters never test, so it's where the untouched bugs are. Apps use window.postMessage for cross-frame/cross-origin communication (widgets, SSO popups, payment iframes, chat embeds). The bug appears when a message listener doesn't validate the sender's origin and pipes the data straight into a sink:
// VULNERABLE listener β no origin check
window.addEventListener('message', function(e) {
document.getElementById('out').innerHTML = e.data; // sink
});// VULNERABLE listener β no origin check
window.addEventListener('message', function(e) {
document.getElementById('out').innerHTML = e.data; // sink
});Any page can send this frame a message. Your attacker page:
<iframe src="<https://target.com/widget>" id="f"></iframe>
<script>
f.onload = () => f.contentWindow.postMessage(
'<img src=x onerror=alert(document.domain)>', '*');
</script><iframe src="<https://target.com/widget>" id="f"></iframe>
<script>
f.onload = () => f.contentWindow.postMessage(
'<img src=x onerror=alert(document.domain)>', '*');
</script>Frame the target, fire the message, the unvalidated listener drops it into innerHTML, and your JS runs on their origin. To hunt these, search the bundle for addEventListener('message' and onmessage, then check every hit for a missing/loose if (e.origin === ...) guard. A weak check like e.origin.indexOf('target.com') > -1 is also bypassable (target.com.evil.com).
Step 4: Bypasses when a framework fights back
React/Vue escape most things by default β but they have escape hatches that reintroduce DOM XSS:
- React:
dangerouslySetInnerHTML={{__html: userInput}} - Vue:
v-html="userInput" - Angular:
bypassSecurityTrustHtml(userInput) - Any framework: a URL bound to an
href/srcβjavascript:payloads
Grep the source for those exact strings β each is a deliberate hole where the framework's protection was turned off.
Step 5: Automate the tracing
Burp's DOM Invader is purpose-built for this β it instruments the page, auto-traces sources to sinks, and specifically hunts postMessage bugs and prototype-pollution gadgets. Turn it on, browse the app, and it flags exploitable flows for you. Still confirm and craft manually so your report has a clean, reproducible PoC.
The impact ladder
- Self-DOM-XSS (needs the victim to paste a payload) β usually low/informational
- Hash/URL-driven DOM XSS via a link β medium
- postMessage XSS exploitable from any attacker page β high (no user interaction beyond visiting your page)
- DOM XSS in an authenticated app β session theft / account actions β high/critical
Conclusion β the DOM XSS playbook
- DOM XSS = a source reaching a sink unsanitized, client-side.
- Learn the source/sink lists; grep the JS bundle for sinks.
- Trace backwards with breakpoints; test hash-based payloads.
- Hunt
addEventListener('message'for missing/loose origin checks β the untouched goldmine. - Grep framework escape hatches (
dangerouslySetInnerHTML,v-html). - Use DOM Invader to automate, confirm by hand.