Zum Inhalt
wannacode

Passwörter

Kurz gesagt: Passwörter werden als langsamer Hash mit Salt gespeichert (bcrypt oder argon2id), nicht verschlüsselt und nicht mit schnellem SHA-256, damit ein Datenbank-Leak die Passwörter selbst nicht preisgibt.

Problem

Das Backup, das öffentlich wurde

Der Shop speichert seine Käufer in einer Tabelle users: eine E-Mail und ein Passwort. Jemand lädt ein Datenbank-Backup in einen öffentlichen Ordner hoch, und eine Woche später ist es im Internet. Was die Käufer verlieren, hängt nur davon ab, wie das Passwort gespeichert war:

  • als Text: Jedes Passwort liegt offen. Viele benutzen überall dasselbe Passwort, also leaken auch ihre E-Mail-Konten;
  • verschlüsselt mit einem Schlüssel, der neben der Datenbank liegt: dasselbe. Der Schlüssel steht in der Konfiguration desselben Servers und leakt mit;
  • als SHA-256 ohne Salt: Die Passwörter sind nicht zu sehen, aber gleiche Passwörter ergeben gleiche Hashes. Die Tabelle selbst zeigt, wer mit wem ein Passwort teilt. Und wer die Tabelle hat, kann eine Liste häufiger Passwörter nehmen, jedes hashen und nach Treffern suchen: Das ist ein Wörterbuchangriff, und SHA-256 wurde gebaut, um schnell zu sein.

Einen Leak kann man nie ganz ausschließen. Also wird das Passwort so gespeichert, dass ein Leak die Passwörter nicht herausgibt.

Die Idee

Behalte den Fingerabdruck, nicht das Passwort

Der Server muss das Passwort nicht kennen. Er muss nur prüfen, ob das eingegebene Passwort dasselbe ist. Dafür gibt es einen kryptografischen Hash: eine Funktion, die Text in einen Fingerabdruck fester Länge verwandelt und nicht umkehrbar ist. Dieselbe Eingabe ergibt denselben Fingerabdruck, und der Fingerabdruck führt nicht zur Eingabe zurück.

Das ist nicht der GetHashCode aus der Lektion über das Dictionary: Der ist schnell, kurz und kollidiert oft, weil er für Buckets gemacht ist. Ein kryptografischer Hash ist etwas anderes.

Der alte Code des Shops speichert das SHA-256 des Passworts:

static string Sha256(string text) =>
Convert.ToHexStringLower(
SHA256.HashData(Encoding.UTF8.GetBytes(text)));
// Der alte Code des Shops: SHA-256 ohne Salt.
static Row Register(string email, string password) =>
new(email, "", Sha256(password));

Vier Käufer; ana und vera haben dasselbe Passwort. Die Funktion SharedRows zählt die Zeilen, deren Hash mehr als einmal vorkommt:

static int SharedRows(List<Row> users) =>
users.GroupBy(row => row.Hash)
.Where(group => group.Count() > 1)
.Sum(group => group.Count());
List<Row> users =
[
Register("ana@example.com", "qwerty"),
Register("boris@example.com", "sofia2024"),
Register("vera@example.com", "qwerty"),
Register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
Console.WriteLine("Die Tabelle users:");
foreach (var row in users)
Console.WriteLine($"{row.Email,-20}{row.Hash[..16]}…");
Console.WriteLine($"Zeilen mit gemeinsamem Hash: {SharedRows(users)}");
Ausgabe
Die Tabelle users:
ana@example.com 65e84be33532fb78…
boris@example.com 6191711a1871a6bd…
vera@example.com 65e84be33532fb78…
georgi@example.com 9cbd9835924cf733…
Zeilen mit gemeinsamem Hash: 2

Die Zeilen von ana und vera sind gleich. Ohne etwas zu brechen, sieht jeder mit der Tabelle, dass die beiden ein Passwort haben. Gibt eine von ihnen es preis, gibt sie das der anderen mit preis.

Die Idee

Ein Salt: jede Zeile anders

Gegen gleiche Zeilen hilft ein Salt: zufällige Bytes, für jede Zeile anders, die vor dem Hash neben das Passwort kommen. Der Salt ist kein Geheimnis. Er steht offen in der Zeile, weil die Anmeldung ihn wieder braucht.

// Ein eigener zufälliger Salt für jede Zeile.
static Row Register(string email, string password)
{
string salt = Convert.ToHexStringLower(
RandomNumberGenerator.GetBytes(16));
return new(email, salt, Sha256(salt + password));
}

RandomNumberGenerator ist ein kryptografischer Zufallszahlengenerator: Sein nächster Wert lässt sich nicht vorhersagen. (Warum der gewöhnliche Generator nicht reicht, steht in der Lektion über Kryptografie.)

Der Salt ist bei jedem Lauf anders, deshalb gibt das Programm nur aus, was sich nicht ändert:

List<Row> users =
[
Register("ana@example.com", "qwerty"),
Register("boris@example.com", "sofia2024"),
Register("vera@example.com", "qwerty"),
Register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
Console.WriteLine($"Gleicher Eintrag für ana und vera: {users[0].Hash == users[2].Hash}");
Console.WriteLine($"Zeilen mit gemeinsamem Hash: {SharedRows(users)}");
Ausgabe
Gleicher Eintrag für ana und vera: False
Zeilen mit gemeinsamem Hash: 0

Gleiche Passwörter fallen nicht mehr auf, und eine Berechnung zählt nur für eine Zeile. Aber SHA-256 ist weiter schnell: Eine Grafikkarte berechnet Milliarden pro Sekunde, der Wörterbuchangriff funktioniert also weiter, nur Zeile für Zeile. Der Hash muss auch absichtlich langsam sein.

Sieh es dir an

Die Tabelle users: ohne Salt, mit Salt, mit langsamem Hash

// Demo; auf einem echten Server 600_000 (OWASP)
const int Iterations = 10_000;
static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Kdf(password, salt, Iterations);
return $"pbkdf2_sha256${Iterations}$"
+ $"{Convert.ToBase64String(salt)}$"
+ Convert.ToBase64String(hash);
}
static bool Verify(string password, string record)
{
string[] parts = record.Split('$');
int iterations = int.Parse(parts[1]);
byte[] salt = Convert.FromBase64String(parts[2]);
byte[] expected = Convert.FromBase64String(parts[3]);
byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.FixedTimeEquals(
expected, actual);
}
Schritt für Schritt, in Worten
  1. Die Tabelle users mit dem alten Code des Shops: eine E-Mail und das SHA-256 des Passworts, ohne Salt.
  2. Gleiche Passwörter ergeben gleiche Hashes. ana und vera haben denselben Wert, also zeigt die Tabelle selbst, wer ein Passwort teilt: 2 Zeilen.
  3. Jetzt bekommt jede Zeile einen eigenen zufälligen Salt, und die Zeile speichert SHA-256(Salt + Passwort).
  4. Jede Zeile ist anders, auch ana und vera: 0 Zeilen teilen einen Hash. Aber SHA-256 ist schnell, deshalb ist jeder Versuch für eine Zeile weiter billig.
  5. Die Lösung ist ein langsamer Hash: PBKDF2 wiederholt HMAC-SHA256 hier 10.000-mal (eine Demo). Auf einem echten Server sind es 600.000.
  6. Der Eintrag speichert alles, was man später zur Prüfung braucht: Algorithmus $ Kosten $ Salt $ Hash. Das Passwort selbst steht nicht darin.
  7. Bei der Anmeldung liest der Code die Kosten und den Salt aus dem Eintrag und hasht das eingegebene Passwort genauso.
  8. Die beiden Hashes werden in konstanter Zeit verglichen: "qwerty" ergibt ✓, "qwerty1" ergibt ✗. Gleiche Passwörter fallen nicht mehr auf, und jeder Versuch ist langsam.

Das letzte Bild fasst die drei Schritte zusammen: Die Spalte speichert einen Eintrag mit Salt und Kosten, keine zwei Zeilen sind gleich, und die Anmeldung prüft das Passwort gegen den Eintrag.

Die Idee

Ein langsamer Hash und der Eintrag

Ein langsamer Passwort-Hash wiederholt seine Arbeit viele Male. Wie oft, sind die Kosten (Work Factor). Eine Anmeldung berechnet ihn einmal und wartet den Bruchteil einer Sekunde. Wer die geleakte Tabelle hat, zahlt für jeden Versuch denselben Preis.

Die Standardbibliothek hat PBKDF2: HMAC-SHA256, so oft wiederholt, wie du Iterationen angibst.
static byte[] Kdf(string password, byte[] salt,
int iterations) =>
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations,
HashAlgorithmName.SHA256, 32);
Rfc2898DeriveBytes.Pbkdf2 ist seit .NET 6 eine statische Methode: Passwort, Salt, Iterationen, Algorithmus und Länge in Bytes (32).

