Zum Inhalt
wannacode

Kryptografie, die du nicht selbst schreibst

Kurz gesagt: Kryptografie kommt aus der Standardbibliothek, du schreibst sie nicht selbst: kryptografisch sichere Zufallszahlen, Vergleich in konstanter Zeit und authentifizierte Verschlüsselung wie AES-GCM.

Problem

Der Code, den schon jemand kennt

Der Shop schickt bei „Passwort vergessen“ einen Code aus 6 Ziffern. Derselbe Generator macht auch die Rabattcodes im Newsletter. Der Code sieht zufällig aus: 807592, 213904, 566120.

In einer Nacht ändert jemand das Passwort eines Käufers und kauft mit dessen Karte ein. Er hat nicht eine Million Codes geraten: Er hat einen versucht. Er kannte den Code, bevor der Käufer die E-Mail öffnete.

Der Generator ist new Random(started), und started ist die Sekunde, in der der Shop gestartet ist.

Die Idee

Random ist eine Formel, kein Zufall

Random ist ein Pseudozufallszahlengenerator: eine Formel, die aus einer Zahl (dem Seed, dem Startwert) eine Folge berechnet, die zufällig aussieht. Derselbe Seed ergibt dieselbe Folge. Für Spiele, Simulationen und Tests ist das nützlich. Für Geheimnisse ist es eine Katastrophe: Wer den Seed kennt oder erraten kann, kennt jede nächste Zahl.

Der Seed des Shops ist die Sekunde seines Starts. Der Angreifer weiß, dass der Shop in der Nacht neu gestartet wurde: 3.600 Möglichkeiten. Er hat seinen eigenen Rabattcode aus dem Newsletter. Er probiert jeden Seed, bis der erste Code mit seinem übereinstimmt. Dann ist der nächste Code seiner Kopie der Code des Käufers.

Für alles, was geheim und unvorhersagbar sein muss (Tokens, Codes, Schlüssel, Salts, Nonces), nimmst du einen kryptografischen Generator. Er holt Zufall vom Betriebssystem (Hardware-Ereignisse, Rauschen) und ist so gebaut, dass sich seine nächste Ausgabe nicht aus dem Vorherigen berechnen lässt.

Sieh es dir an

Der Angreifer probiert jede Sekunde

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
Schritt für Schritt, in Worten
  1. Der Shop macht seine Codes mit Random, und der Seed ist die Sekunde des Starts: 03:40:17. Der erste Code geht in den Newsletter, auch an den Angreifer.
  2. Die nächste Zahl desselben Generators ist der Code zum Zurücksetzen des Passworts für einen Käufer. Er geht per E-Mail raus.
  3. Der Angreifer weiß, dass der Shop in der Nacht neu gestartet wurde. Er probiert jede Sekunde ab 03:00: ein Generator pro Sekunde.
  4. Beim Versuch 2418 ist der erste Code derselbe wie sein Rabattcode. Das ist der Seed: 03:40:17.
  5. Die nächste Zahl seiner Kopie ist der Reset-Code des Käufers. Er ändert das Passwort und übernimmt das Konto.
  6. Die Lösung: Die Bytes kommen vom kryptografischen Generator des Betriebssystems. Es gibt keinen Seed zu erraten.
  7. 3600 Versuche oder 3 Millionen: kein Treffer. Das Token besteht aus 32 Zufallsbytes, und auch ein Code aus 6 Ziffern ist nicht mehr vorhersagbar.
static String code(Random random) {
return String.format("%06d", random.nextInt(1_000_000));
}
static String predictNext(String seen, long from,
int window) {
for (int i = 0; i < window; i++) {
Random guess = new Random(from + i);
if (code(guess).equals(seen)) {
System.out.println("Seed: Versuch " + (i + 1));
return code(guess);
}
}
return null;
}
long night = 1_793_070_000L; // 27.10.2026, 03:00 UTC
long started = night + 2_417; // der Shop wurde um 03:40:17 neu gestartet
Random random = new Random(started);
String discount = code(random);
String reset = code(random);
System.out.println("Rabattcode aus dem Newsletter (den hat der Angreifer): " + discount);
String predicted = predictNext(discount, night, 3_600);
System.out.println("Vorhergesagter Code zum Zurücksetzen des Passworts: " + predicted);
System.out.println("Der echte Code, an den Käufer geschickt: " + reset);
Ausgabe
Rabattcode aus dem Newsletter (den hat der Angreifer): 727726
Seed: Versuch 2418
Vorhergesagter Code zum Zurücksetzen des Passworts: 807592
Der echte Code, an den Käufer geschickt: 807592

