Aller au contenu principal

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 :

  1. donner l'état que nmap affiche pour un port dont les paquets sont jetés par un pare-feu avec drop
  2. citer l'option de nmap qui demande le nom et la version du service qui répond sur un port
  3. 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()

info

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.

  1. Combien de mots de passe l'attaquant peut-il utiliser immédiatement ?
  2. Quels comptes partagent le même mot de passe ?
  3. 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
  1. Que remarquez-vous pour alice et chloe ?
  2. Que peut en déduire un attaquant qui lit ce fichier ?
  3. 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
  1. alice et chloe ont-elles désormais la même empreinte ? Pourquoi ?
  2. Que représentent les différentes parties de la chaîne $2y$12$... ?
  3. 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
  1. Quel résultat obtient-on pour chacune des quatre commandes ?
  2. Pourquoi ne peut-on pas simplement recalculer password_hash($saisie) et comparer le résultat à la chaîne stockée ?
  3. 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
  1. Par combien le temps est-il multiplié chaque fois que le coût augmente de 2 ?
  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 ?
  3. 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
  1. Que se passe-t-il lors de la première connexion réussie ?
  2. Pourquoi faut-il attendre la connexion pour faire cette migration ?
  3. 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.
astuce

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​

StockageCe qu'un attaquant obtient après une fuiteVerdict
En clairTous les mots de passe, immédiatementInacceptable
Empreinte rapide (MD5, SHA-1, SHA-256)Des empreintes qu'il casse très vite, avec des tables ou un dictionnaireInsuffisant
Empreinte lente et salée (bcrypt, Argon2)Des empreintes qu'il doit deviner une à une, chacune au prix d'un calcul coûteuxAcceptable

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​

FonctionRôleRemarque
password_hash($mdp, PASSWORD_DEFAULT)Crée l'empreinte saléePASSWORD_DEFAULT désigne le meilleur algorithme du moment (bcrypt aujourd'hui)
password_verify($saisie, $hash)Vérifie une saisieCompare 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
attention

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​

MenacePourquoi le stockage ne suffit pasMesure complémentaire
Mot de passe faibleazerty123 est dans tous les dictionnairesRègles de composition, vérification, longueur
Attaque en ligne (essais sur le formulaire)Elle ne dépend pas du stockageLimiter les tentatives, double authentification
Phishing, réutilisationLe mot de passe est donné volontairementSensibilisation, double authentification
Fuite de la baseLes empreintes sont volées, même saléesNotification, réinitialisation des mots de passe
Pour aller plus loin

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​


Pourquoi ne faut-il jamais stocker un mot de passe en clair dans une base de données ?


Deux utilisateurs ont le même mot de passe. Pourquoi password_hash() produit-il deux chaînes différentes ?


Que contient une chaîne du type $2y$12$... produite par password_hash() ?


Comment vérifie-t-on correctement une saisie lors de la connexion d'un utilisateur ?


Pourquoi la lenteur de bcrypt est-elle une qualité pour le stockage d'un mot de passe ?


Un site possède des empreintes MD5. Quand et comment les migrer vers password_hash() ?


Quelle limite de password_hash() faut-il garder à l'esprit ?


Quelle taille de colonne faut-il prévoir pour stocker le résultat de password_hash() ?



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() et password_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.