● CWE-916 · OWASP A02:2021

Unsicheres Passwort-Hashing (CWE-916): Warum SHA-256 nicht reicht und wie man Passwörter speichert

Wer Passwörter als ungesalzene SHA-256-, SHA-1- oder MD5-Digests speichert, lässt eine abgeflossene Tabelle mit Milliarden Versuchen pro Sekunde auf einer GPU prüfen, und alle Nutzer mit demselben Passwort teilen sich einen Hash. Wie Salts, speicherintensive Funktionen wie Argon2id, Rehash beim Login und eine Schein-Prüfung eine gestohlene Datenbank schützen, mit einem Python-Beispiel mit argon2-cffi.

Einfache Erklärung (ELI5)

Eine Garderobe bewahrt von jeder Marke eine Kopie auf, um zu prüfen, wem welcher Mantel gehört. Sind die Kopien einfache Fotokopien, geht ein Dieb, der den Ordner stiehlt, mit allen Mänteln hinaus. Ein schneller Hash ist eine leicht verschmierte Fotokopie: Ein Dieb mit einem schnellen Scanner entschmiert Millionen pro Sekunde. Ein langsamer, speicherhungriger Hash ist eine Kopie in einem kleinen Tresor, der eine volle Sekunde und eine große Werkbank zum Öffnen braucht, ein Tresor pro Marke, jeder mit einem anderen Schloss. Der Besitzer öffnet an der Tür einen Tresor, ohne die Wartezeit zu merken; der Dieb muss Millionen öffnen.

Kernkonzepte & Begriffe

Schneller Hash
SHA-256, SHA-1 und MD5 sind auf Geschwindigkeit ausgelegt. Eine einzige moderne GPU berechnet Milliarden SHA-256-Hashes pro Sekunde, sodass kurze oder gängige Passwörter Brute-Force- und Wörterbuchangriffen binnen Stunden erliegen.
Salt
Ein Zufallswert, der mit jedem Hash gespeichert wird, damit gleiche Passwörter verschiedene Hashes ergeben. Ohne ihn knackt eine einzige vorberechnete Tabelle alle Konten auf einmal, und gleiche Hashes verraten Nutzer mit demselben Passwort.
Speicherintensive Funktion (Argon2id)
Argon2id lässt jeden Versuch Zeit und Speicher kosten, was den Großteil des GPU-Vorteils beseitigt. Das OWASP-Minimum sind 19 MiB Speicher, zwei Iterationen und eine Spur (memory_cost=19456, time_cost=2, parallelism=1); scrypt und bcrypt sind vertretbare Alternativen.
PHC-String und Rehash
PasswordHasher.hash() liefert einen String wie $argon2id$v=19$m=19456,t=2,p=1$... mit Algorithmus, Parametern, Salt und Hash. check_needs_rehash() meldet, wenn gespeicherte Parameter schwächer als die aktuellen sind, damit der Hash beim nächsten erfolgreichen Login erneuert wird.
Antwortzeit und Konten-Enumeration
Antworten unbekannte E-Mail-Adressen sofort, während echte die Dauer einer Hash-Prüfung brauchen, verraten die Antwortzeiten, welche Konten existieren. Eine Prüfung gegen einen Schein-Hash lässt beide Wege gleich lange dauern.

Schritt-für-Schritt Angriffsablauf

Schritt 1

Die Nutzertabelle fließt ab

Eine SQL-Injection, ein offenes Backup oder ein gestohlener Laptop gibt dem Angreifer die Spalte password_hash.

Schritt 2

Der Angreifer startet einen GPU-Cracker

Ungesalzenes SHA-256 erlaubt es, eine Wortliste einmal zu hashen und mit allen Zeilen zu vergleichen. Ein Testkonto mit dem Passwort Summer2024! zeigt, wie schnell ein gängiges Muster fällt.

Schritt 3

Geteilte Passwörter fallen gemeinsam

Alle Nutzer mit demselben Passwort haben denselben Digest, ein geknackter Hash legt also alle offen.

Schritt 4

Passwörter werden anderswo wiederverwendet

Geknackte Passwörter werden bei E-Mail-, Bank- und Cloud-Konten ausprobiert. 2012 verlor LinkedIn rund 6,5 Millionen ungesalzene SHA-1-Hashes, und 2016 wurde ein Satz von rund 117 Millionen Zugangsdaten aus demselben Vorfall zum Kauf angeboten.

Quellcode: Verwundbar vs. Sicher

VERWUNDBARE IMPLEMENTIERUNG
# users.py: ein schneller, ungesalzener Digest pro Passwort
import hashlib

from .db import db


# gleiches Passwort, gleicher Hash, Milliarden Versuche pro Sekunde auf einer 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))


# unbekannte E-Mails antworten sofort, die Zeit verrät also existierende Konten
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()
GEHÄRTETER SICHERHEITS-PATCH
# users.py: Argon2id mit Salt pro Passwort und Erneuerung beim Login
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError

from .db import db

# OWASP-Minimum für Argon2id: 19 MiB, 2 Iterationen, 1 Spur
hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
DUMMY_HASH = hasher.hash("timing-equaliser")


# der gespeicherte String enthält Algorithmus, Parameter, Salt und Hash
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)))


# auch Unbekannte kosten eine Prüfung; schwache Hashes werden erneuert
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

Checkliste für Engineering & Systemsicherheit

Quellen

← Zum vollständigen Sicherheitsverzeichnis Alle Anleitungen zu Schwachstellen →