Two Documents, the Same Bytes

DOM Clobbering and Mutation XSS

DOM Clobbering and Mutation XSS look like unrelated curiosities. They’re really the same engineering failure seen twice. Security tooling validates one interpretation of a byte stream while the browser executes a different one.

Both attacks exploit a gap between what a security check sees and what the browser does. Here’s how that gap forms, how to test for it, and which defenses help close it.

Why these bugs still happen

Cross-site scripting has a well-understood shape: untrusted input reaches somewhere the browser will interpret it as code. A generation of defenses grew up around that model. We escape output, allowlist tags and attributes, ship a Content Security Policy, and assume that if the markup we hand to the page contains no <script> or event-handler attributes, nothing will run.

DOM Clobbering and Mutation XSS both break that assumption while still satisfying the rules it was built on. Neither requires an injected <script>. Neither requires an onerror. Both can pass through sanitizers that a security team would normally consider safe.

The problem is that the check and the execution happen against two different models of the same bytes.

In DOM Clobbering, the two models are about how a name resolves. The sanitizer sees normal elements carrying an id or name. The browser sees those same attributes as entries in the named-property namespace of document and window. Attacker-controlled markup can then shadow globals that the page’s own JavaScript depends on.

Mutation XSS has the same kind of disagreement, but in parsing. The sanitizer inspects one parse tree and decides that it is clean. That tree is serialized back into a string and inserted again. The browser’s parser runs under a different context and can produce a different tree, potentially one containing executable markup.

The key question throughout is simple: does the browser use the same structure your security check approved?

How bytes become a live document

To understand the gap, it helps to look at what happens between a string of markup and a running page. Three mechanisms matter, and each creates a place where interpretations can diverge.

Parsing is context-sensitive and lossy

The HTML parser does not simply map characters to elements one-to-one. It uses a tree-construction algorithm with different modes and insertion rules. What a tag does depends partly on where it appears.

A <td> outside a table can be dropped. A <p> can be implicitly closed by a following block element. More importantly for this problem, HTML, SVG, and MathML use different parsing rules. The parser can switch between them in the middle of a document when it crosses into foreign content such as <svg> or <math>.

The same characters can therefore represent an element in one context and harmless text in another.

Serialization is not the inverse of parsing

Reading element.innerHTML serializes the current DOM into a string. Assigning to innerHTML parses a string into a DOM.

Those operations look like opposites, but they aren’t guaranteed to be.

There are trees whose serialized representation produces a different tree when parsed again. In other words:

parse(serialize(t)) does not always equal t.

That fact is the mechanical basis of Mutation XSS.

Names resolve against the DOM, not just variables

Long before getElementById existed, browsers allowed scripts to reach elements by name. That behavior is still around.

Elements with certain id or name values can participate in named property access on objects such as window and document. That means application code which expects a normal JavaScript value may instead resolve an attacker-injected DOM element.

That behavior is the mechanical basis of DOM Clobbering.

These behaviors give the same input more than one meaning. A defense can inspect one meaning while the application acts on another.

DOM Clobbering

DOM Clobbering is basically a client-side code-reuse attack. The attacker does not necessarily inject their own logic. Instead, they inject named markup that changes a value the application’s trusted JavaScript later reads.

The application supplies the useful code. The attacker changes the data that code operates on.

Where the surface comes from

Named-property behavior is built into browsers. An element with an appropriate id can become available through named access, while collections of named elements can produce more complicated object-like structures.

Those behaviors give attackers a way to influence values using markup alone.

Level one - shadowing a global

If application code guards a feature with a simple truthy or falsy check, an injected named element may be enough to change the result.

// Trusted application code already on the page:
if (window.isAdmin) {
  renderAdminControls();
}

// Attacker-controlled markup that survives a script-stripping sanitizer:
// <a id="isAdmin"></a>
//
// window.isAdmin may now resolve to an element, which is truthy.

No attacker JavaScript was injected. The application’s own code made the security-relevant decision.

Level two - producing a usable value

More interesting gadgets read something they expect to be a string, such as a URL that later becomes a script source, redirect, or network destination.

Certain DOM elements, especially anchors, have useful string-like behavior. Combined with named-property resolution, attacker-controlled markup can sometimes supply a value to trusted code.

// Trusted gadget expects a configured base URL:
const base = window.cfg.endpoint;
const s = document.createElement('script');

s.src = base + '/widget.js';
document.head.appendChild(s);

// Attacker-controlled markup may influence what cfg.endpoint resolves to.

The risk is that trusted code reads security-sensitive configuration from a namespace attacker-controlled markup can influence.

Level three - clobbering built-ins

Named access can also interfere with assumptions about properties exposed by browser objects.

Code that assumes a property on document or window will always have a particular type can fail when markup changes how that name resolves. Depending on the surrounding code, that may cause anything from a crash to a useful exploitation primitive.

