Le HTML sanitisé est re-muté en XSS exécutable par le parseur HTML du navigateur, contournant DOMPurify et les sanitizers similaires.
TL;DR
animate utilisé dans des attaques sponsorisées par des ÉtatsLe XSS par mutation (mXSS) exploite une propriété fondamentale du parsing HTML : le même balisage peut signifier des choses différentes selon le contexte de namespace dans lequel il est parsé. Un sanitizer examine une entrée dans un contexte et la juge sûre. Le navigateur re-parse la sortie sanitisée dans un contexte différent — lors de l'assignation innerHTML — et produit un DOM différent contenant du JavaScript exécutable. Le sanitizer et le navigateur ne sont pas d'accord.
Le mXSS est distinct de toutes les autres variantes XSS car la vulnérabilité ne réside pas dans l'échec de l'application à sanitiser. Le sanitizer a fonctionné correctement. Le moteur de parsing HTML du navigateur, opérant selon ses propres règles tenant compte des namespaces, défait le travail du sanitizer. Il s'agit d'une attaque par différentiel de parseur, et elle contourne toute approche par correspondance de chaînes ou inspection DOM qui ne tient pas compte du comportement de re-parse.
Documenté de manière exhaustive pour la première fois dans Google Search et Proton Mail par Sonar Research, le mXSS affecte toute application qui : (1) accepte du HTML utilisateur, (2) le sanitise avec une bibliothèque côté client comme DOMPurify, et (3) assigne le résultat sanitisé à innerHTML. Le schéma element.innerHTML = DOMPurify.sanitize(html) est "prone to mutation XSS-es by design" (Kevin Mizu, mizu.re, 2024).
HTML, SVG et MathML ont des règles de parsing distinctes. Le différentiel le plus exploité :
<style> en namespace HTML : le contenu est RCDATA (texte uniquement — CSS, pas HTML)<style> en namespace SVG : le contenu est parsé comme du HTML standard (peut contenir des éléments enfants)Un sanitizer opérant en namespace HTML voit <style> comme du texte sûr. Le navigateur, lors du re-parse dans un contexte SVG, traite le contenu de <style> comme du HTML, créant des éléments enfants exécutables.
<!-- DOMPurify (pré-3.4.0) laisse passer ceci comme sûr en namespace HTML -->
<svg><style><img src=x onerror=alert(1)></style></svg>
<!-- Quand assigné à innerHTML dans un contexte où SVG est parsé :
le navigateur voit <img onerror=alert(1)> comme un élément HTML réel --><math><annotation-xml encoding="text/html"> et <svg><foreignObject> sont des points d'intégration — des emplacements où le parseur repasse explicitement en namespace HTML dans du contenu étranger. Les sanitizers qui opèrent uniquement en namespace HTML ratent le fait que le contenu à l'intérieur de ces éléments sera re-parsé en HTML lors de l'assignation innerHTML.
<math>
<annotation-xml encoding="text/html">
<!-- Ce contenu est en namespace HTML — mais le sanitizer peut le manquer -->
<img src=x onerror=alert(document.domain)>
</annotation-xml>
</math>Les tables, légendes, éléments <button> et <select> créent des frontières de portée lors de la construction de l'arbre HTML5. Les éléments qui ne peuvent pas apparaître dans une table sont "foster-parentés" — déplacés avant la table dans le DOM. Ce déplacement se produit après le passage d'inspection du sanitizer, déplaçant des éléments dans des contextes exécutables.
Les navigateurs appliquent une profondeur d'imbrication maximale (~512 éléments) et aplatissent l'imbrication excessive après le parsing. DOMPurify avait implémenté son propre compteur __depth pour rejeter le contenu profondément imbriqué — mais ce compteur lui-même pouvait être clobbered par le DOM :
<!-- Clobber du compteur __depth de DOMPurify ≤ 3.1.1 (Kevin Mizu) -->
<form id="x "><input form="x" name="__depth">L'élément <input> avec name="__depth" crée une propriété DOM formElement.__depth qui remplace le compteur de profondeur de DOMPurify, le réinitialisant en cours de sanitization et permettant au contenu de bypass mXSS profondément imbriqué de passer.
Les recherches de Kevin Mizu sur mizu.re (publiées en novembre 2024) ont documenté quatre bypasses séquentiels, chacun corrigeant le précédent et introduisant le suivant :
| Bypass | Version affectée | Technique | Chercheur |
|---|---|---|---|
| Aplatissement de nœud | ≤ 3.1.0 | Limite de profondeur 512 + réordonnancement <svg><caption> crée une mutation DOM post-sanitization | @IcesFont |
Clobbering __depth | ≤ 3.1.1 | <form id="x "><input form="x" name="__depth"> réinitialise le compteur de profondeur | Kevin Mizu |
| Mutation élévateur | ≤ 3.1.2 | <image> (balise SVG) se convertit en <img> (balise HTML) lors du parcours de namespace | Kevin Mizu |
| Chaîne triple-parse | ≤ 3.1.2 | La mutation <form>/<table> survit aux schémas de double-sanitization (Mermaid.js) | Kevin Mizu |
Chaque bypass neutralisait complètement la sanitization — un payload passant par la version DOMPurify vulnérable s'exécutait garantiment lors de l'assignation innerHTML.
CVE-2024-47875 (CVSS 10.0 CRITIQUE) : DOMPurify < 2.5.0 et 3.0.0–3.1.2, mXSS basé sur l'imbrication via MathML/SVG/foreignObject profondément imbriqués. Corrigé dans 2.5.0 / 3.4.0.
// Détection de version DOMPurify dans la console du navigateur
window.DOMPurify && DOMPurify.version
// Ou chercher dans les bundles JS :
// /DOMPurify\.VERSION\s*=\s*['"]([\d.]+)['"]/Toute application utilisant DOMPurify ≤ 3.1.2 est vulnérable à au moins une de ces chaînes de bypass. DOMPurify 3.4.0 a corrigé les quatre via une détection basée sur des regex bloquant les schémas <!-- et --> au niveau des attributs. Mettre à jour immédiatement.
CVSS 6.1 MOYEN (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N). Affectait Roundcube < 1.5.7 et < 1.6.7.
Le sanitizer HTML rcube_washer de Roundcube échouait à retirer les espaces des valeurs d'attributs SVG <animate>. La vérification attributeName comparait les noms d'attributs après découpage de la chaîne — mais " onbegin" (avec un espace initial) ne correspondait pas à la liste noire "onbegin" (sans espace).
<!-- Payload — espace initial dans attributeName contourne la vérification de liste noire -->
<svg><animate attributeName=" onbegin" values="alert(1)" dur="1s" repeatCount="indefinite"/>La vulnérabilité a été activement exploitée par des acteurs sponsorisés par des États ciblant des organisations gouvernementales dans les pays de la CEI. La CISA l'a ajoutée au catalogue Known Exploited Vulnerabilities le 24 octobre 2024. C'est une étude de cas sur l'échec des approches par liste noire : un seul espace a contourné l'ensemble du sanitizer.
Joplin, une application de prise de notes multi-plateforme basée sur Electron, utilisait htmlparser2 pour la sanitization avant le rendu dans Chromium. La bibliothèque htmlparser2 parsait le contenu SVG <style> comme du texte (règles du namespace HTML), puis le renderer Chromium d'Electron re-parsait la sortie en namespace SVG, traitant le contenu de <style> comme du HTML. Le différentiel de parseur convertissait une balise <style> dans un SVG en HTML exécutable.
C'est l'exemple canonique de mXSS par différentiel de parseur : deux parseurs différents — un pour la sanitization, un pour le rendu — opérant selon des règles de namespace différentes sur le même contenu.
Sonar Research a documenté des vulnérabilités mXSS dans Google Search et Proton Mail causées par la gestion de la confusion de namespace SVG dans DOMPurify 2.0.0. Les deux plateformes utilisaient DOMPurify dans leurs pipelines de rendu de texte riche et étaient vulnérables jusqu'à ce que Google et Proton déploient des versions corrigées. Les travaux antérieurs de Masato Kinugawa sur le mXSS de Google Search ont établi la théorie fondamentale de la confusion de namespace qui a ensuite informé les recherches systématiques sur les bypasses DOMPurify.
L'article "Dancer in the Dark: Synthesizing and Evaluating Polyglots for Blind Cross-Site Scripting" (Kirchner et al., USENIX Security Symposium, août 2024) a introduit une approche systématique pour synthétiser des polyglottes XSS via la recherche arborescente Monte Carlo (MCTS).
Un payload XSS polyglotte s'exécute dans plusieurs contextes d'injection sans modification — la même chaîne fonctionne dans le corps HTML, l'attribut HTML (guillemet simple/double/sans guillemet), la chaîne JavaScript, le contexte URL et CSS. C'est crucial pour les scénarios de XSS à l'aveugle et de mXSS où le contexte d'exécution est inconnu de l'attaquant.
Les 7 payloads Magic ont été synthétisés pour atteindre une couverture complète des contextes dans tous les principaux contextes d'injection :
Résultat de validation : Appliqué au Tranco Top 100 000 pour la chasse au XSS à l'aveugle, l'ensemble Magic 7 a découvert 20 vulnérabilités dans 18 systèmes backend web — un taux de détection comparable aux approches complètes de suivi de contamination.
Polyglotte classique (démontre la technique d'échappement multi-contexte, pas l'ensemble USENIX exact) :
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//
</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\\x3eBreachVex utilise un ensemble soigné de payloads polyglottes pour la couverture mXSS et XSS à l'aveugle — en injectant chacun sur les surfaces à contexte inconnu, de sorte qu'une seule sonde exerce plusieurs contextes de parsing à la fois.
La méthode de détection mXSS définitive : soumettre un payload, le sanitiser, sérialiser le DOM sanitisé, réassigner à innerHTML, et comparer le DOM résultant à ce que le sanitizer a produit :
// Test de rendu différentiel — exécuter dans la console du navigateur
function testMXSS(payload) {
// Étape 1 : Sanitiser
const sanitized = DOMPurify.sanitize(payload);
// Étape 2 : Assigner au DOM
const div1 = document.createElement('div');
div1.innerHTML = sanitized;
const serialized1 = div1.innerHTML;
// Étape 3 : Réassigner (simule le vecteur d'attaque de re-parse)
const div2 = document.createElement('div');
div2.innerHTML = serialized1;
const serialized2 = div2.innerHTML;
// Étape 4 : Comparer — mutation = mXSS potentiel
if (serialized1 !== serialized2) {
console.warn('Différentiel mXSS détecté !', { serialized1, serialized2 });
return true;
}
return false;
}
// Tester avec des payloads mXSS connus
testMXSS('<svg><style><img src=x onerror=alert(1)></style></svg>');innerHTML → sérialisation → re-parse, ne marquant un finding CONFIRMÉ que si le payload survit au round-trip complethtmlparser2, sanitize-html, DOMPurify et HtmlSanitizer.NETinnerHTMLBreachVex confirme le mXSS uniquement quand un payload survit à un cycle complet innerHTML → sérialisation → re-parse, évitant les faux positifs des payloads que les sanitizers bloquent correctement.
import DOMPurify from 'dompurify';
// Vérifier la version avant utilisation
console.assert(
DOMPurify.version >= '3.4.0',
'Version DOMPurify trop ancienne — vulnérabilités mXSS présentes'
);
// Sanitiser avec une configuration restrictive
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, // s'associe avec la politique Trusted Types
});La chaîne triple-parse exploite les applications qui traitent la sortie sanitisée une deuxième fois. Une fois que DOMPurify produit du HTML sanitisé, le traiter comme immuable :
// SÛR : sanitiser une fois, assigner une fois
element.innerHTML = DOMPurify.sanitize(input);
// VULNÉRABLE au mXSS : sanitiser puis concaténer (re-parse lors de l'assignation)
element.innerHTML = DOMPurify.sanitize(input) + userProvidedSuffix;
// VULNÉRABLE : sanitiser avec une bibliothèque qui re-parse (schéma triple-parse Mermaid.js)
const sanitized = DOMPurify.sanitize(input);
mermaid.render('graph', sanitized); // Mermaid re-parse en interneLes Trusted Types imposent que innerHTML ne reçoive que des valeurs explicitement approuvées par une politique. Combinées avec DOMPurify, cela ajoute un second verrou :
// Politique Trusted Types appliquant la sanitization DOMPurify
if (window.trustedTypes) {
const policy = trustedTypes.createPolicy('default', {
createHTML: (input) => {
const sanitized = DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false });
// Supplémentaire : rejeter si sanitized !== re-sérialisé (vérification différentielle)
const div = document.createElement('div');
div.innerHTML = sanitized;
if (div.innerHTML !== sanitized) {
throw new TypeError('Différentiel mXSS détecté — entrée rejetée');
}
return sanitized;
},
});
}L'API Sanitizer W3C utilise le même parseur HTML que le moteur de rendu du navigateur. Cela élimine tous les différentiels de parseur — sanitizer et renderer s'accordent par construction :
// API Sanitizer native — aucun différentiel de parseur possible
const sanitizer = new Sanitizer({
allowElements: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowAttributes: { 'a': ['href'] },
});
element.setHTML(untrustedHTML, { sanitizer });
// setHTML() est l'équivalent sûr de innerHTML — intégré au navigateurL'API Sanitizer native et setHTML() sont la solution à long terme au mXSS. En utilisant le propre parseur du navigateur pour la sanitization, la surface d'attaque par différentiel de parseur est éliminée. En 2026, Firefox 148+ la fournit ; l'implémentation Chrome et Safari est en attente. Utiliser DOMPurify 3.4.0+ comme fallback pour la compatibilité cross-navigateur.
Le mXSS exploite la non-idempotence du parsing HTML. Un sanitizer juge une entrée sûre après un seul passage de parsing, mais quand la sortie sanitisée est assignée à innerHTML, le navigateur la re-parse dans un contexte de namespace différent (SVG, MathML), produisant un DOM différent contenant du JavaScript exécutable. Le sanitizer et le navigateur ne sont pas d'accord sur ce que signifie le balisage.
Les sanitizers HTML comme DOMPurify parsent l'entrée une fois pour identifier les éléments dangereux. Le navigateur re-parse la sortie sanitisée lors de l'assignation innerHTML — et les règles de parsing diffèrent selon le namespace. Un élément <style> en namespace HTML est du texte brut (CSS), mais en namespace SVG il contient des enfants HTML parsables. Cette incohérence crée une fenêtre où le contenu sanitisé devient exécutable lors du re-parse.
DOMPurify ≤ 3.1.0 était vulnérable à l'aplatissement de nœud (bypass par @IcesFont). DOMPurify ≤ 3.1.1 était vulnérable au clobbering du compteur __depth (Kevin Mizu). DOMPurify ≤ 3.1.2 contenait deux bypasses supplémentaires : la mutation élévateur et la chaîne triple-parse. Tous ont été corrigés dans DOMPurify 3.4.0. De plus, CVE-2024-47875 (CVSS 10.0) affectait DOMPurify < 2.5.0 et 3.0.0–3.1.2 via des MathML/SVG profondément imbriqués.
1) Commutation de namespace : les règles de parsing SVG/MathML diffèrent du namespace HTML. 2) Points d'intégration HTML : un élément math contenant annotation-xml avec encoding 'text/html', et un élément svg contenant foreignObject, repassent tous deux en namespace HTML dans le contenu étranger. 3) Éléments de formatage actifs et marqueurs de portée : tables, légendes, boutons foster-parentent des éléments lors du re-parse. 4) Application de la limite de profondeur : les navigateurs aplatissent l'imbrication excessive après le passage du sanitizer, créant un DOM muté.
CVE-2024-37383 (Roundcube, bypass espace SVG animate, CISA KEV), CVE-2024-47875 (DOMPurify CVSS 10.0, mXSS basé sur l'imbrication), CVE-2023-33726 (Joplin, différentiel htmlparser2 vs renderer Electron), CVE-2023-44390 (HtmlSanitizer .NET). Sonar Research a documenté le mXSS dans Google Search, Proton Mail, TYPO3 et osTicket.
Les Magic 7 sont un ensemble de 7 payloads polyglottes XSS synthétisés via la recherche arborescente Monte Carlo (MCTS) dans l'article USENIX Security 2024 de Kirchner et al. ('Dancer in the Dark'). Chaque polyglotte s'exécute dans plusieurs contextes d'injection. Appliqués au XSS à l'aveugle sur le Tranco Top 100 000, ils ont découvert 20 vulnérabilités dans 18 systèmes backend.
Détection par rendu différentiel : soumettre un payload au sanitizer, sérialiser la sortie, réassigner à innerHTML, et comparer le DOM résultant à celui produit par le sanitizer. S'ils diffèrent, le mXSS est possible. BreachVex utilise un cycle innerHTML → sérialisation → re-parse : un finding n'est marqué CONFIRMÉ que si le payload survit au round-trip complet.
Utiliser DOMPurify ≥ 3.4.0 (tous les bypasses 2024 corrigés). Ne jamais muter ou concaténer la sortie sanitisée. Combiner avec les Trusted Types pour empêcher l'assignation de chaînes brutes à innerHTML. Utiliser l'API Sanitizer native du navigateur (setHTML()) quand disponible — elle utilise le même parseur HTML que le rendu, éliminant tous les différentiels de parseur par conception.