● CWE-916 · OWASP A02:2021

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

Étape 1

La table des utilisateurs fuit

Une injection SQL, une sauvegarde exposée ou un ordinateur portable volé donne à l'attaquant la colonne password_hash.

Étape 2

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.

Étape 3

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.

Étape 4

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é

IMPLÉMENTATION VULNÉRABLE
# 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()
PATCH SÉCURISÉ ET ROBUSTE
# 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

Sources

← Parcourir tout l'annuaire sécurité Tous les guides de vulnérabilités →