Der Eintrag speichert alles, was die Prüfung braucht: Algorithmus, Kosten, Salt und Hash, getrennt durch $. So können die Kosten später steigen, ohne die alten Einträge zu brechen.

// Demo; auf einem echten Server 600_000 (OWASP)
const int Iterations = 10_000;
static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Kdf(password, salt, Iterations);
return $"pbkdf2_sha256${Iterations}$"
+ $"{Convert.ToBase64String(salt)}$"
+ Convert.ToBase64String(hash);
}
static bool Verify(string password, string record)
{
string[] parts = record.Split('$');
int iterations = int.Parse(parts[1]);
byte[] salt = Convert.FromBase64String(parts[2]);
byte[] expected = Convert.FromBase64String(parts[3]);
byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.FixedTimeEquals(
expected, actual);
}
string record = HashPassword("qwerty");
string[] parts = record.Split('$');
Console.WriteLine($"Algorithmus und Kosten: {parts[0]}, {parts[1]}");
Console.WriteLine($"Anmeldung mit dem richtigen Passwort: {Verify("qwerty", record)}");
Console.WriteLine($"Anmeldung mit einem falschen Passwort: {Verify("qwerty1", record)}");
Console.WriteLine($"Ein Passwort, zwei Einträge, gleich: {record == HashPassword("qwerty")}");
Ausgabe
Algorithmus und Kosten: pbkdf2_sha256, 10000
Anmeldung mit dem richtigen Passwort: True
Anmeldung mit einem falschen Passwort: False
Ein Passwort, zwei Einträge, gleich: False

Die Kosten im Programm sind klein, damit es schnell läuft. Auf einem echten Server empfiehlt OWASP 600.000 Iterationen für PBKDF2-HMAC-SHA256. In einem echten Projekt schreibst du das meist nicht selbst, sondern nimmst bcrypt oder argon2id aus einer bewährten Bibliothek (Einzelheiten unter „Genauer, für Neugierige“).

Probier es aus

Sage die Ausgabe voraus

Ein und dasselbe Passwort, zweimal gehasht. Was gibt es aus?

string first = HashPassword("qwerty");
string second = HashPassword("qwerty");
Console.WriteLine(first == second);
Console.WriteLine(Verify("qwerty", first));
Console.WriteLine(Verify("qwerty", second));
Antwort

Probier es aus

Ergänze

Bei der Anmeldung werden die beiden Hashes verglichen. Ein gewöhnlicher Vergleich hält beim ersten unterschiedlichen Byte an, und die Antwortzeit kann verraten, wie weit sie übereinstimmen. Welcher Vergleich geht immer durch alle Bytes?

byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.(
    expected, actual);

Probier es aus

Finde den Fehler

Ein Kollege „vereinfacht“ den Code: statt eines zufälligen Salts für jede Zeile ein Salt für alle. Der langsame Hash ist derselbe. Wo liegt das Problem?

Wähle die Zeile mit dem Fehler.

Sieh es dir an

Die Kosten wachsen über die Jahre

Computer werden schneller, also reichen Kosten, die letztes Jahr genügten, nach einiger Zeit nicht mehr. Alte Einträge lassen sich nicht neu berechnen: Ein Hash geht nicht rückwärts, und der Server hat das Passwort nur im Moment einer Anmeldung. Also findet der neue Hash bei einer erfolgreichen Anmeldung statt. Hier nimmt HashPassword die Kosten als Parameter, und jeder Eintrag speichert seine eigenen, sodass die alten Einträge weiter funktionieren.

// letztes Jahr war es 10_000
const int Iterations = 20_000;
static int CostOf(string record) => int.Parse(record.Split('$')[1]);
static bool Login(Account account, string password)
{
if (!Verify(password, account.Record)) return false;
if (CostOf(account.Record) < Iterations)
{
// erst jetzt haben wir das Passwort in der Hand: Hash mit den neuen Kosten
account.Record = HashPassword(password, Iterations);
}
return true;
}
var account = new Account(HashPassword("qwerty", 10_000));
Console.WriteLine($"Kosten vor der Anmeldung: {CostOf(account.Record)}");
Console.WriteLine($"Anmeldung: {Login(account, "qwerty")}");
Console.WriteLine($"Kosten nach der Anmeldung: {CostOf(account.Record)}");
Ausgabe
Kosten vor der Anmeldung: 10000
Anmeldung: True
Kosten nach der Anmeldung: 20000
Genauer, für Neugierige