Die Codes sind in den vier Sprachen verschieden, weil die Generatoren verschieden sind. Der Versuch ist derselbe: 2418, weil der Seed 03:40:17 ist.

Die Idee

Der kryptografische Generator in jeder Sprache

static final SecureRandom RANDOM = new SecureRandom();
static String resetToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(bytes);
}
static String code() {
return String.format("%06d", RANDOM.nextInt(1_000_000));
}
String first = resetToken();
String second = resetToken();
System.out.println("Länge des Tokens: " + first.length());
System.out.println("Nur A–Z, a–z, 0–9, - und _: " + first.matches("[A-Za-z0-9_-]+"));
System.out.println("Zwei Tokens hintereinander sind verschieden: " + !first.equals(second));
System.out.println("Der Code hat 6 Ziffern: " + code().matches("\\d{6}"));
Ausgabe
Länge des Tokens: 43
Nur A–Z, a–z, 0–9, - und _: true
Zwei Tokens hintereinander sind verschieden: true
Der Code hat 6 Ziffern: true

Das Token besteht aus 32 Bytes (256 Bit), geschrieben in URL-sicherem Base64: 43 Zeichen, die in einen Link passen. Es zu erraten ist so schwer wie das Erraten eines AES-256-Schlüssels.

SecureRandom wird einmal erzeugt und wiederverwendet: Die erste Benutzung sammelt Zufall vom System und kann langsam sein. Auch UUID.randomUUID() benutzt SecureRandom.

Probier es aus

Sage die Ausgabe voraus

Zwei Generatoren mit demselben Seed. Was gibt das aus?

Random a = new Random(42);
Random b = new Random(42);
System.out.println(a.nextInt(100) == b.nextInt(100));
System.out.println(a.nextInt(100) == b.nextInt(100));
Antwort

Probier es aus

Ergänze

Ein Token zum Zurücksetzen des Passworts aus dem kryptografischen Generator:

static final SecureRandom RANDOM = new ();

Probier es aus

Finde den Fehler

Das Token ist lang und sieht sicher aus. Welche Zeile macht es vorhersagbar?

Wähle die Zeile mit dem Fehler.

Die Idee

Ein Vergleich, der nichts verrät

Prüft der Shop den Code aus der E-Mail, vergleicht er ihn mit dem gespeicherten. Ein gewöhnlicher Stringvergleich hält beim ersten Unterschied an. Je mehr führende Zeichen stimmen, desto länger dauert es. Über das Netz sind die Unterschiede Nanosekunden, aber mit vielen Versuchen und Statistik lassen sie sich messen. So errät der Angreifer Zeichen für Zeichen: 10 Versuche für das erste, 10 für das zweite …

Das Programm unten misst keine Zeit: Es zählt, wie viele Zeichen der Vergleich ansieht, und genau das macht den Zeitunterschied.

// Was equals intern macht: Es hält beim ersten Unterschied an
static int comparedChars(String secret, String guess) {
int i = 0;
while (i < secret.length() && secret.charAt(i) == guess.charAt(i)) {
i++;
}
return Math.min(i + 1, secret.length());
}
String secret = "481937";
for (String guess : List.of("000000", "400000", "480000", "481000")) {
System.out.println(guess + ": verglichene Zeichen " + comparedChars(secret, guess));
}
byte[] expected = secret.getBytes(StandardCharsets.UTF_8);
for (String guess : List.of("481000", "481937")) {
byte[] given = guess.getBytes(StandardCharsets.UTF_8);
System.out.println(guess + ": MessageDigest.isEqual = " + MessageDigest.isEqual(expected, given));
}
Ausgabe
000000: verglichene Zeichen 1
400000: verglichene Zeichen 2
480000: verglichene Zeichen 3
481000: verglichene Zeichen 4
481000: MessageDigest.isEqual = false
481937: MessageDigest.isEqual = true

Ein Vergleich in konstanter Zeit sieht sich jedes Byte an, egal was er findet, und die Zeit hängt nicht davon ab, wie viel geraten wurde. Jede Sprache hat ihn fertig: MessageDigest.isEqual. Benutze ihn für alles Geheime, das von außen kommt: Codes, Tokens, Signaturen von JWT, HMACs.

Die Idee

