Zum Inhalt springen
CodeAuditAgent
Alle Artikel

Race Conditions und TOCTOU-Fehler in Webanwendungen

Wie Double-Spends, Gutschein-Mehrfachnutzung und Limit-Umgehungen entstehen und wie Constraints, atomare Updates, Locks und Idempotenzschlüssel sie beheben.

· 7 Min. Lesezeit · Lina Source LLC

Der meiste Web-Code wird so geschrieben, als kämen Anfragen nacheinander an. Guthaben lesen, prüfen, ob es hoch genug ist, abziehen, speichern. Von Hand getestet funktioniert das jedes Mal. Zwanzigmal parallel gesendet, kann dasselbe Geld zwanzigmal ausgegeben werden.

Diese Fehler sind Race Conditions (CWE-362), und die häufigste Form ist Time-of-Check to Time-of-Use, kurz TOCTOU (CWE-367): Die Anwendung prüft eine Bedingung, handelt danach, und die Bedingung ändert sich dazwischen. Im Review sind sie leicht zu übersehen, weil jede Zeile für sich korrekt aussieht. Der Fehler liegt in der Lücke zwischen zwei Zeilen.

Warum Node.js nicht immun ist

Eine verbreitete Annahme ist, dass Single-Threaded-Laufzeiten keine Race Conditions haben können. JavaScript führt einen Callback nach dem anderen aus, aber jedes await ist ein Punkt, an dem eine andere Anfrage laufen kann. Ihre Datenbank wird von allen Instanzen, allen Workern und allen Anfragen gemeinsam genutzt. Zwischen dem SELECT und dem UPDATE kann alles passieren.

// Verwundbar: prüfen und dann handeln über zwei awaits hinweg
export async function withdraw(userId: string, amount: number) {
  const account = await db.account.findUnique({ where: { userId } });
  if (!account || account.balance < amount) {
    throw new Error('Insufficient funds');
  }
  // Eine andere Anfrage kann dieselbe Prüfung bestehen, bevor diese Zeile läuft
  await db.account.update({
    where: { userId },
    data: { balance: account.balance - amount },
  });
}

Zwei Anfragen lesen ein Guthaben von 100, beide bestehen die Prüfung für eine Abhebung von 100, und beide schreiben 0. Der Nutzer hat 200 aus einem Konto geholt, auf dem 100 lagen. Weil der zweite Schreibvorgang den ersten mit einem aus veralteten Daten berechneten Wert überschreibt, zeigen die Logs nichts Auffälliges. ORMs ändern daran nichts. Einen Datensatz in ein Objekt zu laden, das Objekt zu ändern und es zu speichern, ist dasselbe Read-Modify-Write-Muster mit derselben Lücke.

Wo Races auftreten

  • Guthaben, Credits und Wallets: dieselben Mittel doppelt ausgeben.
  • Gutscheine und Geschenkkarten: einen Einmalcode mehrfach einlösen.
  • Plan- und Nutzungslimits: mehr Projekte, Plätze oder API-Aufrufe anlegen, als der Plan erlaubt.
  • Registrierung und Einladungen: zwei Konten mit derselben E-Mail-Adresse anlegen oder eine Einladung zweimal annehmen.
  • Abstimmungen, Likes und Bewertungen: einen Nutzer mehr als einmal zählen.
  • Zahlungs- und Bestellabläufe: eine Bestellung doppelt ausführen, wenn ein Webhook und ein Redirect gleichzeitig eintreffen.

Das auszunutzen ist nicht schwer. Ein Schwung gleichzeitig gesendeter Anfragen mit Promise.all, curl oder einem Proxy-Werkzeug genügt, um Zeitfenster von wenigen Millisekunden zu treffen, und Techniken wie der Single-Packet-Angriff lassen die Anfragen nahezu gleichzeitig am Server ankommen. Gehen Sie davon aus: Wenn es eine Race Condition gibt, kann jemand sie gewinnen.

Fix 1: die Datenbank die Regel durchsetzen lassen

Die stärksten Lösungen verlagern die Regel in die Datenbank, wo gleichzeitige Anfragen für Sie serialisiert werden. Zwei Werkzeuge decken die meisten Fälle ab: Unique Constraints und atomare bedingte Updates.

-- Einmal pro Nutzer: Das zweite Insert schlägt fehl, unabhängig vom Timing
CREATE UNIQUE INDEX coupon_redemptions_once
  ON coupon_redemptions (coupon_id, user_id);

-- Globale Nutzungsobergrenze: prüfen und erhöhen in einer Anweisung
UPDATE coupons
   SET uses = uses + 1
 WHERE id = $1
   AND uses < max_uses
