Cross-Site Scripting (XSS): The Complete Practical Guide
Mutation XSS (mXSS)
How mutation XSS works: the browser's HTML parser rewriting sanitized markup into executable code, and why client-side sanitizers get bypassed.
Published
Mutation XSS is the subtle one. Your input passes the sanitizer cleanly, as a string the sanitizer considers safe, and then the browser, while parsing that string into the DOM, rewrites it into something that executes. Nobody injected a live payload. The browser manufactured it out of markup that looked harmless, because the sanitizer and the parser did not agree on what the markup meant.
How the parser rewrites "safe" markup
Browsers do not store HTML as the text you wrote. They parse it into a tree and can reserialize it back into different text. When a sanitizer checks a string and then code assigns that string to innerHTML, the browser parses it again, and in some cases normalizes or rewrites it. If that rewrite produces a tag or attribute that executes, and the sanitizer approved the earlier, inert form, you have mutation XSS. The exploit is finding input that is safe before the browser touches it and dangerous after.
The innerHTML reserialization problem
The core sink is innerHTML. Reading element.innerHTML serializes the current DOM back to a string, and writing it reparses a string into DOM. A sanitize-then-assign flow runs the string through the parser after approval:
el.innerHTML = sanitize(userInput); // sanitizer approved a string the parser then rewritesThe sanitizer validated the input it received. The browser then parsed that input, possibly reshaping it. The gap between those two interpretations is where mXSS lives.
Namespace and nesting confusions
The classic mutations come from contexts where HTML parsing rules change: inside <svg> and <math> the parser switches to foreign-content rules, and elements like <template>, <noscript>, and comments have special parsing. Markup that is inert as ordinary HTML can, once the parser applies foreign-content or template rules, be reserialized so that an attribute or tag breaks out and runs. Crafting these takes knowledge of the specific parsing quirk, but the principle is always the same: exploit a place where the browser interprets your markup differently from how the sanitizer did.
Why client-side sanitizers get bypassed
A sanitizer is only safe if it models the exact mutations the browser will perform. Hand-rolled sanitizers never do, and even good libraries have shipped bypasses as new mutation cases were discovered. A sanitizer that works on the string without accounting for innerHTML reserialization is checking a version of the markup that will not exist by the time it renders. Server-side sanitizers that do not match the browser's parser have the same flaw.
Testing for it
Look for the dangerous flow first: input that is sanitized and then assigned to innerHTML (or round-tripped through it), especially inside rich-text rendering. Then feed parser-confusing markup built around svg, math, template, and comment edge cases and watch whether the serialized DOM differs from your input in a way that executes. The research literature catalogs known mutation vectors worth trying against a given sanitizer version, which overlaps with the obfuscation mindset in filter and WAF evasion.
The fix: a parser-accurate sanitizer
The defense is to use a maintained, parser-accurate sanitizer that reproduces the browser's mutations, keep it updated, and avoid re-feeding sanitized output through innerHTML. Prefer textContent when you only need text, and layer a strict CSP so a mutation that does slip through still has nowhere to run.
Mutation XSS is proof that "the sanitizer passed it" is not the same as "it is safe." See it bypass a sanitizer on a free live target.
← Back to Cross-Site Scripting (XSS): The Complete Practical Guide