Stocker un mot de passe en PHP : pourquoi jamais en clair, découvrir password_hash()
Dans les séances de craquage, vous avez cassé des mots de passe en quelques secondes.
Voici le côté du développeur : comment les stocker pour qu'une fuite de la base ne livre rien d'exploitable ?
Petite vérification
Avant de commencer, vérifiez si vous savez encore réaliser ces 3 actions très simples, sur la séance précédente consacrée au scan de ports :
- donner l'état que nmap affiche pour un port dont les paquets sont jetés par un pare-feu avec
drop - citer l'option de nmap qui demande le nom et la version du service qui répond sur un port
- expliquer pourquoi arrêter un service inutile est préférable à le filtrer seulement
📌 Réponse attendue
TP pour réfléchir et résoudre des problèmes
Votre défi pour aujourd'hui consiste à mesurer ce que vaut une base d'utilisateurs en fuite selon la façon dont les mots de passe y sont stockés, puis à passer d'un stockage en clair à un stockage correct avec
password_hash()
Le site des adhérents du café associatif « Le Comptoir », ses utilisateurs et leurs mots de passe sont fictifs, construits pour l'exercice.
Le café associatif « Le Comptoir » a fait développer un petit site réservé à ses adhérents. Un bénévole a stocké les mots de passe tels que les utilisateurs les saisissent, « pour pouvoir les renvoyer à ceux qui les oublient ». Une fuite vient d'être signalée. Vous reprenez le site.
Étape 1 — Mesurer les dégâts d'une base en clair
Dans le terminal de votre VM Debian, installez PHP en ligne de commande et créez la « base » d'utilisateurs du site :
sudo apt install php-cli
mkdir ~/tp-motdepasse
cd ~/tp-motdepasse
cat > utilisateurs_clair.csv <<'EOF'
login;mot_de_passe
alice;azerty123
bruno;Soleil2024
chloe;azerty123
david;Jard1n!Partage#52
EOF
cat utilisateurs_clair.csv
Le fichier ci-dessus est ce que l'attaquant vient de télécharger.
- Combien de mots de passe l'attaquant peut-il utiliser immédiatement ?
- Quels comptes partagent le même mot de passe ?
- Le danger se limite-t-il au site du café ? Pourquoi ?
📌 Réponse attendue
Étape 2 — Le « correctif » rapide : MD5
Le bénévole propose de remplacer les mots de passe par leur empreinte MD5, vue lors de la séance de craquage. Écrivez le script de conversion et lancez-le :
cat > convertir_md5.php <<'EOF'
<?php
foreach (array_slice(file('utilisateurs_clair.csv', FILE_IGNORE_NEW_LINES), 1) as $ligne) {
[$login, $mdp] = explode(';', $ligne);
echo $login, ';', md5($mdp), "\n";
}
EOF
php convertir_md5.php
- Que remarquez-vous pour
aliceetchloe? - Que peut en déduire un attaquant qui lit ce fichier ?
- Ce stockage est-il acceptable ? Justifiez en deux arguments.
📌 Réponse attendue
Étape 3 — password_hash()
PHP fournit une fonction faite pour cet usage : elle ajoute un sel aléatoire et utilise un algorithme volontairement lent.
La fonction PHP password_hash()
password_hash() génère un sel aléatoire en interne à chaque appel, puis l’intègre au résultat.
Vous n’avez donc pas à créer ni à stocker le sel séparément.
Par exemple, 2 appels avec le même mot de passe produisent généralement des chaînes différentes :
$a = password_hash('secret', PASSWORD_DEFAULT);
$b = password_hash('secret', PASSWORD_DEFAULT);
C’est possible parce que la chaîne renvoyée contient non seulement le hachage, mais aussi les informations nécessaires pour le vérifier : l’algorithme, ses paramètres et le sel.
À la connexion, password_verify() lit ces informations dans la chaîne et refait le calcul avec le sel correspondant :
password_verify('secret', $a); // true
Le sel n’est pas un secret, mais le rôle de password_hash() est de :
- empêcher que 2 mots de passe identiques donnent le même hachage
- rendre ainsi les rainbow tables inutilisables.
Lancez la conversion et enregistrez le résultat :
cat > convertir_hash.php <<'EOF'
<?php
foreach (array_slice(file('utilisateurs_clair.csv', FILE_IGNORE_NEW_LINES), 1) as $ligne) {
[$login, $mdp] = explode(';', $ligne);
echo $login, ';', password_hash($mdp, PASSWORD_DEFAULT), "\n";
}
EOF
php convertir_hash.php | tee utilisateurs_hash.csv
aliceetchloeont-elles désormais la même empreinte ? Pourquoi ?- Que représentent les différentes parties de la chaîne
$2y$12$...? - Peut-on retrouver le mot de passe à partir de cette chaîne ?
📌 Réponse attendue
Étape 4 — Vérifier une connexion
On ne compare pas deux chaînes : on utilise password_verify(), qui lit le sel dans l'empreinte
stockée. Écrivez le script et testez-le :
cat > verifier.php <<'EOF'
<?php
[$script, $login, $saisie] = $argv;
foreach (file('utilisateurs_hash.csv', FILE_IGNORE_NEW_LINES) as $ligne) {
[$l, $hash] = explode(';', $ligne);
if ($l === $login) {
echo password_verify($saisie, $hash) ? "Connexion acceptée\n" : "Connexion refusée\n";
exit;
}
}
echo "Connexion refusée\n";
EOF
php verifier.php alice azerty123
php verifier.php alice azerty124
php verifier.php chloe azerty123
php verifier.php eve azerty123
- Quel résultat obtient-on pour chacune des quatre commandes ?
- Pourquoi ne peut-on pas simplement recalculer
password_hash($saisie)et comparer le résultat à la chaîne stockée ? - Pourquoi le script répond-il la même chose pour un mot de passe faux et pour un login inconnu ?
📌 Réponse attendue
Étape 5 — Mesurer le coût
Le coût règle le temps de calcul. Mesurez-le pour trois valeurs :
cat > mesurer.php <<'EOF'
<?php
foreach ([8, 10, 12] as $cout) {
$debut = hrtime(true);
password_hash('azerty123', PASSWORD_BCRYPT, ['cost' => $cout]);
printf("coût %d : %.0f ms\n", $cout, (hrtime(true) - $debut) / 1e6);
}
EOF
php mesurer.php
- Par combien le temps est-il multiplié chaque fois que le coût augmente de 2 ?
- Avec un coût de 10 et environ 50 ms par calcul, combien d'essais un attaquant peut-il faire par heure, sur un seul cœur de processeur ?
- Pourquoi ne pas choisir un coût très élevé, comme 20 ?
📌 Réponse attendue
Étape 6 — Migrer les anciens comptes sans les casser
Vous ne pouvez pas convertir une empreinte MD5 en empreinte bcrypt (le mot de passe d'origine n'est pas connu). Le principe est de vérifier avec l'ancienne méthode, puis de stocker la nouvelle, à la connexion de chaque utilisateur :
cat > connexion_migration.php <<'EOF'
<?php
// Ancienne base : empreintes MD5
$base = ['alice' => md5('azerty123')];
[$script, $login, $saisie] = $argv;
$stocke = $base[$login] ?? '';
if (str_starts_with($stocke, '$2y$')) {
$ok = password_verify($saisie, $stocke);
} else {
$ok = hash_equals($stocke, md5($saisie));
if ($ok) {
$base[$login] = password_hash($saisie, PASSWORD_DEFAULT);
echo "Empreinte MD5 remplacée par : ", substr($base[$login], 0, 7), "...\n";
}
}
echo $ok ? "Connexion acceptée\n" : "Connexion refusée\n";
EOF
php connexion_migration.php alice azerty123
php connexion_migration.php alice mauvais
- Que se passe-t-il lors de la première connexion réussie ?
- Pourquoi faut-il attendre la connexion pour faire cette migration ?
- Que faire des comptes qui ne se connectent jamais ?
📌 Réponse attendue
Étape 7 — Conseiller le café associatif
Le bénévole vous demande : « Maintenant que nos mots de passe sont stockés avec password_hash(), est-ce
que nous pouvons dormir tranquilles ? Et faut-il informer les adhérents de la fuite d'avant ? »
Que lui répondez-vous ?
Restitution orale
Un étudiant (ou un binôme) vient présenter à la classe la comparaison des trois façons de stocker un mot de passe (étapes 1 à 3) et les conseils donnés au café (étape 7).
- Durée : 5 minutes de présentation + 2 à 3 minutes de questions.
- Qui passe : un(e) étudiant(e) (ou un binôme) désigné(e) par l'enseignant.
- Support : le travail réalisé pendant le TP, aucune préparation supplémentaire n'est nécessaire.
- Ce qui est attendu :
- rappeler le contexte (le site du café et la fuite de sa base) ;
- présenter la démarche suivie (clair, MD5,
password_hash()) et les conseils donnés ; - répondre aux questions de la classe.
Cet exercice entraîne à l'oral technique court exigé par plusieurs épreuves du BTS (notamment E5 et E6) : clarté, structure, capacité à répondre à l'improviste.
📌 Une solution
Notions théoriques
Vous venez de comparer trois façons de stocker un mot de passe. Voyons pourquoi seule la dernière est
acceptable, et ce que fait réellement password_hash().
3 façons de stocker un mot de passe
| Stockage | Ce qu'un attaquant obtient après une fuite | Verdict |
|---|---|---|
| En clair | Tous les mots de passe, immédiatement | Inacceptable |
| Empreinte rapide (MD5, SHA-1, SHA-256) | Des empreintes qu'il casse très vite, avec des tables ou un dictionnaire | Insuffisant |
| Empreinte lente et salée (bcrypt, Argon2) | Des empreintes qu'il doit deviner une à une, chacune au prix d'un calcul coûteux | Acceptable |
Un site n'a jamais besoin de connaître un mot de passe : il lui suffit de vérifier qu'une saisie correspond à l'empreinte stockée. C'est pourquoi un site sérieux ne sait pas « renvoyer » un mot de passe oublié : il propose d'en choisir un nouveau.
Les ingrédients d'une bonne empreinte
- Une fonction à sens unique : on ne peut pas reconstituer le mot de passe à partir de l'empreinte.
- Un sel aléatoire, propre à chaque mot de passe : deux utilisateurs qui ont le même mot de passe ont des empreintes différentes, et aucune table précalculée n'est utilisable.
- Un coût réglable : l'algorithme est volontairement lent, ce qui multiplie le temps de chaque essai pour l'attaquant, et que l'on peut augmenter avec la puissance des machines.
$2y$12$aUARl4JpJey1cTirYgLTXu io0Pt/5k0b5R/ZQEhJvV8KhsVhWGJ06
| | |__________________| |__________________________________|
| | sel (22) empreinte (31)
| coût de calcul
algorithme (2y = bcrypt)
L'algorithme, le coût et le sel sont rangés dans la chaîne elle-même : c'est pourquoi
password_verify() n'a besoin que de cette chaîne et du mot de passe saisi.
Les 3 fonctions de PHP à connaître
| Fonction | Rôle | Remarque |
|---|---|---|
password_hash($mdp, PASSWORD_DEFAULT) | Crée l'empreinte salée | PASSWORD_DEFAULT désigne le meilleur algorithme du moment (bcrypt aujourd'hui) |
password_verify($saisie, $hash) | Vérifie une saisie | Compare en temps constant, lit le sel dans $hash |
password_needs_rehash($hash, PASSWORD_DEFAULT) | Indique si l'empreinte doit être refaite | À appeler après une connexion réussie, pour passer à un coût ou un algorithme plus récents |
Prévoyez une colonne de 255 caractères pour stocker l'empreinte : PASSWORD_DEFAULT peut changer
d'algorithme dans une version future de PHP, avec des empreintes plus longues que les 60 caractères
de bcrypt.
Ce que password_hash() ne règle pas
| Menace | Pourquoi le stockage ne suffit pas | Mesure complémentaire |
|---|---|---|
| Mot de passe faible | azerty123 est dans tous les dictionnaires | Règles de composition, vérification, longueur |
| Attaque en ligne (essais sur le formulaire) | Elle ne dépend pas du stockage | Limiter les tentatives, double authentification |
| Phishing, réutilisation | Le mot de passe est donné volontairement | Sensibilisation, double authentification |
| Fuite de la base | Les empreintes sont volées, même salées | Notification, réinitialisation des mots de passe |
Le cours « Stockage des mots de passe » du dossier de cryptographie de ce site reprend ces notions avec un formulaire d'inscription et de connexion complet, et présente aussi Argon2.
Test de mémorisation/compréhension
Bonnes pratiques
- Ne jamais stocker un mot de passe en clair, ni le renvoyer par e-mail : proposer d'en choisir un nouveau.
- Utiliser
password_hash()etpassword_verify(), jamais MD5 ni SHA brut, même salés à la main. - Prévoir une colonne de 255 caractères pour l'empreinte.
- Régler le coût pour que le calcul prenne quelques dizaines à quelques centaines de millisecondes sur le serveur.
- Migrer progressivement les anciennes empreintes à la connexion, avec
password_needs_rehash(). - Répondre de la même façon pour un login inconnu et un mot de passe faux.
- Ne pas s'arrêter au stockage : mots de passe solides, limitation des essais, double authentification.