RETURNING id;

-- Guthaben: Die Bedingung wird gegen die aktuelle Zeile ausgewertet
UPDATE accounts
   SET balance = balance - $1
 WHERE user_id = $2
   AND balance >= $1
RETURNING balance;

-- Doppelt gesichert: Das Guthaben kann nie negativ werden
ALTER TABLE accounts
  ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

Das bedingte UPDATE funktioniert, weil die Datenbank die Zeile sperrt, während sie die WHERE-Klausel auswertet. Konkurrieren zwei Anfragen, prüft die zweite die Bedingung erneut gegen die Zeile, die die erste committet hat. Kommt keine Zeile zurück, ist die Bedingung fehlgeschlagen, und Sie geben einen Fehler zurück. Es gibt keine ausnutzbare Lücke, weil Prüfung und Schreibvorgang dieselbe Anweisung sind.

Behandeln Sie die Unique-Verletzung im Anwendungscode als normalen Ausgang. In PostgreSQL trifft sie als Fehlercode 23505 ein; bilden Sie sie auf eine klare Meldung wie „Gutschein bereits eingelöst“ ab statt auf einen 500er.

Die meisten ORMs können das bedingte Update ausdrücken. Bei Prisma gibt updateMany mit der Guthabenbedingung in der where-Klausel eine Anzahl zurück, und eine Anzahl von null bedeutet, dass die Prüfung fehlgeschlagen ist. Ein findUnique gefolgt von einem separaten update gibt Ihnen dieselbe Garantie nicht, so sorgfältig die Prüfung auch geschrieben ist.

Fix 2: die Zeile mit SELECT ... FOR UPDATE sperren

Manchmal braucht die Entscheidung mehr als eine Anweisung: mehrere Felder lesen, eine Preisfunktion aufrufen, in zwei Tabellen schreiben. Nehmen Sie dann innerhalb einer Transaktion eine Zeilensperre. SELECT ... FOR UPDATE lässt jede andere Transaktion, die dieselbe Zeile sperren will, warten, bis Ihre committet oder zurückgerollt wird.

Eine Transaktion allein genügt nicht. Die Standard-Isolationsstufe von PostgreSQL ist READ COMMITTED, und auf dieser Stufe ändert es nichts, den verwundbaren Code von oben in BEGIN und COMMIT zu klammern: Beide Transaktionen lesen dasselbe Guthaben, beide bestehen die Prüfung, und beide Schreibvorgänge gelingen. Erst die Sperre zwingt die zweite Transaktion zu warten und danach den committeten Wert zu lesen.

import { Pool } from 'pg';

const pool = new Pool();

export async function purchase(userId: string, itemId: string) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(
      'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
      [userId],
    );
    const item = await client.query(
      'SELECT price FROM items WHERE id = $1',
      [itemId],
    );
    if (rows.length === 0 || item.rows.length === 0) {
      throw new Error('Not found');
    }

    const price = Number(item.rows[0].price);
    if (Number(rows[0].balance) < price) throw new Error('Insufficient funds');

    await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
      [price, userId],
    );
    await client.query(
      'INSERT INTO purchases (user_id, item_id, price) VALUES ($1, $2, $3)',
      [userId, itemId, price],
    );

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Zwei Details sind wichtig. Die Sperre hilft nur, wenn jeder Codepfad, der das Guthaben ändert, sie ebenfalls nimmt; ein Pfad, der ohne Sperre aktualisiert, öffnet die Race Condition wieder. Und die gesamte Abfolge muss denselben Client verwenden: BEGIN auf einer Pool-Verbindung und das SELECT auf einer anderen auszuführen ergibt überhaupt keine Transaktion. ORMs bieten dasselbe Muster über interaktive Transaktionen oder Raw Queries.

Fix 3: Advisory Locks für Regeln über mehrere Zeilen

Zeilensperren helfen nicht, wenn sich die Regel auf Zeilen bezieht, die es noch gar nicht gibt, etwa „ein Nutzer im kostenlosen Plan darf höchstens drei Projekte haben“. Zwei Anfragen können jeweils zwei Projekte zählen und jeweils ein drittes einfügen. Mit Advisory Locks von PostgreSQL können Sie einen beliebigen Schlüssel, etwa die Benutzer-ID, für die Dauer einer Transaktion sperren.

