Aller au contenu principal

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.
attention

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>
info

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​

  1. Ouvrez http://localhost:8000/ dans un onglet et vérifiez l'email affiché : vous êtes bien "connecté" (session active).
  2. Sans fermer cet onglet, ouvrez http://localhost:9000/piege.html dans un autre onglet.
  3. 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

attention

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>
info

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 false si 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 TypeError en 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 :

  1. Ouvrez http://localhost:8000/ et vérifiez l'email affiché.
  2. Ouvrez http://localhost:9000/piege.html dans un autre onglet.
  3. 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

Bonne pratique - Un jeton par session, vérifié côté serveur

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).

  1. 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 ?
  2. Corrigez ensuite tous les endroits qui utilisent cette clé de session, puis rejouez l'attaque.
  3. Renommez enfin le champ du formulaire (name="csrf_token") en jeton, 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

Bonne pratique - Un nom cohérent, une valeur secrète

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.

Bonne pratique - Défense en profondeur

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.

remarque

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 ?​

  1. La victime se connecte à un site légitime (elle obtient un cookie de session).
  2. Sans se déconnecter, elle visite une page piégée (email, réseau social, site tiers).
  3. La page piégée déclenche une requête vers le site légitime (formulaire caché auto-soumis, image, lien).
  4. Le navigateur ajoute automatiquement le cookie de session de la victime à cette requête.
  5. 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 : Strict ou Lax empê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.
attention

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​


Quel est le but principal d'une attaque CSRF ?


Pourquoi les cookies de session rendent-ils le CSRF possible ?


Dans le TP, que contient le fichier piege.html hébergé sur localhost:9000 ?


Pourquoi l'attaque du TP fonctionne-t-elle sans que l'attaquant connaisse le mot de passe de la victime ?


Quelle est la principale différence entre CSRF et XSS ?


À quoi sert un jeton CSRF ?


Pourquoi utiliser hash_equals() plutôt que === pour comparer le jeton CSRF ?


Pourquoi l'attaquant ne peut-il pas lire le jeton CSRF affiché sur le site de la victime ?


Que fait l'attribut SameSite=Strict sur un cookie ?


Pourquoi préférer SameSite=Lax à SameSite=Strict dans certains cas ?


Pourquoi une action sensible ne devrait-elle jamais être déclenchable par une simple requête GET ?


Un site protégé par un jeton CSRF mais vulnérable au XSS est-il réellement en sécurité ?


Quelle est la meilleure stratégie de protection contre le CSRF ?



Résumé​

Schéma


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 en POST.
  • Configurer les cookies de session avec SameSite=Strict ou SameSite=Lax.
  • Ne jamais déclencher d'action sensible via une requête GET.
attention

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();
}