Why it survives good sanitizers

The attributes DOM Clobbering needs, particularly id and name, are also completely normal HTML attributes. Stripping them globally would break legitimate styling, anchors, forms, accessibility behavior, and application logic.

That’s why DOM Clobbering isn’t simply a case of allowing an obviously dangerous tag.

It also has an important precondition: the application needs a gadget. There must already be trusted code that reads from a clobberable name and then does something useful with it.

That is what makes clobbering a code-reuse attack. The interesting part isn’t just finding somewhere to inject an id. It’s finding trusted code that gives that id power.

What makes an application interesting to test

  • Security-relevant configuration or feature flags read from window or document by name.
  • Truthy/falsy or fallback checks that an unexpected DOM element could satisfy.
  • HTML injection that permits named elements even though scripts and event handlers are stripped.

Mutation XSS

Mutation XSS takes advantage of the fact that parsing and serialization are not perfect inverses.

A sanitizer receives markup, parses it, decides it is safe, and returns serialized output. Somewhere downstream that output is treated as HTML again and parsed a second time.

If the second parse produces a different tree from the one the sanitizer approved, something that was harmless during validation can become active after insertion.

That’s the mutation in Mutation XSS.

Where the surface comes from

One of the richest sources of parser differences is foreign content.

Inside <svg> and <math>, browsers follow different parsing and namespace rules than they do in ordinary HTML. Certain elements also act as transition points where content interpreted one way in one namespace may be interpreted differently after another parse.

This has historically created problems for sanitizers because a sanitizer may reason about one tree while the browser later reconstructs another.

The mechanism, minimally

The basic idea is simple: find markup whose serialization does not parse back into the same structure.

The sanitizer examines a tree where some dangerous-looking fragment is harmless because of its current parsing context. The tree is approved and serialized. When that serialized string is parsed again in another context, the browser produces a different structure.

Conceptually:

<!-- First parse -->
<svg>
  <style>content treated according to this parsing context</style>
</svg>

<!-- Sanitizer approves the resulting tree -->

<!-- Tree is serialized back to text -->

<!-- Serialized output is later parsed again -->

<!-- Second parse may not reconstruct the same tree -->

The exact payloads change as browsers and sanitizers are patched. That’s part of the point.

If you treat the vulnerability as a list of malicious strings, you’re stuck chasing payloads forever. The underlying problem is the round trip itself.

Researchers have found variations involving namespace confusion, comments and CDATA handling, differences in <noscript> parsing, and serialization behavior around special characters. The details change. The parser differential remains.

Why sanitize-then-mutate is the failure

The dangerous pattern is structural:

parse → make a trust decision → serialize → parse again → render

The security decision happened during one interpretation. Execution happens after another.

If the second parse can change the markup, test whether that change can make something unsafe run.

What makes an application interesting to test

  • Sanitizer output returned as a string and later sent to innerHTML, outerHTML, or insertAdjacentHTML.
  • Template logic that modifies or reconstructs sanitized markup before insertion.
  • Content that crosses HTML, SVG, or MathML parsing boundaries.

What the attacks have in common

Put DOM Clobbering and Mutation XSS next to each other and the shared problem becomes easier to see.

Both are differentials: two parts of the platform claim to understand the same input, but they don’t give it exactly the same meaning. The security decision happens in one model while the final behavior comes from another.

DOM Clobbering is a differential in the naming model. A sanitizer sees id and name as normal attributes. Runtime property resolution may treat them as names available to JavaScript.

Mutation XSS is a differential in parsing and serialization. The sanitizer approves one tree. The browser later constructs another.

In both cases, the browser gets the last word.

Content Security Policy, output encoding, and allowlist sanitization are still useful defenses. The problem is that none of them automatically removes a disagreement between two interpretations.

A parser differential isn’t necessarily a bad character you forgot to escape or a dangerous tag you forgot to remove. It’s a disagreement about what the input means.

The stronger position is architectural: make the component that decides whether something is trusted operate on the same representation that ultimately gets rendered.

What to look for

Testing looks for a parsing or property-lookup difference that reaches something security-sensitive. Code review asks whether untrusted markup can create that difference in the first place.

Finding an exploitable path

  • Enumerate the sinks first. Search client-side code for innerHTML, outerHTML, insertAdjacentHTML, document.write, and framework equivalents such as dangerouslySetInnerHTML or v-html. Sanitized output that later reaches one of these deserves attention.

  • Map gadgets for clobbering. Look for reads from window.NAME, document.NAME, fallback defaults, and configuration pulled from the DOM. Then determine whether attacker-controlled markup can influence the same name.

  • Differential-test the sanitizer. Parse a candidate fragment, serialize it, parse it again, and compare the resulting DOMs. Structural differences are worth investigating, especially around SVG, MathML, and other known parsing boundaries.

  • Prove impact, not novelty. A weird parser mutation that reaches nothing is interesting research, but not necessarily an exploitable bug. Trace it to what actually happens: script loading, redirects, authorization behavior, or another security-sensitive sink.

