● CWE-362 · CWE-367

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 SELECT und die Verwendung ein späteres UPDATE, 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 UPDATE auf dieselbe Zeile auf die erste Transaktion und wertet seine WHERE-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 und CHECK (balance_cents >= 0) lassen ein verlorenes Rennen laut scheitern, statt doppelt auszugeben. Ein unter einem eindeutigen Index gespeicherter Idempotency-Key erlaubt Clients, Zahlungen zu wiederholen, ohne sie doppelt auszuführen.

Schritt-für-Schritt Angriffsablauf

Schritt 1

Der Angreifer besitzt eine gültige Geschenkkarte

Er hat eine einzige Karte mit festem Betrag und ein Konto, dem sie gutgeschrieben wird.

Schritt 2

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.

Schritt 3

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.

Schritt 4

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

VERWUNDBARE IMPLEMENTIERUNG
# 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]
GEHÄRTETER SICHERHEITS-PATCH
# 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

Quellen

← Zum vollständigen Sicherheitsverzeichnis Alle Anleitungen zu Schwachstellen →