DOM-based XSS
The vulnerability is entirely in client-side JavaScript: a source reads attacker-controllable input and passes it, unsanitized, to a sink that writes it into the page or executes it. The server may never see the payload at all - in the classic location.hash case it sits after the #, which browsers don’t send in the request. That makes DOM XSS quiet (nothing in server logs) and invisible to tools that only diff server responses.
# Payload rides in a client-only source - fragment, often never sent to the serverhttp://<TARGET>/#<img src=x onerror=alert(document.domain)>
# Vulnerable when a source flows to a sink with no sanitization, e.g.:document.getElementById('out').innerHTML = decodeURIComponent(location.hash.slice(1));The decodeURIComponent matters: browsers percent-encode <, >, and spaces in the fragment, so a sink that assigns location.hash raw to innerHTML inserts the text %3Cimg...%3E, not an element. Real-world DOM sinks almost always decode first (as above) - that decode step is what makes the payload fire.
Success indicator: the payload executes, but the raw HTTP response does not contain it in a form the client reads from the body - it is assembled into the live DOM by JavaScript (visible under Inspect / DevTools, not necessarily in View Source). That source→sink assembly, not mere absence from the response, is the signature of DOM XSS.
Sources and sinks
Section titled “Sources and sinks”A DOM XSS exists only when a source reaches a sink. Learn to spot both.
Sources (attacker-influenceable inputs read by JS):
| Source | Notes |
|---|---|
location / location.hash / .search / .href |
The fragment (#…) is the classic one - not sent to the server |
document.URL, document.documentURI, document.referrer |
URL-derived strings |
window.name |
Survives cross-origin navigation - a cross-domain delivery vector |
postMessage data |
Cross-window messages (see below) |
localStorage / sessionStorage / cookies |
Persisted client-side values |
Sinks (functions that turn a string into markup or code):
| Sink | Effect |
|---|---|
innerHTML, outerHTML, insertAdjacentHTML |
Parses the string as HTML |
document.write(), document.writeln() |
Writes HTML into the document |
eval(), Function(), setTimeout/setInterval with a string |
Executes the string as JS |
element.src / .href set to javascript: |
Executes on activation |
jQuery .html(), $() selector with markup, .append() |
Parses HTML |
Step 1 - Trace source to sink
Section titled “Step 1 - Trace source to sink”- Open DevTools and search the page’s JavaScript for sink names (
innerHTML,document.write,eval,.html(). - For each sink, work backwards: does its argument derive from a source (
location,document.referrer,postMessage, storage)? - If a source reaches a sink with no encoding/sanitization in between, you have a candidate. Burp’s DOM Invader automates this trace and flags source→sink flows.
- Confirm by putting a canary in the source (e.g. in
#…) and watching JavaScript assemble it into the live DOM via Inspect. Seeing it appear in the DOM but not View Source is a strong signal; but the reverse is not proof of safety (see the failure modes).
Step 2 - Match the payload to the sink
Section titled “Step 2 - Match the payload to the sink”The sink dictates the payload, independent of any server-side context:
# innerHTML / document.write (HTML sink) - no <script>; use a handler#<img src=x onerror=alert(document.domain)>#<svg onload=alert(document.domain)>
# eval / setTimeout("…") (JS sink) - inject JS directly, close any wrapper';alert(document.domain);//
# href/src set from a source (URL sink)javascript:alert(document.domain)See Payload construction for the full catalog and Filter bypasses when client-side code tries to strip or encode the value.
postMessage attacks
Section titled “postMessage attacks”A frequent modern DOM XSS: a page registers a message listener and feeds the message into a sink without checking the sender.
// Vulnerable listener - no origin check, data flows to innerHTMLwindow.addEventListener('message', (e) => { document.getElementById('out').innerHTML = e.data;});If the handler does not validate e.origin (and sometimes e.source), any page that can open or frame the target can post a payload to it:
// Attacker pageconst w = window.open('https://<TARGET>/');setTimeout(() => w.postMessage('<img src=x onerror=alert(document.domain)>', '*'), 2000);Check every addEventListener('message', …) for a missing or weak origin check (e.g. indexOf/startsWith tests that a lookalike domain satisfies).
Why DOM XSS evades detection
Section titled “Why DOM XSS evades detection”- The payload need never appear in the HTTP response, so response-diffing scanners and WAFs that inspect requests/responses miss it.
location.hashcontent is not sent to the server, so there is nothing in server logs - this is reflected in thestealthnoise rating.- It can be purely reflected (fragment) or stored (a tainted value read from
localStorageon every load) - the source decides, not the server.
Common failure modes
Section titled “Common failure modes”- Canary appears in View Source → this does not rule out DOM XSS. A value can be server-rendered into the response (a JSON blob, a
data-attribute, a hidden field) and then read by client JS that passes it to an unsafe sink. Classify by the client-side source→sink flow, not by presence in the response. Absence from View Source but presence in the live DOM is a strong positive signal; presence in View Source is simply inconclusive. <script>doesn’t run viainnerHTML→ expected; useonerror/onloadhandler payloads.- Framework auto-escapes (React/Angular/Vue) → default bindings encode output; look for the escape hatches (
dangerouslySetInnerHTML,[innerHTML],v-html,bypassSecurityTrust*) or template-injection sinks. - Value is URL-decoded twice / re-encoded → trace the exact transformations in the JS; the working payload matches what survives to the sink.
Quick reference
Section titled “Quick reference”| Step | Action |
|---|---|
| Find sinks | Grep JS for innerHTML, document.write, eval, .html( |
| Trace back | Does the sink argument come from a source? |
| Automate | Burp DOM Invader for source→sink flows |
| Confirm | Canary in source → appears in live DOM, not View Source |
| Payload | Match the sink (HTML sink → handler payload; JS sink → raw JS) |
| postMessage | Check listeners for missing e.origin validation |
Next move
Section titled “Next move”- Source→sink confirmed in HTML sink →
<img src=x onerror=…>/<svg onload=…>; escalate via XSS to session - Sink is
eval/setTimeout→ inject JS directly with';…// - Vulnerable
postMessagelistener → host an attacker page that opens/frames the target and posts the payload - Payload sanitized client-side → Filter bypasses
- Executes only in an admin/other view you can’t see → Blind XSS
- Strict CSP blocks inline handlers → look for CSP bypasses in Filter bypasses