Welche Einstellungen (OWASP, Password Storage Cheat Sheet):

  • argon2id: 19 MiB Speicher, 2 Iterationen, Parallelität 1. Die erste Wahl für ein neues Projekt;
  • scrypt: N = 2¹⁷, r = 8, p = 1. Es braucht 128 MiB Speicher, und sowohl in Python als auch in Node liegt das Standardlimit bei 32 MiB, also wird es mit maxmem angehoben;
  • bcrypt: Kosten 10 oder mehr. Es benutzt nur die ersten 72 Bytes des Passworts;
  • PBKDF2-HMAC-SHA256: 600.000 Iterationen, wenn ein FIPS-zugelassener Algorithmus verlangt wird.

Was echte Projekte benutzen. Du schreibst das selten selbst; du nimmst die Bibliothek des Frameworks: ASP.NET Core Identity hat PasswordHasher<TUser>: PBKDF2 mit Salt und Kosten im Eintrag, und PasswordVerificationResult.SuccessRehashNeeded, das dir genau sagt, wann neu zu hashen ist. Für argon2id und bcrypt gibt es NuGet-Pakete.

Pepper. Manche fügen außerdem einen geheimen Wert hinzu, gemeinsam für alle Zeilen, der außerhalb der Datenbank lebt (in einem Secret Store, siehe die Lektion über Geheimnisse). Ein Leak allein der Datenbank reicht dann nicht. Das hilft, ersetzt aber weder den Salt noch den langsamen Hash.

Die Passwortrichtlinie. Ein langes Passwort (mindestens 12 Zeichen) und ein Abgleich mit Listen geleakter Passwörter helfen mehr als Regeln wie „ein Großbuchstabe und eine Ziffer“. Das ist auch die Aufgabe unten.

Wie es in den anderen Sprachen ist: Java und C# haben PBKDF2 in der Standardbibliothek, aber kein scrypt; Python und Node haben beide (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). Keine der vier hat argon2id oder bcrypt eingebaut.

Übung

Das Passwort ändern

Löse die Aufgabe in deinem Editor.

Das Konto des Käufers hat das Passwort "qwerty". Schreibe ChangePassword(account, oldPassword, newPassword), das:

  1. das aktuelle Passwort mit Verify prüft und ablehnt, wenn es falsch ist;
  2. ein neues Passwort ablehnt, das kürzer als 12 Zeichen ist;
  3. ein neues Passwort ablehnt, das (ohne Beachtung von Groß- und Kleinschreibung) in der Liste password1234, qwerty123456, 123456789012 steht;
  4. sonst einen neuen Eintrag mit neuem Salt speichert und „Passwort geändert“ zurückgibt.

Rufe es mit ("qwerty1", "new-pass-2026!"), ("qwerty", "short1"), ("qwerty", "Qwerty123456") und ("qwerty", "tram-9-to-vitosha") auf und prüfe dann die Anmeldung mit dem neuen Passwort:

Ausgabe
Falsches aktuelles Passwort
Das neue Passwort ist kürzer als 12 Zeichen
Dieses Passwort steht auf den Listen geleakter Passwörter
Passwort geändert
Anmeldung mit dem neuen Passwort: True

Weitere Aufgaben

  • Ein neuer Hash bei der Anmeldung. Füge Login aus „Die Kosten wachsen über die Jahre“ hinzu und prüfe, dass ein altes Konto mit niedrigeren Kosten aus der Anmeldung mit den neuen herauskommt.
  • Dieselbe Zeit bei einer falschen E-Mail. Existiert die E-Mail nicht, antwortet die Anmeldung sofort; existiert sie, berechnet sie den langsamen Hash. Wie würdest du dafür sorgen, dass beide Fälle gleich lang dauern?

Test

Teste dich

1. Warum werden Passwörter nicht mit einem Schlüssel verschlüsselt gespeichert?
2. Was macht ein Salt, und was macht er NICHT?
3. Warum hilft ein langsamer Hash, statt zu schaden?
4. Warum wird der Hash bei der Anmeldung in konstanter Zeit verglichen?
5. Du erhöhst die Kosten von 10.000 auf 20.000 Iterationen. Was passiert mit den alten Einträgen?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·