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
SELECTet l'usage unUPDATEulté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
UPDATEsur la même ligne attend la première transaction, puis réévalue sa clauseWHEREsur 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
UNIQUEetCHECK (balance_cents >= 0)font échouer bruyamment une course perdue au lieu de dépenser deux fois. UneIdempotency-Keystocké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
L'attaquant détient une carte cadeau valide
Il possède une seule carte d'un montant fixe et un compte à créditer.
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.
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.
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é
# 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]
# 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
- Remplacez les séquences lecture-contrôle-écriture sur l'argent, les stocks, les coupons et les quotas par un seul
UPDATE ... WHERE ... RETURNINGconditionnel, ou parSELECT ... FOR UPDATEdans une transaction. - Adossez les actions à usage unique à des contraintes de base (
UNIQUE,CHECK (balance_cents >= 0)) pour qu'une course perdue échoue au lieu de réussir deux fois. - Acceptez une
Idempotency-Keysur les endpoints de paiement et d'encaissement et stockez-la sous un index unique. - Ne comptez pas sur des verrous ou des drapeaux en mémoire du processus quand plusieurs workers ou serveurs traitent les requêtes.
- Ajoutez un test de concurrence qui lance 20 encaissements parallèles d'une même carte de test et vérifie qu'un seul réussit.