Skip to content

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 server
http://<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.

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
  1. Open DevTools and search the page’s JavaScript for sink names (innerHTML, document.write, eval, .html().
  2. For each sink, work backwards: does its argument derive from a source (location, document.referrer, postMessage, storage)?
  3. 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.
  4. 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).

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.

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 innerHTML
window.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 page
const 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).

  • The payload need never appear in the HTTP response, so response-diffing scanners and WAFs that inspect requests/responses miss it.
  • location.hash content is not sent to the server, so there is nothing in server logs - this is reflected in the stealth noise rating.
  • It can be purely reflected (fragment) or stored (a tainted value read from localStorage on every load) - the source decides, not the server.
  • 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 via innerHTML → expected; use onerror/onload handler 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.
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
  • 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 postMessage listener → 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