Hachage de mots de passe non sécurisé (CWE-916) : pourquoi SHA-256 ne suffit pas et comment stocker les mots de passe
Stocker les mots de passe sous forme d'empreintes SHA-256, SHA-1 ou MD5 sans sel signifie qu'une table divulguée peut être testée à des milliards d'essais par seconde sur un GPU, et que tous les utilisateurs ayant le même mot de passe partagent une seule empreinte. Comment les sels, les fonctions coûteuses en mémoire comme Argon2id, le re-hachage à la connexion et la vérification factice protègent une base volée, avec un exemple Python utilisant argon2-cffi.
Explication en Langage Simple (ELI5)
Un vestiaire garde une copie de chaque ticket pour vérifier à qui appartient chaque manteau. Si les copies sont de simples photocopies, un voleur qui dérobe le classeur repart avec tous les manteaux. Un hachage rapide est une photocopie légèrement brouillée : un voleur muni d'un scanner rapide en débrouille des millions par seconde. Un hachage lent et gourmand en mémoire est une copie enfermée dans un petit coffre qui demande une seconde entière et un grand établi pour l'ouvrir, un coffre par ticket, chacun avec une serrure différente. Le propriétaire ouvre un coffre à la porte sans remarquer l'attente ; le voleur doit en ouvrir des millions.
Concepts Clés et Termes
- Hachage rapide
- SHA-256, SHA-1 et MD5 sont conçus pour être rapides. Un seul GPU moderne calcule des milliards d'empreintes SHA-256 par seconde, donc les mots de passe courts ou courants tombent en quelques heures face à la force brute et aux attaques par dictionnaire.
- Sel
- Une valeur aléatoire stockée avec chaque empreinte pour que des mots de passe identiques donnent des empreintes différentes. Sans sel, une seule table précalculée casse tous les comptes d'un coup, et des empreintes égales révèlent les utilisateurs qui partagent un mot de passe.
- Fonction coûteuse en mémoire (Argon2id)
- Argon2id fait coûter chaque essai en temps et en mémoire, ce qui supprime l'essentiel de l'avantage d'un GPU. Le minimum OWASP est de 19 Mio de mémoire, deux itérations et une voie (
memory_cost=19456,time_cost=2,parallelism=1) ; scrypt et bcrypt sont des alternatives acceptables. - Chaîne PHC et re-hachage
PasswordHasher.hash()renvoie une chaîne telle que$argon2id$v=19$m=19456,t=2,p=1$...contenant l'algorithme, les paramètres, le sel et l'empreinte.check_needs_rehash()signale quand les paramètres stockés sont plus faibles que les actuels, pour mettre à niveau l'empreinte à la prochaine connexion réussie.- Temps de réponse et énumération de comptes
- Si les e-mails inconnus répondent instantanément alors que les vrais prennent le temps d'une vérification d'empreinte, les temps de réponse révèlent quels comptes existent. Vérifier contre une empreinte factice fait durer les deux chemins le même temps.
Déroulement de l'Attaque Étape par Étape
La table des utilisateurs fuit
Une injection SQL, une sauvegarde exposée ou un ordinateur portable volé donne à l'attaquant la colonne password_hash.
L'attaquant lance un casseur sur GPU
SHA-256 sans sel lui permet de hacher une liste de mots une seule fois et de la comparer à toutes les lignes. Un compte de test avec le mot de passe Summer2024! montre à quelle vitesse tombe un motif courant.
Les mots de passe partagés tombent ensemble
Tous les utilisateurs qui ont choisi le même mot de passe ont la même empreinte, donc une empreinte cassée les expose tous.
Les mots de passe sont réutilisés ailleurs
Les mots de passe cassés sont essayés sur des comptes de messagerie, bancaires et cloud. En 2012, LinkedIn a perdu environ 6,5 millions d'empreintes SHA-1 sans sel, et en 2016 un lot d'environ 117 millions d'identifiants issus de la même fuite a été mis en vente.
Code Source : Vulnérable vs Sécurisé
# users.py : une empreinte rapide et sans sel par mot de passe
import hashlib
from .db import db
# même mot de passe, même empreinte, des milliards d'essais par seconde sur GPU
def create_user(email: str, password: str) -> None:
digest = hashlib.sha256(password.encode()).hexdigest()
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, digest))
# les e-mails inconnus répondent tout de suite, le temps révèle donc les comptes existants
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT password_hash FROM users WHERE email = ?", (email,)).fetchone()
return row is not None and row[0] == hashlib.sha256(password.encode()).hexdigest()
# users.py : Argon2id avec un sel par mot de passe et mise à niveau à la connexion
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from .db import db
# minimum OWASP pour Argon2id : 19 Mio, 2 itérations, 1 voie
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")
# la chaîne stockée contient algorithme, paramètres, sel et empreinte
def create_user(email: str, password: str) -> None:
if not 12 <= len(password) <= 128:
raise ValueError("password must be 12 to 128 characters")
db.execute("INSERT INTO users (email, password_hash) VALUES (?, ?)", (email, hasher.hash(password)))
# les inconnus paient aussi une vérification ; les empreintes faibles sont mises à niveau
def check_login(email: str, password: str) -> bool:
row = db.execute("SELECT id, password_hash FROM users WHERE email = ?", (email,)).fetchone()
stored = row[1] if row else DUMMY_HASH
try:
hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if row is None:
return False
if hasher.check_needs_rehash(stored):
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hasher.hash(password), row[0]))
return True
Liste de Contrôle de Sécurité pour l'Ingénierie
- Hachez les mots de passe avec Argon2id (au moins 19 Mio de mémoire, 2 itérations, 1 voie), scrypt ou bcrypt ; ajoutez une règle Semgrep qui signale les appels
hashlibsur des variables de mot de passe et exécutez Bandit B324 pour MD5 et SHA-1. - Stockez la chaîne PHC complète renvoyée par la bibliothèque, sans jamais extraire ni réutiliser le sel.
- Appelez
check_needs_rehash()après chaque connexion réussie et remplacez les empreintes créées avec des paramètres plus faibles. - Vérifiez contre une empreinte factice quand le compte n'existe pas, pour que le temps de réponse ne révèle pas les e-mails inscrits.
- Exigez au moins 12 caractères, acceptez les longues phrases de passe jusqu'à au moins 64 et refusez les mots de passe présents dans des listes de fuites.