Reviewing the code

  • Pay attention to string-typed sanitizer output. If sanitized HTML becomes a string and later gets parsed again by an HTML sink, review the entire path rather than assuming the sanitizer settles the question.

  • Avoid named global reads for security decisions. Feature flags, authorization state, endpoint configuration, and similar values should come from controlled application state, not runtime named access on window or document.

  • Lint for the round trip. Static analysis can catch sanitize-to-string-to-HTML-sink patterns before they become a production problem.

  • Watch browser reporting. CSP and Trusted Types violation reports can expose unexpected sink usage and other behavior worth investigating rather than treating every report as policy noise.

Signals to search for

Signal What it indicates
.innerHTML =, insertAdjacentHTML, outerHTML mXSS sink candidates
window.NAME, document.NAME, fallback expressions Clobbering gadget candidates
Security-sensitive reads from DOM-backed names Potential clobbering surface
Sanitizer allowlists permitting id, name, forms, and anchors Named-property surface worth reviewing
Round-trip differences across <svg> and <math> boundaries Mutation candidates

Defenses

No single control covers both bugs. The practical approach is to avoid reparsing sanitized strings, control access to HTML sinks, and keep trusted state out of names that markup can shadow.

Use native sanitization where it fits

The structural problem behind mXSS is the sanitize-serialize-reparse cycle.

Browser-native sanitization APIs can reduce that gap by moving sanitization closer to the parser and the point where content is inserted. Instead of having one library create a trusted string and another parser later reinterpret it, the browser can participate directly in the sanitization and insertion process.

Avoid transformations between the content you approved and the content the browser renders.

Trusted Types closes the injection-to-sink path

Trusted Types attacks the problem from the sink side.

When enforced, dangerous DOM sinks cannot simply accept arbitrary strings. Code has to pass values through an explicit policy first.

That does not magically decide whether every piece of HTML is safe, but it makes the trust boundary much easier to see and audit. Instead of security depending on every string operation scattered through an application, the decision can be concentrated into a smaller number of controlled policies.

It also turns many accidental “untrusted string reached an HTML sink” cases into browser-enforced failures instead of silent execution.

Keep trusted state out of clobberable namespaces

DOM Clobbering becomes much less useful when important application state does not come from names that markup can influence.

Security-sensitive configuration belongs in module scope, controlled application objects, or other locations whose meaning cannot be changed just because someone inserted an element with a matching id or name.

// Better: trusted configuration lives in application state,
// not in a name resolved from attacker-influenced markup.

import { endpoint } from './config.js';

const s = document.createElement('script');
s.src = endpoint + '/widget.js';

The simplest defense is to avoid this kind of gadget.

Gadget-free libraries close the reach

Clobbering needs trusted code that turns the manipulated value into something useful.

That means libraries should treat named DOM reads as part of their security surface. Configuration, script sources, URLs, and control-flow decisions should not depend on values that attacker-controlled markup can unexpectedly shadow.

This matters especially for widely deployed dependencies. One reusable gadget can create the same attack surface across many applications.

Content Security Policy as containment, not cure

CSP still earns its place as defense in depth.

A strict nonce- or hash-based policy can prevent attacker-controlled script sources or inline code from executing even when another layer fails. But CSP does not remove the underlying parser or resolver differential.

The clobber can still happen. The mutation can still happen. CSP limits what happens afterward.

Use CSP as containment behind those fixes, not as a replacement for them.

The defenses that work best all move in the same direction: reduce the distance between the thing you inspect and the thing the browser actually uses. Avoid unnecessary reparsing, control dangerous sinks, and keep trusted state out of namespaces attacker-controlled markup can influence.

Conclusion

DOM Clobbering and Mutation XSS persist because they arise from browser behavior that individual payload filters cannot reliably remove.

The browser can hold multiple interpretations of the same input. Named-property resolution gives markup meaning that isn’t obvious from looking at the HTML alone. Parsing and serialization can turn one DOM structure into another. Those are old browser behaviors, and trying to enumerate every payload built on top of them is a losing game.

For a security team, the practical takeaways are pretty direct. Treat sanitizer output that later gets parsed again as an mXSS candidate. Treat security-sensitive values resolved through named DOM properties as possible clobbering gadgets. Reduce unnecessary parsing round trips, use browser-enforced controls such as Trusted Types where appropriate, keep trusted configuration out of attacker-influenced namespaces, and use CSP as containment.

Trust the document the browser runs, not only the one your tooling inspected.