Mutation XSS (mXSS): How the Browser's Parser Rewrites Your Sanitized HTML
Mutation XSS turns HTML that is inert as a string into executable markup the moment the browser parses and re-serializes it. Here is why an innerHTML round-trip defeats a sanitizer, the parser quirks that cause it, and how to stop re-parsing sanitized output.
<math><mtext><table><mglyph><style>On this page
Mutation XSS (mXSS) is the variant where the sanitizer is correct and the bug fires anyway. The defining move is a mismatch in how HTML is read: the sanitizer inspects a string, decides it is safe, and hands it back — but when that string is later assigned to innerHTML, the browser parses and re-serializes it into a different DOM tree than the one the sanitizer saw. The markup that was inert as text mutates, on the way through the parser, into markup that executes.
The frustrating part is that nothing in the pipeline looks broken. The input passes sanitization. The output looks like the input. The vulnerability lives in the round-trip itself — a sanitized string parsed a second time by an engine with quirks the sanitizer did not model. This post covers why that round-trip is dangerous, the parser behaviors that drive it, and how to stop re-parsing already-sanitized output.
How it happens
mXSS needs a string that means one thing to a sanitizer and another thing to the browser's HTML parser. The parser is not a passive reader — it normalizes, it closes tags it thinks you forgot, and inside foreign content like <svg> and <math> it switches namespaces with different rules. Serialize the resulting tree back to a string and you can get markup that was never in the input.
The dangerous pattern is sanitize-then-reparse. Code sanitizes a value, stores or passes the cleaned string, and somewhere downstream assigns it straight back into the DOM:
const clean = sanitize(userHtml);
container.innerHTML = clean;
The sanitizer parsed userHtml once, walked that tree, and approved it. But innerHTML = clean makes the browser parse the string again, in the live document, where the surrounding context and foreign-content rules differ. If the second parse produces a tree the first parse never had, the sanitizer's verdict no longer applies to what actually renders.
Three families of quirk drive the mutation:
- Namespace confusion in
<svg>and<math>foreign content, where the parser treats tags and attributes under HTML rules in one place and XML-like rules in another, so an element that was a harmless container is re-read as something that can hold script. - Attribute and backtick quirks, where unusual quoting — backticks treated as quote characters by some engines, or attribute values that break out on re-serialization — reassembles into new tags.
- Implicit tag closing, where the parser auto-closes an element to satisfy content-model rules, shifting following markup into a context where it becomes active.
A concrete attack with realistic code
Suppose an application accepts rich text, runs it through a sanitizer, and renders the result with innerHTML. The attacker submits markup that the sanitizer parses as a benign annotation inside foreign content:
<math><mtext><table><mglyph><style><![CDATA[</style><img src=x onerror=alert(document.domain)>]]>
As a string, there is no live <img onerror> here — the dangerous tag is wrapped inside what looks like CDATA and style content, exactly the kind of inert payload a naive check waves through. The sanitizer parses it into one tree and sees nothing executable in the positions it inspects, so it returns the string largely intact.
Then the application does the round-trip:
container.innerHTML = sanitized;
The browser re-parses the string in the live document. Switching out of <math> foreign content and resolving the <table> and <style> boundaries, the parser re-serializes the fragment so that the <img> escapes its wrapper and lands in HTML context as a real element:
<img src="x" onerror="alert(document.domain)">
The onerror handler now exists as a live attribute on a live element, and it fires. Historic DOMPurify bypass classes followed exactly this shape — <svg>/<math> namespace switches and <style>/CDATA boundary tricks that survived one parse and mutated on the next — which is why each was fixed by teaching the sanitizer about the specific re-serialization, not by the calling code changing.
A normal XSS payload is dangerous the moment it is a string. An mXSS payload is inert as a string and only becomes dangerous when the browser parses it — so the bug is not in the input or the sanitizer alone, but in handing sanitized output back to the parser a second time.
What an attacker gains
Once the mutated <img onerror> (or equivalent) executes, mXSS grants exactly what any XSS grants — arbitrary script in the page's origin — but it gets there through a defense the team believed was sound:
- Bypassed sanitization. The payload runs on a page that was explicitly cleaned, defeating the control developers were counting on.
- Full session authority. The script reads the DOM, steals cookies that are not
HttpOnly, and forges state-changing requests as the victim. - Reach wherever the sanitizer is trusted. Rich-text fields, comments, and message bodies that all flow through the same sanitize-then-
innerHTMLpath share the bypass.
Because the rendered output looks identical to the safe input, mXSS is easy to miss in review and tends to survive until something parses the string the way the attacker intended.
The fix: stop the round-trip and prefer safe sinks
The root cause is parsing sanitized output a second time, so the fixes target that round-trip directly:
- Do not re-parse already-sanitized HTML. Sanitize once, immediately before insertion, and assign that result to the DOM without a further
innerHTMLpass that re-serializes it. - Prefer safe sinks. When the value is text, use
textContentinstead ofinnerHTMLso the browser never parses it as markup at all:
container.textContent = userInput;
- Keep the sanitizer updated. mXSS bypasses are fixed as the parser quirks behind them become known — an outdated sanitizer is missing exactly the re-serialization rules that block the latest classes. Track releases and upgrade promptly.
- Adopt Trusted Types. Enforce that only a vetted sanitizer can produce values assigned to dangerous sinks, so no raw string reaches
innerHTMLand the round-trip cannot be reintroduced by accident. - Set a strict Content-Security-Policy that blocks inline scripts, so a mutation that produces an inline handler is far harder to weaponize.
How SelfSec finds it
SelfSec's crawler renders modern, JavaScript-heavy applications with a real browser engine, then probes reflected, stored, and DOM-based sinks with context-aware payloads — including markup designed to mutate through an innerHTML round-trip rather than to look dangerous as a string. Because it confirms execution in the live DOM rather than guessing from a string match, a payload that only becomes active after the browser re-parses it is still caught, and every finding ships with a reproduction request you can replay. As always, the scan runs entirely on your machine and your scan data stays local.
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Cross-Site Scripting
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.