Race condition limite (CWE-362) : requêtes concurrentes contournent les rate limits, vérifications de stock ou soldes de portefeuille avant le décrément en base.
TL;DR
POST /vote concurrents voient tous has_voted=false avant qu'un flag commiteUPDATE WHERE count < limite atomique ou INSERT ON CONFLICT DO NOTHING — jamais SELECT-puis-UPDATEUne race condition de dépassement de limite exploite tout système qui applique une limite numérique ou booléenne via une séquence check-and-increment non atomique. L'application vérifie que le compteur actuel est sous la limite (SELECT), confirme que la condition passe, puis incrémente le compteur (UPDATE) dans une opération séparée. Les requêtes concurrentes passent toutes la vérification avant qu'un incrément ne commite — contournant la limite.
C'est la sous-catégorie la plus large des race conditions web, couvrant : rate limits (N requêtes par minute), caps de votes/likes (1 vote par utilisateur par post), limites d'inventaire (quantité en stock), quotas d'abonnement (appels API par mois), limites d'essai gratuit (1 essai par email), et contrôles anti-automation (1 CAPTCHA par création de compte). Le pattern est uniforme : lire l'état actuel → vérifier par rapport à la limite → agir → mettre à jour l'état.
L'attaque single-packet de Kettle (Black Hat 2023) a rendu les races de dépassement de limite exploitables de manière fiable : avant la synchronisation du dernier octet HTTP/2, le jitter réseau de 15-30ms entre requêtes concurrentes causait souvent la sérialisation par le serveur.
Toutes les races de dépassement de limite partagent le même pattern non atomique :
1. LECTURE : SELECT count FROM limits WHERE user=X AND type='vote' → count=0
2. VÉRIF : if count < max_votes (0 < 1 = VRAI)
3. ACTION : Traiter le vote/action
4. MISE-À-JOUR : UPDATE limits SET count = count + 1 WHERE user=X AND type='vote'# PoC d'inflation de votes — N votes concurrents depuis le même utilisateur
import asyncio, httpx
async def vote_inflate(vote_url: str, post_id: str, auth_headers: dict, n: int = 50):
async with httpx.AsyncClient(http2=True, verify=False) as client:
await client.get(vote_url.rsplit("/", 2)[0] + "/") # Réchauffement
tasks = [
client.post(vote_url.format(id=post_id), headers=auth_headers, json={"value": 1})
for _ in range(n)
]
responses = await asyncio.gather(*tasks, return_exceptions=True)
successes = sum(1 for r in responses if not isinstance(r, Exception)
and r.status_code in (200, 201))
return {"votes_enregistres": successes, "race_confirmee": successes > 1}| Variante | Limite Contournée | Impact | Exemple Réel |
|---|---|---|---|
| Bypass rate limit | Requêtes par minute/seconde | Brute force activé, défaite CAPTCHA | Bypass AWS WAF (2024) |
| Inflation votes/likes | 1 vote par utilisateur par post | Manipulation de sondage, bombing de reviews | Plateformes type Reddit |
| Multi-claim faucet/airdrop | 1 claim par adresse par période | Tokens gratuits × N | Cosmos starport (5K$) |
| Extension essai gratuit | 1 essai par email | Accès gratuit illimité | Plateformes SaaS |
| Réutilisation token CAPTCHA | 1 résolution par inscription | Création de comptes en masse | Anti-automation signup |
| Sur-achat de stock | Quantité disponible | Inventaire négatif, survente | Plateformes e-commerce |
| Bypass quota API | Limite d'appels mensuelle | Dépasser les niveaux gratuitement | Plateformes monétisation API |
Le bypass de rate limit est la variante la plus significative techniquement. Les rate limiters Cloudflare et AWS WAF évaluent le compteur par connexion avant incrément — quand 20 requêtes arrivent dans un seul paquet TCP, toutes 20 sont validées contre le compteur pré-incrément et toutes passent.
Le multi-claim de faucet est la variante Web3 la plus impactante financièrement. Le faucet Cosmos/starport a accordé 5 000$ pour une race permettant 30 claims concurrents depuis une adresse, contournant la limite 1-claim-par-jour. Dans un contexte de gouvernance, N claims de faucet activent N parts de vote.
La race CAPTCHA active la création de comptes en masse depuis un seul CAPTCHA résolu. Si le token est validé (SELECT valid=true) puis marqué utilisé (UPDATE valid=false) en deux étapes, toutes les N requêtes d'inscription concurrentes avec le même token passent la validation avant tout marquage.
Cosmos/starport Faucet Race (HackerOne, 5 000$) Le faucet testnet Cosmos appliquait "1 claim par adresse par 24h" via SELECT-puis-INSERT. Une seule adresse envoyait 30 requêtes concurrentes, toutes passant la vérification d'unicité avant qu'un INSERT ne commite. Toutes les 30 recevaient l'allocation quotidienne.
Tools for Humanity (Worldcoin) Vérification Race (3 000$) Une race condition de vérification biométrique permettait à plusieurs comptes d'être associés à un seul scan. La vérification d'unicité était non atomique avec le commit de création de compte.
InnoGames Email Activation Race (2 000$) Vingt requêtes d'activation d'email concurrentes incrémentaient chacune un compteur de "récompense d'activation" sur une plateforme gaming, accordant 20× le bonus de bienvenue. 137 votes HackerOne.
Helium Transfer Data Credits Race (250$) L'endpoint de transfert de data credits Helium avait une vérification de solde non atomique. Un burst concurrent transférait des crédits N fois, dépassant le solde disponible.
Bypass Rate Limit AWS WAF (2024 Bug Bounty) Un rapport HackerOne 2024 a confirmé que les requêtes single-packet HTTP/2 contournent le rate limiter token-bucket d'AWS WAF quand toutes les requêtes arrivent dans le même paquet TCP. AWS a reconnu la limitation comme architecturale.
/vote, /follow, /like, /claim, /faucet, /free-trial, /register.Pour le bypass de rate limit :
# Détection de dépassement de limite — N-concurrent + preuve d'état
async def limit_probe(limit_url: str, action_url: str, action_body: dict,
headers: dict, n: int = 20, expected_limit: int = 1):
async with httpx.AsyncClient(http2=True, verify=False) as client:
pre = (await client.get(limit_url, headers=headers)).json()
tasks = [client.post(action_url, headers=headers, json=action_body)
for _ in range(n)]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = sum(1 for r in results
if not isinstance(r, Exception) and r.status_code in (200, 201))
post = (await client.get(limit_url, headers=headers)).json()
return {"successes": successes, "pre": pre, "post": post,
"limite_contournee": successes > expected_limit}BreachVex détecte les races de dépassement de limite via plusieurs techniques complémentaires : correspondance des patterns d'URL des endpoints à limite connue (/vote, /claim, /faucet, /follow, /register), envoi de bursts concurrents avec analyse différentielle de réponse, et vérification d'état sur l'endpoint compteur associé.
La correction universelle — vérifier et incrémenter dans une seule instruction SQL :
-- VULNÉRABLE : SELECT puis UPDATE — fenêtre de race
SELECT vote_count FROM user_votes WHERE user_id = $1 AND post_id = $2;
INSERT INTO user_votes (user_id, post_id) VALUES ($1, $2);
UPDATE posts SET vote_count = vote_count + 1 WHERE id = $2;
-- CORRIGÉ : INSERT atomique avec détection de conflit
INSERT INTO user_votes (user_id, post_id)
VALUES ($1, $2)
ON CONFLICT (user_id, post_id) DO NOTHING;
-- Incrémenter SEULEMENT si l'insert a réussi (1 ligne retournée)
-- Si ON CONFLICT déclenché (vote dupliqué), NE PAS incrémenter# SQLAlchemy — vote atomique avec gestion de conflit
from sqlalchemy.dialects.postgresql import insert as pg_insert
async def cast_vote(user_id: int, post_id: int, db: AsyncSession):
stmt = pg_insert(UserVote).values(user_id=user_id, post_id=post_id)
stmt = stmt.on_conflict_do_nothing(index_elements=['user_id', 'post_id'])
result = await db.execute(stmt)
if result.rowcount == 0:
raise ValueError("Déjà voté")
await db.execute(
update(Post).where(Post.id == post_id).values(vote_count=Post.vote_count + 1)
)
await db.commit()-- Incrément atomique de compteur avec limite
UPDATE usage_counters
SET count = count + 1, last_used_at = NOW()
WHERE user_id = $1
AND endpoint = $2
AND period_start = date_trunc('hour', NOW())
AND count < max_calls_per_hour
RETURNING count;
-- 0 lignes = limite atteinte — aucune race possibleimport redis.asyncio as redis
async def rate_limit_atomic(user_id: int, endpoint: str, limit: int,
window_sec: int, r: redis.Redis) -> bool:
"""Rate limit atomique via Redis INCR — aucune race possible."""
key = f"ratelimit:{user_id}:{endpoint}"
# INCR est atomique — aucune race entre vérification et incrément
count = await r.incr(key)
if count == 1:
await r.expire(key, window_sec)
return count <= limitLes rate limits applicatifs utilisant SELECT-puis-UPDATE sur une colonne compteur sont vulnérables à cette attaque exacte. Redis INCR (atomique) et UPDATE conditionnel atomique en DB sont les seuls patterns sûrs. Un rate limiter SELECT-puis-UPDATE ne fournit aucune protection contre les bursts concurrents HTTP/2.
Une race de dépassement de limite contourne toute limite numérique appliquée via un read-then-write non atomique sur un compteur. L'application lit le compteur (SELECT), vérifie que count < limite, puis incrémente (UPDATE). Les requêtes concurrentes passent toutes la vérification avant qu'un incrément ne commite, contournant la limite.
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 ait été incrémenté — toutes lisent count=0 et passent le contrôle. Confirmé sur Cloudflare et AWS WAF en 2024. La correction requiert une application atomique au niveau logique métier, pas seulement au gateway.
Un UPDATE conditionnel atomique prévient la manipulation : UPDATE posts SET votes = votes + 1 WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM post_votes WHERE post_id = $1 AND user_id = $2). Cela vérifie et enregistre le vote en une seule instruction atomique. L'alternative est INSERT INTO post_votes ON CONFLICT DO NOTHING, puis incrémenter seulement si l'INSERT a réussi.
Un faucet crypto avec '1 claim par adresse par 24h' applique l'unicité via SELECT-puis-INSERT. Les requêtes concurrentes depuis la même adresse passent toutes la vérification SELECT avant qu'un INSERT ne commite. Le faucet Cosmos/starport (5 000$ HackerOne) permettait 30 claims concurrents depuis une seule adresse.
Oui. La validation CAPTCHA marque typiquement un token comme utilisé après validation (SELECT valid, puis UPDATE used=true). La fenêtre entre ces deux étapes permet à N requêtes concurrentes avec le même token CAPTCHA de passer toutes la validation avant que le token soit marqué utilisé. Cela permet de créer plusieurs comptes depuis un seul CAPTCHA résolu.
Un bypass de rate limit cible un compteur temporel (N requêtes par minute/heure) qui se réinitialise sur un calendrier. La race exploite le délai d'incrément : toutes les N requêtes single-packet arrivent avant que le compteur ne s'incrémente depuis 0, donc toutes passent la vérification count < limite. Un dépassement de limite cible un compteur permanent ou par-ressource (1 vote par utilisateur par post, 1 essai par email) qui ne se réinitialise jamais. Les deux partagent le même pattern non atomique check-incrément, mais la distinction temporel vs permanent affecte la correction : les rate limits peuvent utiliser Redis INCR (atomique, basé sur TTL) ; les limites par-ressource nécessitent INSERT ON CONFLICT DO NOTHING ou UPDATE WHERE count < max.
Dans les DAO basés sur des tokens, le pouvoir de vote est proportionnel aux holdings de tokens. Un faucet ou airdrop avec une limite par-adresse empêche une seule adresse d'accumuler un pouvoir de vote disproportionné. Si le faucet a une race de dépassement de limite (SELECT-puis-INSERT d'enregistrement de claim), un attaquant peut revendiquer 30× ou 100× l'allocation prévue depuis une seule adresse. Avec suffisamment de tokens dupliqués, l'attaquant obtient le pouvoir de vote majoritaire — permettant des propositions de gouvernance malveillantes : drainage de trésorerie, mises à jour de contrats, ou changements de paramètres. Le bounty Cosmos/starport de 5 000$ a été spécifiquement signalé comme risque de sécurité de gouvernance au-delà de la valeur directe des tokens.
Une plateforme e-commerce vérifiant l'inventaire avant de traiter une commande (SELECT quantity FROM inventory WHERE sku=X ; if quantity > 0 : process_order()) est vulnérable aux soumissions de commandes concurrentes. Vingt requêtes de commande concurrentes lisent toutes quantity=1 et passent toutes la vérification. Les 20 commandes sont traitées et honorées. La plateforme expédie 20 unités qu'elle n'a qu'en 1 exemplaire — engageant 19 unités de perte financière réelle. L'inventaire physique à valeur unitaire élevée (électronique, articles en édition limitée) rend cela commercialement significatif : un GPU à 500€ racé 20 fois coûte 9 500€ de pertes à la plateforme.
L'attaque single-packet HTTP/2 standard (Kettle 2023) fonctionne avec 20–30 requêtes concurrentes — suffisant pour la plupart des races de limite. Le first sequence sync (Flatt Security CODE BLUE 2024) permet jusqu'à 10 000 requêtes concurrentes en exploitant la fragmentation IP pour synchroniser l'établissement de connexion sur toutes les requêtes. Pour les races de faucet/airdrop, 30 claims concurrents peuvent apporter un gain financier significatif mais 10 000 claims amplifies dramatiquement l'attaque. Nécessite un accès raw packet via Scapy — pas faisable depuis une infrastructure cloud standard sans root/CAP_NET_RAW.
Redis INCR est une seule opération atomique côté serveur — la lecture, l'incrément et l'écriture se produisent en une seule commande Redis sans état intermédiaire visible par d'autres clients. Aucun appel INCR concurrent ne peut lire la valeur pré-incrément car Redis traite les commandes séquentiellement dans un seul thread. SELECT-puis-UPDATE en SQL sont deux allers-retours avec un état intermédiaire visible : toute transaction concurrente peut lire la valeur entre le SELECT et l'UPDATE. Redis INCR n'a pas de concept 'd'entre lectures et écritures' — il est instantané du point de vue de chaque autre client. Cela fait d'INCR le bon bloc atomique pour les rate limiters, avec EXPIRE appelé conditionnellement seulement quand INCR retourne 1 (première requête dans la fenêtre).