Verschlüsselung: ein fertiger Algorithmus aus einer fertigen Bibliothek

Der Shop speichert die IBAN von Käufern, die Ware zurückgeben. In der Datenbank muss sie verschlüsselt sein, und beim Lesen muss sicher sein, dass niemand sie geändert hat. Beides leistet die authentifizierte Verschlüsselung. Der Standard ist AES-GCM: Es verschlüsselt und fügt einen Tag von 16 Bytes hinzu. Ändert sich auch nur ein Bit, passt der Tag nicht, und die Entschlüsselung bricht mit einem Fehler ab.

AES-GCM steckt in der Standardbibliothek:
static byte[] encrypt(SecretKey key, byte[] nonce, String text) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return cipher.doFinal(text.getBytes(StandardCharsets.UTF_8));
}
static String decrypt(SecretKey key, byte[] nonce, byte[] box) throws Exception {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, nonce));
return new String(cipher.doFinal(box), StandardCharsets.UTF_8);
}
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256);
SecretKey key = generator.generateKey(); // im echten Shop: aus dem Secret Store
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce); // für jede Nachricht ein neuer
byte[] box = encrypt(key, nonce, "BG80 BNBG 9661 1020 3456 78");
System.out.println("Verschlüsselt: " + box.length + " Bytes");
System.out.println("Entschlüsselt: " + decrypt(key, nonce, box));
box[5] ^= 1;
try {
decrypt(key, nonce, box);
} catch (AEADBadTagException e) {
System.out.println("Ein Byte geändert: Die Tag-Prüfung ist fehlgeschlagen, ich lehne ab");
}
Ausgabe
Verschlüsselt: 43 Bytes
Entschlüsselt: BG80 BNBG 9661 1020 3456 78
Ein Byte geändert: Die Tag-Prüfung ist fehlgeschlagen, ich lehne ab
Die verschlüsselte IBAN sind 27 Bytes Text und 16 Bytes Tag. Der Schlüssel besteht aus 32 Zufallsbytes und kommt im echten Shop aus dem Secret Store, wie in der Lektion über Geheimnisse. Die Nonce sind 12 Zufallsbytes, neu für jede Nachricht: Zwei Texte mit einem Schlüssel und einer Nonce verraten die Verbindung zwischen ihnen. Die Nonce ist nicht geheim und wird neben dem verschlüsselten Text gespeichert.

Sieh es dir an

Ein eigenes Verfahren, in wenigen Zeilen gebrochen

Ein Kollege findet AES „zu kompliziert“ und schreibt eine eigene Verschlüsselung: jedes Byte XOR ein Byte eines geheimen Wortes. Es sieht aus wie Rauschen. Aber XOR hebt sich selbst auf, und jede Rechnung beginnt mit denselben Worten:

static byte[] xor(byte[] data, byte[] key) {
byte[] out = new byte[data.length];
for (int i = 0; i < data.length; i++) {
out[i] = (byte) (data[i] ^ key[i % key.length]);
}
return out;
}
byte[] key = "SHOP2026".getBytes(StandardCharsets.UTF_8); // der „geheime“ Schlüssel des Shops
byte[] invoice = xor("RECHNUNG 2026-0142, gesamt 249,90 €".getBytes(StandardCharsets.UTF_8), key);
byte[] iban = xor("BG80 BNBG 9661 1020 3456 78".getBytes(StandardCharsets.UTF_8), key);
// der Angreifer: Jede Rechnung beginnt so
byte[] known = "RECHNUNG ".getBytes(StandardCharsets.UTF_8);
byte[] found = xor(Arrays.copyOf(invoice, 8), Arrays.copyOf(known, 8));
System.out.println("Der Schlüssel ist: " + new String(found, StandardCharsets.UTF_8));
System.out.println("Eine andere Nachricht: " + new String(xor(iban, found), StandardCharsets.UTF_8));
Ausgabe
Der Schlüssel ist: SHOP2026
Eine andere Nachricht: BG80 BNBG 9661 1020 3456 78

Das geheime Wort ergibt sich aus den ersten 8 Bytes einer Rechnung, und damit lässt sich alles lesen, was je verschlüsselt wurde. Es braucht keine Mathematik und keine Milliarden Versuche, nur einen bekannten Textanfang.

