● CWE-362 · CWE-367

Conditions de concurrence et TOCTOU (CWE-362, CWE-367) : comment des requêtes parallèles contournent les contrôles et comment corriger

Un code qui lit une valeur, la vérifie puis écrit laisse deux requêtes arrivant au même instant passer toutes deux le contrôle : une carte cadeau est encaissée deux fois ou un solde devient négatif. Comment fonctionne la fenêtre entre vérification et action, pourquoi des contrôles applicatifs ne peuvent pas la fermer, et comment un unique UPDATE ... RETURNING conditionnel, les verrous de ligne et les contraintes de la base rendent l'opération atomique, avec un exemple Python et PostgreSQL.

Explication en Langage Simple (ELI5)

Un cinéma a deux caissiers et une seule place libre au rang F. Tous deux regardent le plan de salle à la même seconde, voient la place F7 libre et la vendent. Chaque caissier a vérifié avant de vendre ; le problème est que la vérification et la vente étaient deux moments distincts, et que l'autre caissier a agi entre les deux. La correction est un carnet de billets unique dans lequel les deux doivent détacher : celui qui détache le dernier billet F7 l'obtient, l'autre trouve le carnet vide. Vérifier et prendre deviennent une seule action.

Concepts Clés et Termes

TOCTOU (du moment du contrôle au moment de l'usage)
L'état vérifié peut changer avant d'être utilisé. Dans le code web, le contrôle est souvent un SELECT et l'usage un UPDATE ultérieur, avec d'autres requêtes exécutées entre les deux.
Fenêtre de concurrence et requêtes parallèles
La fenêtre dure souvent quelques millisecondes, mais un attaquant peut aligner des dizaines de requêtes pour qu'elles arrivent ensemble. Les travaux de PortSwigger en 2023 ont montré qu'envoyer les derniers octets de nombreuses requêtes HTTP/2 dans un seul paquet les fait arriver à environ une milliseconde d'écart.
Mise à jour conditionnelle atomique
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ... vérifie et modifie la ligne en une seule instruction. Un seul appelant récupère une ligne ; les autres n'en reçoivent aucune.
Verrous de ligne en Read Committed
Dans PostgreSQL, un second UPDATE sur la même ligne attend la première transaction, puis réévalue sa clause WHERE sur la ligne validée. C'est cette nouvelle vérification qui rend la mise à jour conditionnelle sûre sans relever le niveau d'isolation.
Contraintes et clés d'idempotence
Les index UNIQUE et CHECK (balance_cents >= 0) font échouer bruyamment une course perdue au lieu de dépenser deux fois. Une Idempotency-Key stockée sous un index unique permet aux clients de réessayer un paiement sans le répéter.

Déroulement de l'Attaque Étape par Étape

Étape 1

L'attaquant détient une carte cadeau valide

Il possède une seule carte d'un montant fixe et un compte à créditer.

Étape 2

Il envoie de nombreux encaissements en même temps

Vingt requêtes avec le même code arrivent dans la même milliseconde. Deux requêtes parallèles avec le code de test TEST-0001 illustrent le cas.

Étape 3

Chaque requête passe le contrôle

Chaque SELECT s'exécute avant qu'une requête ait marqué la carte comme encaissée, donc toutes voient redeemed = false.

Étape 4

Le solde est crédité plusieurs fois

Chaque requête ajoute la valeur de la carte avant que le marqueur soit écrit. En 2015, Egor Homakov a montré que des transferts parallèles entre cartes cadeaux Starbucks pouvaient créer du solde à partir de rien.

Code Source : Vulnérable vs Sécurisé

IMPLÉMENTATION VULNÉRABLE
# gift_cards.py : contrôle et usage sont deux instructions séparées
from .db import pool


# chaque requête parallèle peut voir redeemed = false avant que l'une d'elles écrive
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]
PATCH SÉCURISÉ ET ROBUSTE
# gift_cards.py : contrôle et usage en une seule instruction
from .db import pool


# un seul appelant récupère une ligne ; les autres attendent le verrou et voient NOT redeemed échouer
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")
        # crédit dans la même transaction : les deux changements sont validés ensemble ou pas du tout
        conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
        return card[0]

Liste de Contrôle de Sécurité pour l'Ingénierie

Sources

← Parcourir tout l'annuaire sécurité Tous les guides de vulnérabilités →