Race conditions (CWE-362) : exploiter la fenêtre TOCTOU entre vérification et action — réutilisation de coupons, manipulation de solde et contournement de limites de taux.
TL;DR
SELECT FOR UPDATE) + clés d'idempotence Redis SETNXUne race condition (CWE-362) survient quand le résultat de sécurité d'un système dépend du timing ou de l'entrelacement d'événements concurrents non coordonnés. En sécurité web, cela se manifeste systématiquement comme une séquence TOCTOU (Time-of-Check to Time-of-Use, CWE-367) : l'application lit une valeur pour prendre une décision de sécurité, puis agit sur cette décision dans une étape non atomique séparée. Un attaquant envoie plusieurs requêtes concurrentes pour exécuter l'étape "use" avant qu'un seul changement d'état n'ait été commis en base.
La classe de vulnérabilité a été redéfinie par James Kettle à Black Hat USA 2023 ("Smashing the State Machine"). Avant 2023, les race conditions web étaient considérées impraticables à exploiter de manière fiable à distance à cause du jitter réseau de 15-30ms. L'attaque single-packet — envoi de toutes les requêtes HTTP/2 dans un seul paquet TCP via synchronisation du dernier octet — a éliminé cette barrière. GMO Flatt Security a étendu cela à CODE BLUE 2024 avec le first sequence sync, atteignant jusqu'à 10 000 requêtes concurrentes en 166ms via fragmentation IP.
Les race conditions relèvent de OWASP A04:2021 (Insecure Design) car la cause racine est un défaut de conception de machine à états : l'application ne garantit jamais que la séquence vérification-et-action est atomique. Contrairement aux failles d'injection, les race conditions ne produisent aucune entrée malformée — l'attaque consiste entièrement en requêtes légitimes, bien formées, juste envoyées de manière concurrente.
Au cœur de toute race condition web se trouve une séquence vérification-et-action non atomique. La base de données détient l'état autoritatif, mais l'application le lit en mémoire, raisonne dessus, puis réécrit — créant une fenêtre où des lectures concurrentes voient toutes l'état pré-mise-à-jour.
L'attaque se déroule en quatre étapes :
Un PoC fonctionnel avec httpx :
import asyncio
import httpx
async def race_coupon(url: str, headers: dict, body: dict, n: int = 20):
"""Race un endpoint de rédemption de coupon N fois en concurrence."""
async with httpx.AsyncClient(http2=True, verify=False, timeout=30.0) as client:
# Réchauffement de connexion — handshake TLS + HTTP/2 SETTINGS
await client.get(url.rsplit("/", 1)[0] + "/", headers=headers)
# N requêtes sur la même connexion HTTP/2
tasks = [client.post(url, headers=headers, json=body) for _ in range(n)]
responses = await asyncio.gather(*tasks, return_exceptions=True)
success = [r for r in responses if not isinstance(r, Exception) and r.status_code == 200]
return {"total": n, "success_count": len(success), "race_confirmee": len(success) > 1}| Variante | CWE | Technique | Impact | Bounty typique |
|---|---|---|---|---|
| TOCTOU (fichier/DB) | CWE-367 | Check-and-use concurrents sur read-modify-write non atomique | Élévation de privilèges, RCE | 1K–50K$ |
| Dépassement de limite | CWE-362 | N requêtes concurrentes dépassant le compteur avant commit | Multi-rédemption coupon, crédits gratuits | 500–10K$ |
| Vidange de solde | CWE-362 | N retraits concurrents avant tout UPDATE de solde | Solde négatif, vol de fonds | 5K–50K$ |
| Multi-endpoint | CWE-841 | Race entre deux endpoints dépendants | Bypass d'auth, corruption d'état | 2K–20K$ |
| Construction partielle (JIT) | CWE-362 | Exploitation de la fenêtre d'initialisation | ATO, élévation de privilèges | 2K–15K$ |
| OAuth/JWT Race | CWE-362 | Double-rédemption de code, rotation de token | Accès persistant après révocation | 2K–20K$ |
| Upload de fichier | CWE-367 | Race upload-validation | Webshell, RCE (Tomcat CVE-2024-50379) | 1K–10K$ |
| CDN Cache Race | CWE-362 | Requête concurrente pendant revalidation | Cache poisoning (CVE-2025-32421) | 1K–5K$ |
Le dépassement de limite (single-endpoint) est la race la plus courante en bug bounty. L'application vérifie un compteur (uses < max_uses, balance >= amount, already_voted == false), passe la vérification, puis incrémente/décrémente dans une opération séparée.
La JIT inconsistency est la catégorie la plus subtile. Kettle a documenté la fenêtre de construction partielle de Rails+Devise : une ligne utilisateur est insérée avec confirmation_token=NULL avant qu'un job asynchrone peuple le token, permettant une confirmation avec un token vide pendant cette fenêtre.
CVE-2024-30088 — Noyau Windows (CISA KEV, juin 2024, CVSS 7.0)
Un TOCTOU dans NtQueryInformationToken permettait une élévation de privilèges locale vers SYSTEM. Ajouté au catalogue CISA KEV en juin 2024 avec exploitation confirmée.
CVE-2025-22224 — VMware ESXi/Workstation (CISA KEV, mars 2025, CVSS 9.3) Un débordement de heap TOCTOU dans le sous-système VMCI permettait à un processus VM invité d'échapper vers l'hôte hyperviseur. Exploité dans des campagnes actives ciblant les infrastructures cloud avant le correctif de mars 2025.
CVE-2024-50379 + CVE-2024-56337 — Apache Tomcat (CVSS 9.8)
TOCTOU de compilation JSP sur les systèmes de fichiers insensibles à la casse (Windows, macOS). Deux uploads concurrents au même chemin logique — FILE.JSP et file.txt — créent une race où la vérification d'extension voit .txt mais l'étape de compilation exécute le bytecode JSP. GET /file.jsp obtient RCE avec les privilèges du service Tomcat. Corrigé dans Tomcat 11.0.2 / 10.1.34 / 9.0.98.
CVE-2024-58248 — nopCommerce Gift Card Double-Spend (CVSS 7.5)
Des requêtes de rédemption de carte cadeau concurrentes dans nopCommerce < 4.80.0 passaient la vérification de solde avant tout UPDATE. Une seule carte cadeau de 100€ pouvait être rachetée N fois en parallèle.
CVE-2024-45300 — alf.io Promo Code Race (CVSS 7.5) La plateforme de billetterie alf.io (≤ 2.0-M4-2402) permettait des applications concurrentes de code promo qui contournaient les limites d'utilisation.
HackerOne #55140 — OAuth 2 Race (Internet Bug Bounty, 2 500$) Des requêtes d'échange de code d'autorisation concurrentes produisaient deux paires distinctes access_token/refresh_token depuis un seul code. L'attaquant conservait une paire de tokens valide même après la révocation totale par la victime.
InnoGames Email Activation Race (2 000$) Des requêtes d'activation d'email concurrentes incrémentaient un compteur de monnaie virtuelle N fois au lieu d'une fois. Une seule activation en 20 requêtes concurrentes accordait 20× le bonus de bienvenue. 137 votes HackerOne.
/redeem, /apply, /withdraw, /transfer, /confirm, /vote, /refresh, /oauth/token.200 OK là où un seul est attendu = signal préliminaire.Turbo Intruder avec Engine.BURP2 reste le standard pour la confirmation PoC :
# Turbo Intruder — attaque par gate single-packet
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2 # Single-packet HTTP/2
)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1') # Tir simultané des 20
def handleResponse(req, interesting):
table.add(req)Signaux clés :
BreachVex détecte les race conditions via plusieurs techniques complémentaires : correspondance structurelle de patterns d'URL transactionnels, bursts HTTP/2 concurrents avec analyse différentielle de réponse, et preuve read-race-read avant toute confirmation. Les endpoints financiers déclenchent toujours une validation dual-judge avant marquage Critical.
La défense la plus fiable élimine la fenêtre check-et-use au niveau base de données :
-- VULNÉRABLE : SELECT séparé puis UPDATE — fenêtre de race entre les deux
SELECT used FROM coupons WHERE code = 'SAVE25';
-- ...logique applicative...
UPDATE coupons SET used = TRUE WHERE code = 'SAVE25';
-- CORRIGÉ : UPDATE conditionnel atomique
UPDATE coupons
SET used = TRUE, redeemed_by = $1, redeemed_at = NOW()
WHERE code = $2 AND used = FALSE
RETURNING id, amount;
-- 0 lignes retournées = déjà utilisé — aucun SELECT séparé nécessairePour les opérations de solde, SELECT FOR UPDATE acquiert un verrou en écriture au niveau ligne :
BEGIN;
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- Les transactions concurrentes BLOQUENT ici jusqu'au commit
UPDATE accounts SET balance = balance - $2 WHERE id = $1 AND balance >= $2;
COMMIT;Pour les flux cross-service où les transactions DB ne peuvent pas couvrir les frontières de service :
import redis.asyncio as redis
import uuid
async def redeem_coupon_safe(code: str, user_id: int, r: redis.Redis):
lock_key = f"coupon_lock:{code}"
lock_id = str(uuid.uuid4())
# Atomique : SET key seulement si absente (NX) avec expiry 10s
acquired = await r.set(lock_key, lock_id, nx=True, ex=10)
if not acquired:
raise ValueError("Rédemption concurrente en cours — réessayez")
try:
coupon = await db.get_coupon(code)
if coupon.used:
raise ValueError("Coupon déjà utilisé")
await db.mark_coupon_used(code, user_id)
finally:
# Lua : supprimer seulement si on possède le verrou
await r.eval(
"if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end",
1, lock_key, lock_id
)Pour les endpoints API, appliquer un header Idempotency-Key :
@router.post("/transfer")
async def transfer(body: TransferRequest, idempotency_key: str = Header(...)):
cached_key = f"idem:{idempotency_key}"
if existing := await redis.get(cached_key):
return existing
result = await do_transfer(body)
await redis.setex(cached_key, 86400, result.model_dump_json())
return resultAugmenter le niveau d'isolation PostgreSQL à SERIALIZABLE pour les opérations financières. Éviter le pattern read-modify-write ORM :
# VULNÉRABLE — lit en mémoire, modifie, sauvegarde
account = db.query(Account).filter_by(id=account_id).first()
account.balance -= amount
db.commit()
# CORRIGÉ — expression atomique évaluée en DB
from sqlalchemy import update
db.execute(
update(Account)
.where(Account.id == account_id, Account.balance >= amount)
.values(balance=Account.balance - amount)
)
db.commit()Limiter les streams HTTP/2 concurrents (http2_max_concurrent_streams 10 dans nginx) réduit la fenêtre d'attaque single-packet mais ne l'élimine pas. Un attaquant peut encore atteindre 10 requêtes concurrentes — suffisant pour la plupart des attaques de dépassement de limite. Combiner les limites de streams avec des défenses atomiques au niveau logique métier.
Une race condition survient quand le résultat d'une opération critique dépend du timing d'événements concurrents non coordonnés. Le pattern classique est TOCTOU (Time-of-Check to Time-of-Use) : l'application lit une valeur pour prendre une décision, puis agit — mais l'attaquant envoie plusieurs requêtes simultanées pour exécuter l'action avant que le changement d'état de la première requête soit commis en base. Résultat : un contrôle de sécurité passe N fois pour une ressource à usage unique.
L'attaque single-packet (PortSwigger Research, Black Hat USA 2023) envoie 20 à 30 requêtes HTTP/2 dans un seul paquet TCP en retenant le dernier octet de chaque DATA frame HTTP/2 puis en les relâchant simultanément. L'OS coalesce les derniers octets via l'algorithme de Nagle, livrant toutes les requêtes au serveur dans une fenêtre inférieure à 1ms. Cela élimine le jitter réseau intercontinental (15-30ms), rendant les races distantes aussi fiables que les races locales.
Le first sequence sync (GMO Flatt Security, CODE BLUE 2024) étend l'attaque single-packet au-delà de la limite HTTP/2 Window Size de 65 535 octets. En exploitant la fragmentation IP et le réordonnancement des numéros de séquence TCP, il atteint environ 10 000 requêtes concurrentes en 166ms. Cette technique permet d'exploiter des races nécessitant des milliers de participants simultanés, comme le multi-claim de faucet crypto.
Trois figurent dans le catalogue CISA KEV (exploités activement) : CVE-2024-30088 (noyau Windows TOCTOU, CVSS 7.0, juin 2024), CVE-2025-22224 (VMware ESXi TOCTOU VM escape, CVSS 9.3, mars 2025), CVE-2025-38352 (noyau Linux POSIX timer TOCTOU, CVSS 7.4, septembre 2025). Web : CVE-2024-50379 (Apache Tomcat JSP TOCTOU RCE, CVSS 9.8), CVE-2024-58248 (nopCommerce gift card, CVSS 7.5), CVE-2024-45300 (alf.io promo code, CVSS 7.5), CVE-2025-32421 (Next.js cache poisoning race).
SELECT FOR UPDATE acquiert un verrou en écriture au niveau de la ligne dans une transaction. Toute transaction concurrente tentant de SELECT ou UPDATE la même ligne est bloquée jusqu'à ce que la première transaction commite. Cela rend la séquence vérification-et-action atomique au niveau base de données : la deuxième requête ne peut pas lire l'état pré-mise-à-jour car elle est bloquée par le verrou.
Une clé d'idempotence est un UUID fourni par le client que le serveur utilise pour dédupliquer les requêtes. Lors de la première réception, le serveur traite la requête et met en cache le résultat (ex. Redis SETNX avec TTL 24h). Pour les requêtes suivantes avec la même clé, le serveur retourne le résultat mis en cache sans retraiter. Cela prévient les charges en double, les redemptions doubles et la création de comptes en double.
Les rate limiters incrémentent un compteur par utilisateur après le traitement de chaque requête. Avec l'attaque single-packet HTTP/2, toutes les N requêtes arrivent avant qu'un compteur soit incrémenté — elles lisent toutes count=0 et passent le contrôle. Cela a été confirmé sur Cloudflare et AWS WAF en 2024. La correction requiert une déduplication atomique au niveau logique métier, pas seulement au niveau gateway.
OWASP Top 10 2021 A04:2021 (Insecure Design) couvre les race conditions sous les failles de conception de machine à états. OWASP ASVS V11.1.6 exige explicitement le test TOCTOU dans la section Business Logic. WSTG-BUSL-04 (Process Timing) et WSTG-BUSL-05 (Number of Times a Function Can Be Used) fournissent les spécifications de cas de test.
1. Identifier un endpoint stateful avec une garde (coupon, retrait, token OTP). 2. Dans Burp Repeater, créer un groupe de 20 requêtes identiques. 3. Sélectionner 'Send group in parallel (single-packet attack)'. 4. Chercher plusieurs 200 OK là où un seul est attendu. 5. Confirmer avec la preuve read-race-read : GET état avant, burst, GET état après. Si le compteur s'est incrémenté de N au lieu de 1, la race est confirmée.
Turbo Intruder (extension Burp) avec Engine.BURP2 est l'outil standard pour les PoC single-packet HTTP/2. Burp Repeater 'Send group in parallel' (depuis Burp 2023.10) offre une UX simplifiée. h2spacex (Python + Scapy) donne un contrôle bas niveau des frames HTTP/2. httpx avec http2=True et asyncio.gather gère les tests concurrents simples.
Oui. Le TOCTOU exploite une garde sur une ressource existante et complètement initialisée. La JIT inconsistency (Kettle 2023) exploite une fenêtre pendant laquelle un objet existe en base mais n'est pas encore complètement initialisé. Exemple : Rails+Devise crée une ligne utilisateur avec confirmation_token=NULL avant qu'un job asynchrone peuple le token. L'attaquant race une confirmation avec un token vide pendant cette fenêtre.
Oui. L'attaque classique de vidange de solde envoie N retraits concurrents. Chacun lit le solde avant qu'un UPDATE commite — tous passent la vérification solde >= montant. La recherche Doyensec 'Database Race Conditions in AppSec' (2024) a trouvé ce pattern dans plusieurs plateformes fintech utilisant Hibernate avec l'isolation READ COMMITTED par défaut. La correction : SELECT FOR UPDATE ou niveau d'isolation SERIALIZABLE.
Le réchauffement envoie 1 à 3 requêtes innocentes (GET /) sur la même connexion HTTP/2 avant le burst. Cela complète le handshake TLS, l'échange HTTP/2 SETTINGS et réchauffe les tables de routage load-balancer. Sans réchauffement, la variance de cold-start (50 à 500ms pour Lambda ou Cloud Run) sérialise les requêtes accidentellement.
Une race multi-endpoint exploite une transition d'état qui s'étend sur deux endpoints. Exemple : /confirm marque l'email vérifié avant que /login vérifie ce statut — racer ces deux requêtes permet de se connecter avec un email non vérifié. La machine à états est insécurisée car aucun endpoint pris individuellement n'est atomique par rapport à l'autre.
Partiellement. PHP avec sessions fichier sérialise les requêtes par PHPSESSID via flock(). Cela élimine les races intra-session. Cependant, cette protection ne s'applique pas aux applications PHP JWT-based, aux backends utilisant Redis ou Memcached sans verrouillage explicite, ni aux races multi-endpoint sur des sessions distinctes.
Le pattern anti-read-modify-write est le coupable : lire une ligne en mémoire, modifier un champ, sauvegarder. Django obj.field += 1; obj.save(), Rails User.find(id).update(balance: b - 100), Mongoose findOne().then(save). Toutes ces opérations ont une fenêtre de race entre le read et le commit. La correction : expressions F() de Django, update_all de Rails, findOneAndUpdate de Mongoose — évaluées atomiquement en base.