Rufen Sie innerhalb der Transaktion pg_advisory_xact_lock mit einem aus dem Nutzer abgeleiteten Schlüssel auf, zählen Sie dann und fügen Sie ein. Die Funktion nimmt einen 64-Bit-Ganzzahlschlüssel entgegen; leiten Sie also eine stabile Ganzzahl aus der Benutzer-ID ab, zum Beispiel mit hashtext. Eine gelegentliche Kollision zwischen unabhängigen Nutzern kostet nur etwas Wartezeit, niemals Korrektheit. Die Sperre wird beim Commit oder Rollback automatisch freigegeben. Eine weitere Möglichkeit ist die Isolationsstufe SERIALIZABLE, bei der PostgreSQL die konfliktierenden Transaktionen erkennt und eine davon mit Fehler 40001 abbricht; das funktioniert gut, sofern Ihr Code abgebrochene Transaktionen wiederholt.

Was nicht funktioniert, ist ein Mutex im Arbeitsspeicher oder eine JavaScript-Map von Locks. Das deckt einen Prozess ab. Sobald Sie zwei Instanzen, zwei Serverless-Funktionen oder einen Hintergrund-Worker betreiben, ist die Sperre wirkungslos.

Fix 4: Idempotenzschlüssel für Retries und Doppelklicks

Manche Duplikate sind keine Angriffe: Ein Nutzer klickt doppelt, ein Mobile-Client wiederholt die Anfrage nach einem Timeout, ein Zahlungsanbieter stellt einen Webhook erneut zu. Ein Idempotenzschlüssel macht aus „tu das“ ein „tu das genau einmal“. Der Client erzeugt einen Schlüssel pro logischem Vorgang, und der Server speichert ihn mit einem Unique Constraint, bevor er die Arbeit ausführt.

-- Schema
CREATE TABLE idempotency_keys (
  key         text PRIMARY KEY,
  user_id     uuid NOT NULL,
  response    jsonb,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- Zuerst den Schlüssel beanspruchen; null Zeilen heißt, eine andere Anfrage hält ihn
INSERT INTO idempotency_keys (key, user_id)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
RETURNING key;

Gibt das Insert eine Zeile zurück, führen Sie den Vorgang in derselben Transaktion aus und speichern die Antwort. Gibt es nichts zurück, schlagen Sie die gespeicherte Antwort nach und geben sie zurück, oder antworten Sie mit 409, wenn die erste Anfrage noch läuft. Binden Sie Schlüssel an den Nutzer, damit ein Nutzer den Schlüssel eines anderen weder wiederholen noch blockieren kann, und lassen Sie sie nach einem sinnvollen Zeitfenster verfallen. Bei Webhooks ist die Event-ID des Anbieters der natürliche Schlüssel. Erwägen Sie, einen Hash des Request-Bodys zusammen mit dem Schlüssel zu speichern und die Wiederverwendung eines Schlüssels mit anderem Body abzulehnen, damit ein Client-Fehler nicht stillschweigend das Ergebnis eines anderen Vorgangs erhält.

Races im eigenen Code finden

  • Suchen Sie nach einem Lesezugriff, auf den ein Schreibzugriff auf dieselben Daten folgt, mit einem await dazwischen.
  • Suchen Sie nach Zählungen, die vor einem Insert mit Limits verglichen werden.
  • Suchen Sie nach „suchen, dann anlegen, falls nicht vorhanden“ ohne dahinterliegendes Unique Constraint.
  • Prüfen Sie, dass jeder Schreibzugriff auf einen sensiblen Wert über denselben gesperrten oder atomaren Pfad läuft.
  • Schreiben Sie einen Test, der dieselbe Anfrage gleichzeitig abfeuert und prüft, dass die Invariante weiterhin gilt.

Der nebenläufige Test ist der überzeugendste Beleg. Führen Sie den Vorgang zwanzigmal mit Promise.all gegen eine echte Datenbank aus und prüfen Sie anschließend das Guthaben, die Anzahl der Einlösungen oder die Anzahl der Zeilen. Schlägt er vor der Korrektur fehl und besteht danach, ist die Race Condition geschlossen. Lassen Sie ihn mehr als einmal laufen; eine Race Condition, die nur in einem von fünf Läufen auffällt, ist trotzdem eine.

Race Conditions sind die Art von Fehler, die ein KI-Reviewer aufdecken kann, indem er den gesamten Ablauf liest statt einer einzelnen Zeile. Wenn CodeAuditAgent eine meldet, enthält der Befund die CWE, die zitierte Prüfung und den zitierten Schreibvorgang, ein Exploit-Szenario mit den parallelen Anfragen sowie einen Patch, meist einen der obigen Fixes. Die allgemeine Regel bleibt in jedem Fall dieselbe: Lassen Sie die Datenbank entscheiden, denn sie ist die eine Komponente, die jede Anfrage sieht.