Race Conditions und TOCTOU (CWE-362, CWE-367): Wie parallele Requests Prüfungen aushebeln und wie man das behebt
Code, der einen Wert liest, prüft und dann zurückschreibt, lässt zwei gleichzeitig eintreffende Requests beide die Prüfung bestehen, sodass eine Geschenkkarte zweimal eingelöst wird oder ein Guthaben ins Minus rutscht. Wie das Zeitfenster zwischen Prüfen und Handeln funktioniert, warum Prüfungen in der Anwendung es nicht schließen können und wie ein einziges bedingtes UPDATE ... RETURNING, Zeilensperren und Datenbank-Constraints die Operation atomar machen, mit einem Beispiel in Python und PostgreSQL.
Einfache Erklärung (ELI5)
Ein Kino hat zwei Kassen und nur noch einen freien Platz in Reihe F. Beide Kassierer schauen in derselben Sekunde auf den Saalplan, beide sehen Platz F7 frei und beide verkaufen ihn. Jeder hat vor dem Verkauf geprüft; das Problem ist, dass Prüfen und Verkaufen zwei getrennte Momente waren und der andere dazwischen gehandelt hat. Die Lösung ist ein einziger Kartenblock, von dem beide abreißen müssen: Wer die letzte F7-Karte abreißt, bekommt sie, der andere findet den Block leer. Prüfen und Nehmen werden eine Handlung.
Kernkonzepte & Begriffe
- TOCTOU (Time-of-check to time-of-use)
- Der geprüfte Zustand kann sich ändern, bevor er verwendet wird. In Web-Code ist die Prüfung meist ein
SELECTund die Verwendung ein späteresUPDATE, dazwischen laufen andere Requests. - Race-Fenster und parallele Requests
- Das Fenster umfasst oft nur wenige Millisekunden, doch Angreifer können Dutzende Requests so ausrichten, dass sie gleichzeitig ankommen. PortSwiggers Forschung von 2023 zeigte, dass das Senden der letzten Bytes vieler HTTP/2-Requests in einem Paket sie innerhalb von etwa einer Millisekunde eintreffen lässt.
- Atomares bedingtes Update
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...prüft und ändert die Zeile in einer Anweisung. Nur ein Aufrufer bekommt eine Zeile zurück, die anderen keine.- Zeilensperren unter Read Committed
- In PostgreSQL wartet ein zweites
UPDATEauf dieselbe Zeile auf die erste Transaktion und wertet seineWHERE-Klausel dann gegen die bestätigte Zeile neu aus. Diese erneute Prüfung macht das bedingte Update sicher, ohne die Isolationsstufe anzuheben. - Constraints und Idempotenzschlüssel
UNIQUE-Indizes undCHECK (balance_cents >= 0)lassen ein verlorenes Rennen laut scheitern, statt doppelt auszugeben. Ein unter einem eindeutigen Index gespeicherterIdempotency-Keyerlaubt Clients, Zahlungen zu wiederholen, ohne sie doppelt auszuführen.
Schritt-für-Schritt Angriffsablauf
Der Angreifer besitzt eine gültige Geschenkkarte
Er hat eine einzige Karte mit festem Betrag und ein Konto, dem sie gutgeschrieben wird.
Er sendet viele Einlösungen gleichzeitig
Zwanzig Requests mit demselben Code kommen innerhalb einer Millisekunde an. Zwei parallele Requests mit dem Testcode TEST-0001 zeigen den Fall.
Jeder Request besteht die Prüfung
Jedes SELECT läuft, bevor irgendein Request die Karte als eingelöst markiert hat, also sehen alle redeemed = false.
Das Guthaben wird mehrfach gutgeschrieben
Jeder Request addiert den Kartenwert, bevor das Kennzeichen gesetzt ist. 2015 zeigte Egor Homakov, dass parallele Überweisungen zwischen Starbucks-Geschenkkarten Guthaben aus dem Nichts erzeugen konnten.
Quellcode: Verwundbar vs. Sicher
# gift_cards.py: Prüfung und Verwendung sind getrennte Anweisungen
from .db import pool
# jeder parallele Request kann redeemed = false sehen, bevor einer schreibt
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn:
card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
if card is None or card[2]:
raise ValueError("invalid or used card")
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
return card[1]
# gift_cards.py: Prüfung und Verwendung in einer Anweisung
from .db import pool
# nur ein Aufrufer bekommt eine Zeile; die anderen warten auf die Sperre und scheitern an NOT redeemed
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn, conn.transaction():
card = conn.execute(
"UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
"WHERE code = %s AND NOT redeemed RETURNING balance_cents",
(user_id, code),
).fetchone()
if card is None:
raise ValueError("invalid or used card")
# Gutschrift in derselben Transaktion, beide Änderungen gelten gemeinsam oder gar nicht
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
Checkliste für Engineering & Systemsicherheit
- Lese-Prüf-Schreib-Abfolgen bei Geld, Lagerbestand, Gutscheinen und Kontingenten durch ein einziges bedingtes
UPDATE ... WHERE ... RETURNINGoder durchSELECT ... FOR UPDATEin einer Transaktion ersetzen. - Einmalige Aktionen mit Datenbank-Constraints absichern (
UNIQUE,CHECK (balance_cents >= 0)), damit ein verlorenes Rennen scheitert, statt zweimal zu gelingen. - An Zahlungs- und Einlöse-Endpunkten einen
Idempotency-Keyannehmen und unter einem eindeutigen Index speichern. - Sich nicht auf prozessinterne Sperren oder Flags verlassen, wenn mehr als ein Worker oder Server Requests bearbeitet.
- Einen Nebenläufigkeitstest ergänzen, der 20 parallele Einlösungen einer Testkarte abfeuert und prüft, dass genau eine gelingt.