Das ist die Regel dieser Lektion: Erfinde keine eigene Kryptografie. Nicht deinen eigenen Algorithmus, nicht dein eigenes Verfahren um einen fertigen Algorithmus, nicht deinen eigenen Generator. Die Leute, die Kryptografie machen, veröffentlichen ihre Verfahren und warten Jahre darauf, dass andere sie brechen. Du benutzt ihre Arbeit über eine Bibliothek.

Genauer, für Neugierige

Was wofür.

  • Passwörter: ein langsamer Hash mit Salt (bcrypt, argon2id), die Lektion über Passwörter. Nie Verschlüsselung: Ein Passwort darf nicht zurücklesbar sein.
  • Eine Nachricht mit einem gemeinsamen Schlüssel signieren: HMAC-SHA256, wie in JWT.
  • Daten verschlüsseln: AES-GCM (oder ChaCha20-Poly1305) mit einem Schlüssel aus einem Secret Store.
  • Tokens und Codes: der kryptografische Generator.
  • Ein Hash für die Integrität einer Datei: SHA-256 oder SHA-512, wie in der Lektion über Abhängigkeiten. MD5 und SHA-1 sind dafür gebrochen.

Bibliotheken auf höherer Ebene. AES-GCM verlangt weiter, dass du nie eine Nonce wiederholst und den Schlüssel bewachst. Bibliotheken wie libsodium (NaCl), Google Tink und Fernet in Pythons cryptography verstecken auch diese Entscheidungen: Du bekommst encrypt(key, text) und decrypt(key, box), ohne Modi und ohne Nonce.

Der Vergleich in konstanter Zeit von innen. Er vergleicht jedes Byte und sammelt die Unterschiede mit XOR und OR: diff |= a[i] ^ b[i], und prüft am Ende diff == 0. Es gibt keinen vorzeitigen Ausstieg. Du schreibst ihn nicht selbst: Der Compiler könnte ihn „optimieren“; die fertigen Funktionen sind so gebaut, dass das nicht passieren kann.

Der Code aus 6 Ziffern. Selbst aus einem kryptografischen Generator sind 6 Ziffern eine Million Möglichkeiten. Deshalb läuft der Code nach Minuten ab und sperrt nach wenigen falschen Versuchen. Ein Token in einem Link hat 32 Bytes, und dort braucht es diese Grenzen nicht gegen das Raten, nur gegen Leaks (zum Beispiel in einem Log).

UUID. UUID.randomUUID() in Java, uuid.uuid4() in Python und crypto.randomUUID() in Node kommen aus einem kryptografischen Generator. Guid.NewGuid() in .NET tut das praktisch auch, aber die Dokumentation verspricht es nicht, nimm für Geheimnisse also RandomNumberGenerator. Eine UUID selbst sind 122 Zufallsbits: gut für eine Id, aber ein Token von 32 Bytes ist klarer.

Übung

Rabattcodes

Löse die Aufgabe in deinem eigenen Editor.

Der Shop will Rabattcodes aus 10 Zeichen, die sich leicht lesen lassen: nur lateinische Großbuchstaben und Ziffern, ohne 0, O, 1 und I, die man verwechselt. Das Alphabet ist ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 Zeichen, 50 Bit pro Code). Schreibe discountCode mit dem kryptografischen Generator, erzeuge 1.000 Codes und prüfe, dass alle aus dem Alphabet stammen und alle verschieden sind:

Ausgabe
Codes: 1000
Jeder hat 10 Zeichen aus dem Alphabet: true
Verschieden: 1000

Weitere Aufgaben

  • Ablauf. Speichere, wann jeder Code erzeugt wurde, und lehne Codes ab, die älter als 15 Minuten sind.
  • Versuche. Sperre den Code nach 5 falschen Versuchen und zähle die Versuche.
  • Kein Modulo. Warum ist ALPHABET[randomByte % 32] in Ordnung, ALPHABET[randomByte % 30] aber nicht? (Tipp: 256 ist durch 32 teilbar, aber nicht durch 30.)

Test

Teste dich

1. Warum ist ein Code aus einem Random mit Seed für das Zurücksetzen eines Passworts gefährlich, auch wenn er 32 Zeichen lang ist?
2. Welcher ist ein kryptografischer Generator?
3. Warum wird das Token mit MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual verglichen und nicht mit ==?
4. Der Shop „verschlüsselt“ Rechnungen mit XOR und einem geheimen Wort aus 8 Buchstaben. Jede Rechnung beginnt mit "RECHNUNG ". Wie schwer ist es, das Wort zu finden?

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