CSRF
- Comprendre comment une application vulnérable peut être attaquée via une falsification de requête (CSRF).
- Apprendre à s'en protéger avec un jeton CSRF et le flag SameSite des cookies.
Si une action sensible (changement d'email, de mot de passe, virement...) ne vérifie pas l'origine de la requête, un attaquant peut faire agir votre navigateur à votre place, sans que vous vous en rendiez compte (en profitant du fait que vous êtes déjà connecté).
1. Création du projet vulnérable
Créez un dossier tp-csrf contenant le fichier index.php : l'espace membre du club de
randonnée (le même site que celui du TP sur le XSS), où un membre déjà connecté peut changer son
adresse email.
<?php
session_start();
// Simule un membre du club déjà connecté sur ce navigateur
if (!isset($_SESSION['email'])) {
$_SESSION['email'] = 'membre@club-randonnee.fr';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['email'])) {
$_SESSION['email'] = $_POST['email'];
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Espace membre - Club de randonnée</title>
</head>
<body>
<h1>Espace membre du club de randonnée</h1>
<p>Email actuel : <strong><?= htmlspecialchars($_SESSION['email']) ?></strong></p>
<form method="post" action="">
<label for="email">Nouvel email :</label>
<input type="email" id="email" name="email" size="40" required>
<button type="submit">Mettre à jour mon email</button>
</form>
</body>
</html>
Ici, la session PHP simule un membre déjà connecté : dès qu'un navigateur ouvre cette page, il
reçoit un cookie de session, et ce cookie sera automatiquement renvoyé à chaque requête suivante
vers localhost:8000 — exactement comme sur un vrai site avec identifiant/mot de passe.
2. Démarrer le serveur Web
Dans le dossier tp-csrf, démarrez le serveur Web intégré à PHP :
php -S localhost:8000
Ouvrez ensuite http://localhost:8000/ et changez votre email une première fois pour vérifier que le formulaire fonctionne normalement.
3. Créer le site de l'attaquant
Nous allons maintenant jouer le rôle de l'attaquant, en créant un second site, entièrement sur notre machine, qui va piéger la victime pendant qu'elle est connectée au club de randonnée.
Créez un second dossier tp-csrf-attaquant (à côté de tp-csrf, pas dedans), contenant le fichier
piege.html :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Superbe concours photo rando 🏔️</title>
</head>
<body>
<h1>Gagnez un sac à dos de randonnée !</h1>
<p>Cette page ne fait rien de suspect... en apparence.</p>
<!-- Formulaire caché qui envoie une requête automatique -->
<form action="http://localhost:8000/" method="POST" id="csrfForm">
<input type="hidden" name="email" value="attaquant@pirate.fr">
</form>
<script>
document.getElementById('csrfForm').submit();
</script>
</body>
</html>
Dans un second terminal, démarrez ce site sur un port différent :
php -S localhost:9000 -t tp-csrf-attaquant
Vous avez maintenant deux serveurs qui tournent en même temps sur votre machine :
http://localhost:8000→ le site de la victime (le club de randonnée)http://localhost:9000→ le site de l'attaquant (la page piégée)
4. Déclencher l'attaque
- Ouvrez http://localhost:8000/ dans un onglet et vérifiez l'email affiché : vous êtes bien "connecté" (session active).
- Sans fermer cet onglet, ouvrez http://localhost:9000/piege.html dans un autre onglet.
- Revenez sur http://localhost:8000/ et rechargez la page.
L'email a changé pour attaquant@pirate.fr, sans que vous ayez rien saisi sur ce site !
Question : Pourquoi le site localhost:8000 a-t-il accepté la requête envoyée depuis
localhost:9000, alors que ce sont deux sites différents ?
📌 Une réponse
C'est une attaque CSRF (Cross-Site Request Forgery) : l'attaquant ne vole aucune donnée et n'a pas besoin de connaître le mot de passe de la victime. Il se contente de faire exécuter une action par le navigateur de la victime, à son insu, en exploitant sa session déjà active.
5. Sécuriser le code PHP avec un jeton CSRF
Modifiez tp-csrf/index.php pour générer un jeton unique par session et le vérifier avant
toute modification :
<?php
session_start();
if (!isset($_SESSION['email'])) {
$_SESSION['email'] = 'membre@club-randonnee.fr';
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$erreur = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
$erreur = 'Jeton CSRF invalide : requête refusée.';
} else {
$_SESSION['email'] = $_POST['email'];
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Espace membre - Club de randonnée</title>
</head>
<body>
<h1>Espace membre du club de randonnée</h1>
<?php if ($erreur): ?>
<p style="color:red;"><?= htmlspecialchars($erreur) ?></p>
<?php endif; ?>
<p>Email actuel : <strong><?= htmlspecialchars($_SESSION['email']) ?></strong></p>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<label for="email">Nouvel email :</label>
<input type="email" id="email" name="email" size="40" required>
<button type="submit">Mettre à jour mon email</button>
</form>
</body>
</html>
hash_equals() compare deux chaînes en temps constant, ce qui évite qu'un attaquant devine le
jeton petit à petit en mesurant le temps de réponse du serveur (attaque par canal auxiliaire). Une
simple comparaison === fonctionnerait aussi pour ce TP, mais hash_equals() est la bonne pratique
à retenir pour comparer des jetons secrets.
La function PHP hash_equals()
La fonction hash_equals() effectue une comparaison de chaînes en temps constant (timing-safe comparison).
Elle est conçue pour éviter les attaques temporelles (timing attacks).
Le problème qu'elle résout
La comparaison classique avec === s'arrête dès qu'elle rencontre un caractère différent :
if ($tokenUser === $tokenValide) { ... } // ❌ vulnérable
Le temps de comparaison dépend donc du nombre de caractères identiques au début. Un attaquant peut mesurer ces temps de réponse pour deviner progressivement un token, une signature HMAC ou un mot de passe, caractère par caractère.
Points importants
- Disponible depuis PHP 5.6
- Retourne
falsesi les longueurs diffèrent (ce qui peut théoriquement révéler la longueur, mais c'est rarement critique) - Les deux arguments doivent être des chaînes (sinon
TypeErroren PHP 8+) - Ne pas l'utiliser pour les mots de passe : préférez
password_verify(), qui gère déjà une comparaison sécurisée en interne
Bonne pratique
Utilisez hash_equals() chaque fois que vous comparez une donnée secrète ou sensible fournie par l'utilisateur (token, signature, hash) avec une valeur attendue.
6. Vérifier que l'attaque échoue désormais
Videz la session (fermez puis rouvrez le navigateur, ou ouvrez une navigation privée) pour repartir d'une session propre, puis rejouez le scénario de l'étape 4 :
- Ouvrez http://localhost:8000/ et vérifiez l'email affiché.
- Ouvrez http://localhost:9000/piege.html dans un autre onglet.
- Revenez sur http://localhost:8000/ et rechargez la page.
Résultat attendu : l'email n'a pas changé. Le formulaire piégé de piege.html envoie
toujours email=attaquant@pirate.fr, mais il ne connaît pas csrf_token : la vérification
hash_equals() échoue et la modification est refusée.
Question : Pourquoi l'attaquant ne peut-il pas simplement lire le jeton CSRF affiché sur
localhost:8000 pour l'ajouter à son formulaire piégé ?
📌 Une réponse
Le jeton CSRF doit être imprévisible (généré avec random_bytes(), jamais un simple compteur),
stocké côté serveur (en session) et vérifié à chaque requête sensible. Un jeton visible
dans le HTML n'est pas un problème : ce que l'attaquant ne peut pas faire, c'est le lire depuis un
autre site pour le réutiliser.
Pour aller plus loin : le nom du jeton a-t-il de l'importance ?
Dans tp-csrf/index.php, renommez la clé de session csrf_token en token_form, puis
rejouez l'attaque de l'étape 4 (session propre, deux onglets, rechargement).
- Changez d'abord le nom à un seul endroit (par exemple uniquement là où le jeton est généré),
puis rechargez
http://localhost:8000/: que se passe-t-il ? - Corrigez ensuite tous les endroits qui utilisent cette clé de session, puis rejouez l'attaque.
- Renommez enfin le champ du formulaire (
name="csrf_token") enjeton, sans oublier de mettre à jour$_POST[...]. Le formulaire légitime fonctionne-t-il encore ?
Question : Un attaquant qui connaîtrait le nom de la clé de session pourrait-il contourner la protection ? Pourquoi le nom du jeton n'est-il pas ce qui protège le site ?
📌 Une réponse
Choisissez un nom de clé explicite et utilisez-le partout (idéalement via une constante ou une
fonction verifierCsrf() : un seul endroit à modifier). La sécurité repose sur l'imprévisibilité
de la valeur, jamais sur l'obscurité du nom.
7. Ajouter une couche de défense supplémentaire : cookies SameSite
Même avec un jeton CSRF, il est recommandé de configurer les cookies de session avec l'attribut
SameSite, qui indique au navigateur de ne pas envoyer le cookie lors d'une requête
déclenchée depuis un autre site.
Ajoutez ceci tout en haut de index.php, avant session_start() :
<?php
session_set_cookie_params(['samesite' => 'Strict']);
session_start();
Redémarrez le serveur PHP (Ctrl+C puis relancez php -S localhost:8000), effacez les cookies du
navigateur pour localhost:8000, puis reproduisez l'attaque de l'étape 4.
Résultat attendu : le cookie de session n'est même plus envoyé lors de la requête forgée
depuis localhost:9000 — la session est traitée comme "non connectée" pour cette requête.
SameSite=Strict est très efficace mais peut aussi bloquer des cas légitimes (ex : un lien reçu
par email qui ouvre une page nécessitant la session). SameSite=Lax, valeur par défaut de la
plupart des navigateurs récents, autorise la navigation simple (GET) entre sites tout en bloquant
les requêtes POST intersites comme celle de ce TP. Le
jeton CSRF et SameSite sont 2 protections complémentaires, ni l'une ni l'autre à elle
seule n'est infaillible.
Notions théoriques
Le Cross-Site Request Forgery (CSRF), parfois prononcé "sea-surf", est une faille de sécurité qui permet à un attaquant de faire exécuter une action par le navigateur d'une victime déjà connectée à un site, sans son consentement.
Contrairement au XSS, qui injecte du code JavaScript malveillant dans une page, le CSRF n'injecte rien : il se contente de fabriquer une requête (formulaire, image, lien) qui profite de la session déjà ouverte de la victime.
Comment fonctionne une attaque CSRF ?
- La victime se connecte à un site légitime (elle obtient un cookie de session).
- Sans se déconnecter, elle visite une page piégée (email, réseau social, site tiers).
- La page piégée déclenche une requête vers le site légitime (formulaire caché auto-soumis, image, lien).
- Le navigateur ajoute automatiquement le cookie de session de la victime à cette requête.
- Le site légitime exécute l'action en pensant qu'elle vient bien de la victime.
Pourquoi les cookies sont-ils au cœur du problème ?
Un navigateur envoie automatiquement tous les cookies connus d'un site à chaque requête vers ce site, peu importe la page qui a déclenché la requête. C'est ce comportement, normal et utile la plupart du temps, qu'exploite une attaque CSRF : la victime n'a besoin de rien saisir, ni même de remarquer quoi que ce soit.
Comment se protéger contre le CSRF ?
- Jeton CSRF (CSRF Token) : un jeton unique et imprévisible, généré côté serveur pour la session, inclus dans chaque formulaire sensible et vérifié à la réception.
- Cookies
SameSite:StrictouLaxempêchent le navigateur d'envoyer le cookie de session lors d'une requête déclenchée depuis un autre site. - Réserver les actions sensibles au
POST: une action comme "supprimer mon compte" ne devrait jamais pouvoir être déclenchée par un simple lien (GET), plus facile à piéger dans un email ou une image.
Un formulaire protégé par un jeton CSRF mais sans échappement des sorties (voir la séance sur le XSS) reste vulnérable : un attaquant qui parvient à injecter du JavaScript sur le site peut lire le jeton CSRF directement dans la page (il n'est plus sur un autre site, donc la Same-Origin Policy ne le protège plus) et l'utiliser lui-même. CSRF et XSS se protègent tous les deux, l'un ne remplace jamais l'autre.
Test de mémorisation/compréhension
Résumé

Bonnes pratiques
- Générer un jeton CSRF imprévisible avec
random_bytes(), unique par session. - Vérifier ce jeton avec
hash_equals()avant toute action sensible enPOST. - Configurer les cookies de session avec
SameSite=StrictouSameSite=Lax. - Ne jamais déclencher d'action sensible via une requête
GET.
Les frameworks modernes (Symfony, Laravel...) génèrent et vérifient les jetons CSRF automatiquement pour tous les formulaires. Ne désactivez jamais cette protection par défaut sans en comprendre précisément les conséquences.
Ce que fait Symfony
Le composant Form de Symfony ajoute automatiquement un champ caché _token à chaque formulaire
et vérifie ce jeton à la réception : une requête forgée, sans jeton valide, est refusée sans
une ligne de code de votre part. Le cookie de session est aussi envoyé avec SameSite=Lax par
défaut.
Pour un formulaire écrit à la main (par exemple un bouton « Supprimer »), Twig et le contrôleur fournissent les deux outils nécessaires :
<input type="hidden" name="_token" value="{{ csrf_token('supprimer-article') }}">
if (!$this->isCsrfTokenValid('supprimer-article', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}