Sanitized HTML is re-mutated into executable XSS by the browser's HTML parser, bypassing DOMPurify and similar sanitizers.
TL;DR
animate whitespace bypass used in state-sponsored attacksMutation XSS (mXSS) exploits a fundamental property of HTML parsing: the same markup can mean different things depending on the namespace context in which it is parsed. A sanitizer examines input in one context and finds it safe. The browser re-parses the sanitized output in a different context — during innerHTML assignment — and produces a different DOM tree containing executable JavaScript. The sanitizer and the browser disagree.
mXSS is distinct from all other XSS variants because the vulnerability is not in the application's failure to sanitize. The sanitizer ran correctly. The browser's HTML parsing engine, operating under its own namespace-aware rules, undoes the sanitizer's work. This is a parser differential attack, and it defeats every string-matching or DOM-inspection approach that does not account for re-parse behavior.
First comprehensively documented in Google Search and Proton Mail by Sonar Research, mXSS affects any application that: (1) accepts user HTML, (2) sanitizes it with a client-side library like DOMPurify, and (3) assigns the sanitized result to innerHTML. The pattern element.innerHTML = DOMPurify.sanitize(html) is "prone to mutation XSS-es by design" (Kevin Mizu, mizu.re, 2024).
HTML, SVG, and MathML have distinct parsing rules. The most exploited differential:
<style> in HTML namespace: content is RCDATA (text only — CSS, not HTML)<style> in SVG namespace: content is parsed as regular HTML (can contain child elements)A sanitizer operating in HTML namespace sees <style> as safe text. The browser, when re-parsing inside SVG context, treats the <style> content as HTML, spawning executable child elements.
<!-- DOMPurify (pre-3.4.0) passes this as safe in HTML namespace -->
<svg><style><img src=x onerror=alert(1)></style></svg>
<!-- When assigned to innerHTML in a context where SVG is parsed:
the browser sees <img onerror=alert(1)> as a real HTML element --><math><annotation-xml encoding="text/html"> and <svg><foreignObject> are integration points — locations where the parser explicitly switches back to HTML namespace inside foreign content. Sanitizers that operate only in HTML namespace miss that content inside these elements will be re-parsed as HTML on innerHTML assignment.
<math>
<annotation-xml encoding="text/html">
<!-- This content is HTML namespace — but sanitizer may miss it -->
<img src=x onerror=alert(document.domain)>
</annotation-xml>
</math>Tables, captions, <button> elements, and <select> elements create scope boundaries during HTML5 tree construction. Elements that cannot appear inside a table are "foster-parented" — relocated to before the table in the DOM. This relocation happens after the sanitizer's inspection pass, moving elements into executable contexts.
Browsers enforce a maximum nesting depth (~512 elements) and flatten excess nesting after parsing. DOMPurify implemented its own __depth counter to reject deeply nested content — but this counter itself could be DOM-clobbered:
<!-- DOMPurify ≤ 3.1.1 __depth counter clobber (Kevin Mizu) -->
<form id="x "><input form="x" name="__depth">The <input> element with name="__depth" creates a DOM property formElement.__depth that overrides DOMPurify's depth counter, resetting it mid-sanitization and allowing deeply nested mXSS bypass content to pass through.
Kevin Mizu's research at mizu.re (published November 2024) documented four sequential bypasses, each fixing the previous and introducing the next:
| Bypass | Version Affected | Technique | Researcher |
|---|---|---|---|
| Node flattening | ≤ 3.1.0 | 512-depth limit + <svg><caption> reordering creates post-sanitization DOM mutation | @IcesFont |
__depth clobbering | ≤ 3.1.1 | <form id="x "><input form="x" name="__depth"> resets depth counter | Kevin Mizu |
| Elevator mutation | ≤ 3.1.2 | <image> (SVG tag) converts to <img> (HTML tag) during namespace traversal | Kevin Mizu |
| Triple-parse chain | ≤ 3.1.2 | <form>/<table> mutation survives double-sanitize patterns (Mermaid.js) | Kevin Mizu |
Each bypass completely defeated sanitization — a payload passing through the vulnerable DOMPurify version was guaranteed to execute on innerHTML assignment.
CVE-2024-47875 (CVSS 10.0 CRITICAL): DOMPurify < 2.5.0 and 3.0.0–3.1.2, nesting-based mXSS via deeply nested MathML/SVG/foreignObject. Fixed in 2.5.0 / 3.4.0.
// DOMPurify version detection in browser console
window.DOMPurify && DOMPurify.version
// Or search JS bundles for:
// /DOMPurify\.VERSION\s*=\s*['"]([\d.]+)['"]/Any application running DOMPurify ≤ 3.1.2 is vulnerable to at least one of these bypass chains. DOMPurify 3.4.0 patched all four via regex-based detection blocking <!-- and --> patterns at the attribute level. Upgrade immediately.
CVSS 6.1 MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N). Affected Roundcube < 1.5.7 and < 1.6.7.
Roundcube's rcube_washer HTML sanitizer failed to strip whitespace from SVG <animate> attribute values. The attributeName check compared attribute names after string splitting — but " onbegin" (with a leading space) did not match the denylist "onbegin" (without space).
<!-- Payload — leading space in attributeName bypasses denylist check -->
<svg><animate attributeName=" onbegin" values="alert(1)" dur="1s" repeatCount="indefinite"/>The vulnerability was actively exploited in the wild by state-sponsored actors targeting government organizations in CIS countries. CISA added it to the Known Exploited Vulnerabilities catalog on 2024-10-24. This is a case study in why denylist approaches fail: a single whitespace character bypassed the entire sanitizer.
Joplin, a cross-platform note-taking application built on Electron, used htmlparser2 for sanitization before rendering in Chromium. The htmlparser2 library parsed SVG <style> content as text (HTML namespace rules), then Electron's Chromium renderer re-parsed the output in SVG namespace, treating the <style> content as HTML. The parser differential converted a <style> tag in an SVG into executable HTML.
This is the canonical example of parser differential mXSS: two different parsers — one for sanitization, one for rendering — operating under different namespace rules on the same content.
Sonar Research documented mXSS vulnerabilities in Google Search and Proton Mail caused by DOMPurify 2.0.0's handling of SVG namespace confusion. Both platforms ran DOMPurify in their rich text rendering pipelines and were vulnerable until Google and Proton deployed patched versions. Masato Kinugawa's prior work on Google Search mXSS established the foundational namespace confusion theory that later informed the systematic DOMPurify bypass research.
The paper "Dancer in the Dark: Synthesizing and Evaluating Polyglots for Blind Cross-Site Scripting" (Kirchner et al., USENIX Security Symposium, August 2024) introduced a systematic approach to synthesizing XSS polyglots using Monte Carlo Tree Search (MCTS).
A polyglot XSS payload executes across multiple injection contexts without modification — the same string works in HTML body, HTML attribute (single/double/unquoted), JavaScript string, URL context, and CSS. This is critical for blind XSS and mXSS scenarios where the execution context is unknown to the attacker.
The Magic 7 payloads were synthesized to achieve complete context coverage across all major injection contexts:
Validation result: Applied to Tranco Top 100,000 websites for blind XSS hunting, the Magic 7 set discovered 20 vulnerabilities in 18 web-based backend systems — a detection rate on par with full taint-tracking approaches.
Classic polyglot (demonstrates the multi-context escape technique, not the exact USENIX set):
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//
</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\\x3eBreachVex uses a curated set of polyglot payloads for mXSS and blind XSS coverage — injecting each one across unknown-context surfaces so a single probe exercises multiple parsing contexts at once.
The definitive mXSS detection method: submit a payload, sanitize it, serialize the sanitized DOM, re-assign to innerHTML, and compare the resulting DOM to what the sanitizer produced:
// Differential rendering test — run in browser console
function testMXSS(payload) {
// Step 1: Sanitize
const sanitized = DOMPurify.sanitize(payload);
// Step 2: Assign to DOM
const div1 = document.createElement('div');
div1.innerHTML = sanitized;
const serialized1 = div1.innerHTML;
// Step 3: Re-assign (simulates the re-parse attack vector)
const div2 = document.createElement('div');
div2.innerHTML = serialized1;
const serialized2 = div2.innerHTML;
// Step 4: Compare — mutation = potential mXSS
if (serialized1 !== serialized2) {
console.warn('mXSS differential detected!', { serialized1, serialized2 });
return true;
}
return false;
}
// Test with known mXSS payloads
testMXSS('<svg><style><img src=x onerror=alert(1)></style></svg>');innerHTML → serialize → re-parse confirmation cycle, only marking a finding CONFIRMED if the payload survives the full round-triphtmlparser2, sanitize-html, DOMPurify, and HtmlSanitizer.NETinnerHTML assignment monitoringBreachVex confirms mXSS only when a payload survives a full innerHTML → serialization → re-parse cycle, preventing false positives from payloads that sanitizers correctly block.
import DOMPurify from 'dompurify';
// Verify version before use
console.assert(
DOMPurify.version >= '3.4.0',
'DOMPurify version too old — mXSS vulnerabilities present'
);
// Sanitize with restrictive configuration
const safe = DOMPurify.sanitize(userHTML, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: false,
RETURN_TRUSTED_TYPE: true, // pairs with Trusted Types policy
});The triple-parse chain exploits applications that process sanitized output a second time. Once DOMPurify produces sanitized HTML, treat it as immutable:
// SAFE: sanitize once, assign once
element.innerHTML = DOMPurify.sanitize(input);
// VULNERABLE to mXSS: sanitize then concatenate (re-parse on assignment)
element.innerHTML = DOMPurify.sanitize(input) + userProvidedSuffix;
// VULNERABLE: sanitize with library that re-parses (Mermaid.js triple-parse pattern)
const sanitized = DOMPurify.sanitize(input);
mermaid.render('graph', sanitized); // Mermaid re-parses internallyTrusted Types enforce that innerHTML receives only values explicitly approved by a policy. Combined with DOMPurify, this adds a second gate:
// Trusted Types policy enforcing DOMPurify sanitization
if (window.trustedTypes) {
const policy = trustedTypes.createPolicy('default', {
createHTML: (input) => {
const sanitized = DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false });
// Additional: reject if sanitized !== re-serialized (differential check)
const div = document.createElement('div');
div.innerHTML = sanitized;
if (div.innerHTML !== sanitized) {
throw new TypeError('mXSS differential detected — rejecting input');
}
return sanitized;
},
});
}The W3C Sanitizer API uses the same HTML parser as the browser's rendering engine. This eliminates all parser differentials — sanitizer and renderer agree by construction:
// Native Sanitizer API — no parser differential possible
const sanitizer = new Sanitizer({
allowElements: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowAttributes: { 'a': ['href'] },
});
element.setHTML(untrustedHTML, { sanitizer });
// setHTML() is the safe equivalent of innerHTML — built into the browserThe native Sanitizer API and setHTML() are the long-term solution to mXSS. By using the browser's own parser for sanitization, the parser differential attack surface is eliminated. As of 2026, Firefox 148+ ships it; Chrome and Safari implementation is pending. Use DOMPurify 3.4.0+ as the fallback for cross-browser compatibility.
mXSS exploits the non-idempotency of HTML parsing. A sanitizer deems input safe after one parse pass, but when the sanitized output is assigned to innerHTML, the browser re-parses it in a different namespace context (SVG, MathML), yielding a different DOM tree that contains executable JavaScript. The sanitizer and the browser disagree about what the markup means.
HTML sanitizers like DOMPurify parse the input once to identify dangerous elements. The browser re-parses the sanitized output during innerHTML assignment — and parsing rules differ by namespace. A <style> element in HTML namespace is raw text (CSS), but in SVG namespace it contains parseable HTML children. This inconsistency creates a window where sanitized content becomes executable on re-parse.
DOMPurify ≤ 3.1.0 was vulnerable to node flattening (bypass by @IcesFont). DOMPurify ≤ 3.1.1 was vulnerable to __depth counter clobbering (Kevin Mizu). DOMPurify ≤ 3.1.2 contained two additional bypasses: the elevator mutation and the triple-parse chain. All were fixed in DOMPurify 3.4.0. Additionally, CVE-2024-47875 (CVSS 10.0) affected DOMPurify < 2.5.0 and 3.0.0–3.1.2 via deeply nested MathML/SVG.
1) Namespace switching: SVG/MathML parsing rules differ from HTML namespace. 2) HTML integration points: a math element containing annotation-xml with encoding 'text/html', and an svg element containing foreignObject, both switch back to HTML namespace inside foreign content. 3) Active formatting elements and scope markers: tables, captions, buttons foster-parent elements during re-parsing. 4) Depth limit enforcement: browsers flatten excess nesting after the sanitizer passes, creating a mutated DOM.
CVE-2024-37383 (Roundcube, SVG animate whitespace bypass, CISA KEV), CVE-2024-47875 (DOMPurify CVSS 10.0, nesting-based mXSS), CVE-2023-33726 (Joplin, htmlparser2 vs Electron renderer differential), CVE-2023-44390 (HtmlSanitizer .NET). Sonar Research documented mXSS in Google Search, Proton Mail, TYPO3, and osTicket.
The Magic 7 are a set of 7 polyglot XSS payloads synthesized via Monte Carlo Tree Search (MCTS) in the USENIX Security 2024 paper by Kirchner et al. ('Dancer in the Dark'). Each polyglot executes across multiple injection contexts. Applied to blind XSS on the Tranco Top 100,000, they discovered 20 vulnerabilities in 18 backend systems.
Differential rendering detection: submit a payload to the sanitizer, serialize the output, reassign it to innerHTML, and compare the resulting DOM to the expected DOM. If they differ, mXSS is possible. BreachVex uses an innerHTML → serialize → re-parse cycle gate: a finding is only marked CONFIRMED if the payload survives the full round-trip.
Use DOMPurify ≥ 3.4.0 (all 2024 bypasses patched). Never mutate or concatenate sanitized output. Combine with Trusted Types to prevent raw string assignment to innerHTML. Use the browser's native Sanitizer API (setHTML()) when available — it uses the same HTML parser as rendering, eliminating all parser differentials by design.