bcrypt ignore tout après 72 octets : auditez vos password_hash()
Avec bcrypt, password_hash() ignore tout au-delà de 72 octets. Un poivre ou un nonce concaténé suffit à rendre le mot de passe inutile. L’audit prend une heure.
Prenez un poivre de 64 caractères hexadécimaux, collez-le devant le mot de passe, passez le tout à password_hash() avec bcrypt : seuls les 8 premiers caractères du mot de passe comptent encore. Azerty12 ouvre le compte dont le mot de passe est Azerty12-et-la-suite, et password_verify() répond true sans un avertissement. Ce n’est pas un bug de PHP, c’est le comportement documenté de bcrypt, qui ignore tout ce qui dépasse 72 octets. Fin septembre, une RFC a proposé que PHP cesse de tronquer en silence ; une seconde a proposé un nouvel algorithme pour contourner la limite. Aucune n’est votée, et ce n’est pas le sujet : le risque se trouve rarement chez l’utilisateur qui tape 73 caractères, presque toujours dans le code qui ajoute quelque chose avant le mot de passe, et il se débusque en une heure sur n’importe quelle base PHP qui a quelques années.
72 octets, et pas 72 caractères
La documentation de password_hash() le dit dans un encadré : avec PASSWORD_BCRYPT, le mot de passe est tronqué à 72 octets. PASSWORD_DEFAULT vaut toujours bcrypt (2y) aujourd’hui, donc la plupart des applications sont concernées sans avoir rien choisi. La troncature est silencieuse dans les deux sens : password_hash() ne lève rien, et password_verify() compare uniquement les 72 premiers octets de ce qu’on lui donne.
Le mot important est « octets ». En UTF-8, un é en occupe deux, la plupart des caractères chinois ou japonais trois. Une phrase de passe de 36 lettres accentuées atteint déjà la limite. Sur PHP 8.3, ce test renvoie int(72), int(36) puis bool(true) :
$p = str_repeat('é', 36);
var_dump(strlen($p), mb_strlen($p));
$h = password_hash($p . 'x', PASSWORD_BCRYPT);
var_dump(password_verify($p . 'y', $h)); // true : le 73e octet n'existe pas pour bcrypt
Pour un humain, la conséquence reste modeste : 72 octets de phrase de passe valent bien plus qu’un mot de passe moyen. C’est le code qui transforme cette limite en faille.
Le danger réel : ce que le code colle devant le mot de passe
Trois motifs reviennent dans les applications PHP d’avant password_hash(), ou écrites juste après par des équipes qui voulaient « renforcer » le hachage. Le poivre concaténé (PEPPER . $password), l’identifiant ou le login préfixé ($id . ':' . $password), et la construction en deux étages, où l’on hache un hash. Plus ce qui précède le mot de passe est long, moins il en reste dans la fenêtre de 72 octets.
FreshRSS en a fait l’expérience, et la RFC cite son cas (CVE-2025-68402). Son authentification par défi faisait vérifier au serveur password_verify($nonce . $hash, $challenge), où $hash est le bcrypt du mot de passe. Tant que le nonce était un SHA-1 hexadécimal de 40 caractères, les 72 octets couvraient 32 caractères du hash, dont une partie dépend du mot de passe. Une modification censée renforcer la cryptographie l’a passé en SHA-256, soit 64 caractères : il ne restait plus que les 8 premiers caractères du hash, le préfixe $2y$10$ et un caractère du sel, qui ne dépendent pas du mot de passe. N’importe quel mot de passe passait. Le défaut n’a touché que la branche de développement, pendant environ deux mois, et le correctif a simplement inversé l’ordre de la concaténation.
J’ai reproduit la construction en quelques lignes, sur PHP 8.3 :
$stored = password_hash('le-bon-mot-de-passe', PASSWORD_BCRYPT);
$salt1 = substr($stored, 0, 29); // le sel du compte, envoyé au client
$nonce = hash('sha256', random_bytes(16)); // 64 caractères
$client = fn (string $pwd) => password_hash($nonce . crypt($pwd, $salt1), PASSWORD_BCRYPT);
var_dump(password_verify($nonce . $stored, $client('le-bon-mot-de-passe'))); // true
var_dump(password_verify($nonce . $stored, $client('nimporte-quoi'))); // true
Ce qui rend ce genre de défaut redoutable, c’est qu’aucun test fonctionnel ne le voit : le bon mot de passe fonctionne, la recette passe. Il faut écrire le test qui vérifie qu’un mauvais mot de passe est refusé, et presque personne ne l’écrit.
Le piège inverse : passer au hasher de Symfony
Le cas que je trouve le plus sournois sur une migration vers Symfony est l’inverse du précédent. Le NativePasswordHasher de Symfony connaît la limite et la contourne : dans les branches 5.4, 6.4 et 7.4 (vérifié dans le code source), quand l’algorithme est bcrypt et que le mot de passe dépasse 72 octets ou contient un octet nul, il le remplace par base64_encode(hash('sha512', $password, true)) avant password_hash(), et fait de même à la vérification. En 4.4, l’encodeur équivalent refusait purement ces mots de passe.
C’est une bonne protection pour les comptes créés par Symfony. Mais si vous reprenez une table d’utilisateurs hachés par l’ancien code avec un simple password_hash($password, PASSWORD_BCRYPT), les comptes dont le mot de passe dépasse 72 octets ne pourront plus se connecter : l’ancien hash porte sur les 72 premiers octets bruts, Symfony vérifie un SHA-512 encodé. Avec symfony/password-hasher 7.4.8 :
$long = str_repeat('phrase de passe très longue ', 3); // 87 octets
$legacy = password_hash($long, PASSWORD_BCRYPT, ['cost' => 10]);
$hasher = new NativePasswordHasher(cost: 10, algorithm: PASSWORD_BCRYPT);
var_dump($hasher->verify($legacy, $long)); // false
var_dump(password_verify($long, $legacy)); // true
Ce sont précisément les utilisateurs les plus soigneux, ceux qui ont une phrase de passe ou un gestionnaire qui génère 80 caractères, qui appellent le support le lendemain de la mise en production. La parade tient en un hasher de transition qui essaie d’abord password_verify() brut sur les hashes hérités, puis réhache au format Symfony.
Trouver ces motifs en une heure
Le premier passage se fait au grep. Il ne trouve pas tout, mais il trouve les cas évidents, qui sont les plus fréquents :
# Concaténation dans le premier argument, ou crypt() utilisé directement
grep -rnE "password_(hash|verify)\([^,]*\.|crypt\(" --include='*.php' src/
# Pré-hachage avant bcrypt
grep -rnE "hash\('(sha512|whirlpool)'|hash\([^)]*true\)" --include='*.php' src/
Ces deux commandes ont été exécutées sur un petit projet de test qui contient un poivre concaténé, un identifiant préfixé, un crypt() sur un SHA-512 et un password_hash() correct : elles remontent les trois premiers et ignorent le quatrième. Elles ne voient pas une chaîne construite trois lignes plus haut dans une variable : pour cela, partez de chaque appel à password_hash(), password_verify() et crypt(), et remontez à la main l’origine de l’argument.
Pour chaque appel, quatre questions suffisent :
| Question | Ce qui doit alerter |
|---|---|
| Qu’est-ce qui précède le mot de passe ? | Tout préfixe : poivre, identifiant, nonce, autre hash |
| Quelle longueur maximale atteint la chaîne ? | Plus de 72 octets, en comptant l’UTF-8 |
| Y a-t-il un pré-hachage binaire ? | hash(..., true) sans base64_encode() : un octet nul peut apparaître |
| Existe-t-il un test qui refuse un mauvais mot de passe ? | Aucun, ou seulement le cas du mot de passe vide |
Le pré-hachage binaire mérite une ligne de plus. Sur PHP 8.3, password_hash() lève ValueError: Bcrypt password must not contain null character dès qu’un octet nul apparaît. Avec un SHA-256 brut de 32 octets, la probabilité qu’un des octets soit nul est d’environ 12 % : ce n’est pas une faille, c’est une inscription sur huit qui échoue au hasard, et un ticket que personne n’arrive à reproduire.
Corriger sans réinitialiser tous les mots de passe
On ne peut pas recalculer un hash sans le mot de passe en clair, donc la correction se fait à la connexion : on vérifie avec l’ancien schéma, et si c’est bon, on réhache avec le nouveau. Voici la forme que je donne à cette transition, ici vers Argon2id, testée sur PHP 8.3 :
function verifyAndUpgrade(string $plain, string $stored, callable $save): bool
{
if (str_starts_with($stored, '$argon2id$')) {
$ok = password_verify($plain, $stored);
if ($ok && password_needs_rehash($stored, PASSWORD_ARGON2ID)) {
$save(password_hash($plain, PASSWORD_ARGON2ID));
}
return $ok;
}
// Ancien schéma : poivre concaténé devant le mot de passe, puis bcrypt
if (!password_verify(PEPPER . $plain, $stored)) {
return false;
}
$save(password_hash($plain, PASSWORD_ARGON2ID));
return true;
}
Argon2id n’a pas de limite de 72 octets ; l’OWASP recommande au minimum 19 Mio de mémoire, 2 itérations et un degré de parallélisme de 1. Si vous tenez à bcrypt, ou si l’extension n’est pas disponible sur un vieil hébergement, la forme recommandée par l’OWASP pour garder un poivre est bcrypt(base64(hmac-sha384($password, $pepper))) : 64 caractères, sans octet nul, et le poivre stocké hors de la base. C’est d’ailleurs l’idée de la seconde RFC, PASSWORD_BCRYPT_SHA256, qui fait passer le mot de passe par un HMAC-SHA256 avant bcrypt, dans un format conçu pour être compatible avec celui de Passlib en Python.
Pour les comptes qui ne se reconnectent jamais, fixez une date au-delà de laquelle l’ancien schéma est refusé et la réinitialisation par email imposée. Sans cette date, l’ancien code de vérification vit pour toujours.
Les limites
Les RFC sur le sujet sont des propositions. La première, qui ajouterait un E_DEPRECATED en PHP 8.7 puis une ValueError en PHP 8.8, est toujours en brouillon, et sa discussion, qu’externals.io classe désormais comme inactive, a buté sur des objections sérieuses : Tim Düsterhus juge que « le remède est pire que le mal » pour les utilisateurs non ASCII, Derick Rethans craint de bloquer des comptes existants au moment d’un réhachage. Il est tout à fait possible qu’aucune ne soit adoptée ; l’audit reste utile dans ce cas, puisque la troncature est là aujourd’hui.
Le correctif à la connexion a aussi un angle mort : tant qu’un compte n’a pas migré, l’ancien schéma reste aussi faible qu’avant, et le mot de passe tapé à ce moment devient le nouveau. Si l’audit révèle qu’il ne restait que quelques caractères utiles, une réinitialisation forcée des comptes concernés vaut mieux qu’une migration douce. Enfin, les cas reproduits ici l’ont été sur PHP 8.3 et symfony/password-hasher 7.4.8 ; pour FreshRSS, je m’appuie sur l’analyse publiée et l’avis CVE, sans avoir relancé l’application elle-même.
Concrètement, par où commencer
- Lancez les deux
grepci-dessus, puis listez à la main chaque appel àpassword_hash(),password_verify()etcrypt(), avec l’origine de son premier argument. - Pour chaque appel où quelque chose précède le mot de passe, calculez combien d’octets du mot de passe restent dans la fenêtre de 72.
- Écrivez le test qui manque presque partout : un mauvais mot de passe qui partage les 8, 16 ou 32 premiers caractères du bon doit être refusé.
- Si vous migrez vers le hasher de Symfony, comptez dans votre base les mots de passe hérités dont vous ne connaissez pas la longueur, et prévoyez le hasher de transition avant la mise en production, pas après le premier ticket.
- Choisissez le schéma cible (Argon2id, ou bcrypt derrière un HMAC encodé en base64), mettez en place le réhachage à la connexion et fixez la date de fin de l’ancien schéma.
Une fonction d’authentification est souvent le code le plus ancien et le moins relu d’une application, et c’est justement celui qu’aucun utilisateur ne viendra jamais signaler comme cassé.
Mise à jour du 2 novembre 2026
Le 29 octobre, une troisième RFC a été ouverte à la discussion pour PHP 8.7 : PASSWORD_BCRYPT_WRAP, d’Andrey Andreev. Elle se présente comme une alternative à PASSWORD_BCRYPT_SHA256 et lui reproche de reposer sur SHA-256, peu coûteux à attaquer sur GPU ; elle prépare le mot de passe avec cSHAKE256 par défaut (ou un HMAC SHA-512 tronqué), salé, encodé en Base64, puis passé à bcrypt, avec des mots de passe bornés à 1 024 octets et un format de hash propre ($bcrypt-wrap$v=0$…). Pour vous, rien ne change à court terme : aucune de ces RFC n’est votée, et l’audit décrit plus haut reste le travail à faire sur le code existant. J’ai corrigé plus haut la section « Les limites », qui ne parlait que de deux RFC.
Sources
- PHP RFC: Add PASSWORD_BCRYPT_WRAP, wiki PHP, 29 octobre 2026
- PHP RFC: Throw error for passwords longer than 72 bytes in password_hash() with bcrypt, wiki PHP, 27 septembre 2026
- PHP RFC: Add PASSWORD_BCRYPT_SHA256 to password_hash(), wiki PHP, 4 octobre 2026
- Discussion de la RFC sur php.internals, externals.io, septembre 2026
- FreshRSS bcrypt truncation auth bypass, PentesterLab, 10 mars 2026
- CVE-2025-68402, OSV
- password_hash(), manuel PHP
- Password Storage Cheat Sheet, OWASP
- NativePasswordHasher.php, Symfony 7.4, GitHub
Sur ce sujet, l’offre de l’atelier : Migration PHP