DOM Clobbering and Client-Side Path Traversal

DOM Clobbering and Client-Side Path Traversal - article cover image Web Exploitation
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

DOM clobbering is a technique for injecting HTML — with no <script> tag and no on* event handler — that still influences JavaScript execution. It exploits the browser’s named-property access behavior: elements with an id or name attribute are automatically exposed as properties on window and document. If application JavaScript reads a global (window.CONFIG) or a DOM lookup result and assumes it is either undefined or an object it created itself, an attacker who can inject markup — even through a strict HTML sanitizer that strips scripts — can *clobber* that reference with an HTMLElement or HTMLCollection carrying attacker-chosen values.

Client-side path traversal is a related front-end trust failure: a URL-derived value (a hash fragment, query parameter, or a value clobbered into the DOM) is concatenated into a path used by client-side routing, dynamic import(), or an AJAX request, without validating that it stays inside the intended directory or endpoint namespace. ../ sequences — or their encoded equivalents — let the attacker redirect the request to an unintended module or API path. The two techniques are frequently chained: DOM clobbering overrides a “trusted” configuration object, and the polluted value is then used to build a path that traverses outside its intended scope.

Attack Prerequisites

Both techniques target applications that trust markup-derived or client-controlled values as if they were only ever set by first-party script:

  • An HTML injection point that survives sanitization — a sanitizer (e.g. DOMPurify) or markdown renderer that allows harmless-looking tags/attributes such as <a id>, <img name>, or <form>/<input name>, even while stripping <script> and event handlers.
  • Front-end code that reads an implicit globalwindow.CONFIG, document.getElementById('x').value, or a bare identifier that resolves to a DOM element when no explicit variable of that name was declared — without a runtime type check.
  • A path-building routine (client-side router, dynamic import(), or a fetch helper) that concatenates a URL fragment/query value or a clobbered property into a path without normalizing ../ or validating against an allowlist.

How It Works

Per the HTML specification’s *named access on the Window/Document objects*, any element with an id or name attribute becomes reachable as window.<id> / document.<id> if no explicit JavaScript variable already occupies that name. <a id="CONFIG"> alone gives you window.CONFIG pointing at that anchor element — falsy checks like if (window.CONFIG) now pass, because an HTMLElement is truthy. Nested clobbering goes further: a <form> exposes its named <input> children as properties of the form element itself, so <form id="CONFIG"><input name="trustedDomain" value="evil.example"></form> produces window.CONFIG.trustedDomain, whose value coerces to the string "evil.example" in string contexts (via the input’s toString/value behavior) — enough to fool code that expected a genuine config object.

Client-side path traversal follows from the same misplaced trust, just applied to routing instead of configuration. A single-page app often turns a fragment or query value directly into a resource path: import('./pages/' + page + '.js') or fetch('/api/' + section + '/data'). Because the browser resolves relative module specifiers and URL paths using standard ../ semantics, a value like ../../admin/internal walks the path outside the intended pages//api/ namespace and loads or requests something the developer never intended to expose to that code path — an internal-only module, a different API surface, or (in Electron/desktop-shell contexts) a filesystem location outside the app’s asset directory.

The two chain naturally: if the value that decides *which path to load* comes from a DOM lookup that can be clobbered (rather than directly from the URL), an attacker who can only inject sanitized HTML — not run script — can still steer client-side routing or override a security-relevant setting such as a trusted origin, a CSP nonce holder, or a sanitizer configuration object.

Vulnerable Code / Configuration

Application code that trusts an implicit global without verifying its type is the enabling bug:

// VULNERABLE: assumes window.CONFIG, if present, was set by our own <script>
if (window.CONFIG && window.CONFIG.trustedDomain) {
  loadWidgetFrom(window.CONFIG.trustedDomain);   // no type/origin validation
}
JavaScript

A sanitizer that allows id/name on structural tags (the DOMPurify default allowlist, prior to explicitly enabling clobbering protection) lets an attacker reach that sink with pure markup — no <script>, no event handler:

<!-- Survives a sanitizer that strips scripts/event handlers but allows
     forms and named inputs -->
<form id="CONFIG">
  <input name="trustedDomain" value="evil.example">
</form>
<!-- window.CONFIG            -> the <form> element (truthy)
     window.CONFIG.trustedDomain -> the <input>, coerces to 'evil.example' -->
HTML

The path-traversal half of the bug is equally small — a router that trusts a URL-derived (or now clobbered) value verbatim:

// VULNERABLE: no normalization, no allowlist of known page names
function loadPage(name) {
  return import('./pages/' + name + '.js').then(m => m.render());
}
loadPage(location.hash.slice(1));
// URL: https://app.example/#../internal/adminTools
// -> import('./pages/../internal/adminTools.js')  resolves OUTSIDE ./pages/
JavaScript

Walkthrough / Exploitation

Start by mapping what the app trusts implicitly. Search the bundled JavaScript for bare-identifier reads and DOM lookups used in conditionals without a type guard (window.X, document.getElementById(...).value fed straight into logic, typeof x === 'object' checks that an HTMLElement will happily satisfy):

# quick grep across a downloaded bundle for classic clobbering-sensitive patterns
grep -nE "window\.[A-Za-z_]+ *(&&|\?)|getElementById\(.*\)\.value" bundle.js
Bash

Confirm clobbering works for a given identifier directly in the browser console against the sanitizer’s real allowlist before touching app logic:

<a id="CONFIG" name="trustedDomain" href="//evil.example"></a>
<!-- console: window.CONFIG        -> <a> element
             window.CONFIG.trustedDomain -> undefined (single element has no
             named children — use the <form>/<input> nesting trick above for
             multi-property objects) -->
HTML

Burp’s DOM Invader (built into Burp’s embedded browser) includes a dedicated DOM Clobbering scanner: it instruments the page, flags candidate sources/sinks automatically, and can inject canary elements to prove a given global is reachable — the fastest way to confirm exploitability without hand iterating every sanitizer allowlist combination.

For the path-traversal half, once you control the value fed into import()/fetch(), try both raw and encoded traversal sequences (../, %2e%2e%2f, ..%2f) and watch the Network/DOM Invader panel for requests or module loads landing outside the expected directory — a strong signal you can now reach internal-only bundles or cross-tenant API paths that were never meant to be client-selectable.

Note: Clobbering multi-level objects (CONFIG.a.b.c) requires nesting <form>/<input> structures, since only <form> exposes named descendants as its own properties. A single <a id=X name=Y> only clobbers one level and cannot represent a nested path — this is the detail that trips up most manual attempts.

Opsec / Testing tip: Always test clobbering payloads against the *exact* sanitizer configuration in use, not a default install — many apps tighten the allowlist (removing form/name) specifically because of this class of research. If clobbering the top-level global fails, check whether the app reads a value from inside an already-permitted container element instead (e.g. a rendered comment <div> whose children the app later queries by id).

Detection and Defense

Both halves of this bug are fixed by not trusting implicit, markup-derived identifiers as security-relevant state:

  • Never source security-relevant configuration from an implicit global or DOM lookup. Define config in a scoped module-level const, not window.X, so there is no name for HTML to clobber.
  • Type-check anything read from window/document before trusting itx instanceof HTMLElement or x instanceof HTMLCollection should be treated as “this was clobbered,” not a valid config object.
  • Enable clobbering protection in your sanitizer — DOMPurify’s SANITIZE_DOM/SANITIZE_NAMED_PROPS options (on by default in current versions) strip id/name values that collide with dangerous global names.
  • Normalize and allowlist path segments client-side too — resolve ../ sequences and reject any result that escapes the intended base directory before calling import()/fetch(); treat this as a real security boundary, not just a UX guard, and enforce the equivalent allowlist server-side as well.
  • Deploy a strict, nonce-based CSP with Trusted Types — Trusted Types blocks assignment of unvalidated values to dangerous DOM sinks, closing off many clobbering-to-XSS chains even when a clobbering primitive exists.

Real-World Impact

DOM clobbering research (notably by PortSwigger’s Gareth Heyes) demonstrated practical clobbering of sanitizer configuration objects and of the elements libraries use to locate CSP nonces, leading widely used sanitizers including DOMPurify to ship dedicated anti-clobbering defenses. Client-side path traversal shows up recurringly in single-page applications and Electron-based desktop apps where a URL fragment or IPC-supplied value is trusted to select a bundle, asset, or local file path without normalization.

Conclusion

Both bug classes come from the same root cause: treating something the browser derives from markup or a URL as if it were trusted, first-party JavaScript state. Keep security-relevant configuration in scoped variables instead of globals, type-check anything pulled from the DOM, and normalize/allowlist any path built from user-influenced input on both the client and the server — DOM clobbering and client-side path traversal both lose their power the moment the app stops trusting names it did not itself define.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL