Un coupon ou bon à usage unique est appliqué plusieurs fois en concurrence avant que le flag 'utilisé' ne soit persisté en base de données.
TL;DR
UPDATE WHERE used=FALSE RETURNING id — vérification et consommation atomiques en une seule instruction SQLUne race condition de réutilisation de coupon est une attaque de dépassement de limite ciblant les codes promotionnels, bons et cartes cadeaux à usage unique ou limité. La cause racine est la séquence non atomique vérification-et-marquage-comme-utilisé : l'application vérifie si un coupon est encore valide (SELECT), puis le marque comme consommé (UPDATE) dans deux opérations DB séparées. Les requêtes concurrentes passent toutes le SELECT avant qu'un UPDATE ne commite, chacune croyant que le coupon est encore disponible.
Il s'agit d'une instance directe de CWE-362 (Race Condition) sous OWASP A04:2021 (Insecure Design). L'implémentation naïve est naturelle : lire la validité, appliquer la remise, mettre à jour la base. Chaque tutoriel de framework démontre ce pattern en trois étapes sans mentionner la race condition qu'il introduit. L'attaque single-packet de Kettle 2023 a rendu cela exploitable de manière fiable à distance.
1. Client envoie : POST /cart/apply-coupon {"code": "SAVE20"} ×20 concurrents
2. Serveur (×20) : SELECT used FROM coupons WHERE code='SAVE20' → used=false
3. Serveur (×20) : UPDATE coupons SET used=true WHERE code='SAVE20'
4. Serveur (×20) : retourne 200 OK — remise appliquéeLe payload Turbo Intruder pour cette attaque :
# Turbo Intruder — race de coupon via attaque single-packet HTTP/2
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)
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)Requête cible :
POST /api/cart/apply-coupon HTTP/2
Host: shop.example.com
Cookie: session=abc123
Content-Type: application/json
{"coupon_code":"SAVE20","cart_id":"cart_xyz"}| Variante | Cible | Plateforme | Bounty |
|---|---|---|---|
| Multi-rédemption coupon unique | Checkout e-commerce | Tout site marchand | 216–5K$ |
| Contournement limite code promo | Billetterie, facturation SaaS | alf.io (CVE-2024-45300) | CVE |
| Vidange de carte cadeau | Commerce, gaming | nopCommerce (CVE-2024-58248) | CVE / 1,5K$ |
| Multi-claim code parrainage | Signup SaaS, fintech | Diverses plateformes | 500–2K$ |
| Extension essai gratuit | Facturation SaaS | Plateformes d'abonnement | 500–3K$ |
Les races de carte cadeau sont l'impact financier le plus élevé pour un endpoint unique. CVE-2024-58248 dans nopCommerce permettait à une carte cadeau de 100€ d'être rachetée N fois avant qu'un décrément de solde ne commite.
Les races de code parrainage exploitent la contrainte "1 récompense parrainage par code". Vingt inscriptions simultanées avec le même code passent toutes la vérification usage unique et reçoivent toutes le crédit de 50€.
CVE-2024-45300 — alf.io Promo Code Race (CVSS 7.5) alf.io (versions ≤ 2.0-M4-2402) est une plateforme de billetterie open source. Des requêtes concurrentes d'application de code promo contournent la limite max-uses via un check non atomique dans le flux de réservation. GHSA-67jg-m6f3-473g ; corrigé dans alf.io 2.0-M4-2403.
CVE-2024-58248 — nopCommerce Gift Card Double-Spend (CVSS 7.5)
nopCommerce < 4.80.0 avait un flux de rédemption de carte cadeau non atomique. Des requêtes concurrentes à /giftcard/apply lisaient toutes le solde avant tout débit. Confirmé par Outpost24 avec un burst single-packet Turbo Intruder. Corrigé dans nopCommerce 4.80.0.
Reverb.com — Multi-Rédemption Carte Cadeau (HackerOne, 1 500$) Une race condition sur la marketplace Reverb.com permettait des applications multiples du même solde de carte cadeau. Le chercheur a démontré 5 requêtes concurrentes appliquant chacune 50€ depuis une seule carte cadeau de 50€.
Dropbox — Contournement Code Coupon (HackerOne, 216$) Une race condition de code coupon permettait d'appliquer le même code promo à plusieurs comptes Dropbox via des requêtes concurrentes.
InnoGames Email Activation Race (2 000$) Vingt requêtes d'activation d'email concurrentes incrémentaient chacune un compteur de monnaie virtuelle. Une seule activation par email jouée en parallèle accordait 20× le bonus de bienvenue. 137 votes HackerOne.
/apply-coupon, /redeem, /gift-card/apply, /promo/claim.200 OK avec confirmation de remise au lieu de 409 Conflict indique la race.BreachVex détecte les races de coupon via plusieurs techniques complémentaires : correspondance des patterns d'URL de redemption (/coupon, /promo, /voucher, /redeem, /apply, /gift), envoi d'un burst single-packet HTTP/2 de requêtes de redemption identiques, analyse du différentiel de réponse (plus d'un succès déclenche la vérification), et confirmation par une preuve d'état read-race-read sur le total du panier ou le solde du compte — si la remise s'est appliquée plusieurs fois, la race est confirmée.
-- VULNÉRABLE : deux étapes check + update séparées
SELECT used, max_uses, uses_count FROM coupons WHERE code = $1;
UPDATE coupons SET uses_count = uses_count + 1 WHERE code = $1;
-- CORRIGÉ : UPDATE conditionnel atomique
UPDATE coupons
SET uses_count = uses_count + 1,
last_used_by = $2,
last_used_at = NOW()
WHERE code = $1
AND uses_count < max_uses
AND (single_use = FALSE OR uses_count = 0)
RETURNING id, discount_percent;
-- 0 lignes = limite atteinte ou déjà utiliséfrom sqlalchemy import update
async def apply_coupon(code: str, user_id: int, cart_id: str, db: AsyncSession):
"""Rédemption atomique de coupon — aucune race condition possible."""
result = await db.execute(
update(Coupon)
.where(
Coupon.code == code,
Coupon.uses_count < Coupon.max_uses,
Coupon.active == True,
)
.values(uses_count=Coupon.uses_count + 1, last_used_by=user_id)
.returning(Coupon.discount_percent, Coupon.id)
)
row = result.fetchone()
if row is None:
raise HTTPException(409, detail="Coupon épuisé ou invalide")
return {"discount": row.discount_percent, "coupon_id": row.id}import redis.asyncio as redis, uuid
async def apply_coupon_with_lock(code: str, user_id: int, r: redis.Redis):
lock_key = f"coupon:{code}:lock"
lock_id = str(uuid.uuid4())
if not await r.set(lock_key, lock_id, nx=True, ex=5):
raise ValueError("Tentative de rédemption concurrente — réessayez")
try:
coupon = await db.get_coupon(code)
if coupon.uses_count >= coupon.max_uses:
raise ValueError("Limite coupon atteinte")
await db.increment_coupon(code, user_id)
finally:
await r.eval(
"if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end",
1, lock_key, lock_id
)Les sessions PHP natives sérialisent les requêtes par PHPSESSID via flock(). Une race de coupon testée depuis un seul navigateur paraîtra protégée. Toujours tester avec des tokens de session distincts par requête concurrente pour contourner le verrou de session natif.
Quand l'application vérifie la validité du coupon (SELECT used FROM coupons WHERE code=X) et le marque comme utilisé (UPDATE coupons SET used=TRUE) dans deux opérations DB séparées, les requêtes concurrentes passent toutes la vérification SELECT avant qu'un UPDATE ne commite. Chaque requête voit used=false et applique la remise. La correction est un UPDATE WHERE used=FALSE atomique qui retourne 0 lignes si déjà consommé.
CVE-2024-45300 (CVSS 7.5) est un contournement de limite de code promo par race condition dans la plateforme de billetterie alf.io (versions jusqu'à 2.0-M4-2402). Des applications concurrentes de code promo contournent la vérification de limite d'utilisation avant qu'un incrément ne commite. Plusieurs participants peuvent appliquer simultanément un code à usage unique. Corrigé via le pattern UPDATE WHERE count < max_uses.
Turbo Intruder avec Engine.BURP2 envoie toutes les N requêtes de rédemption de coupon via une seule connexion HTTP/2 dans un seul paquet TCP (attaque single-packet). Toutes les requêtes arrivent au serveur en moins de 1ms — plus vite qu'une transaction DB ne peut commiter. Le mécanisme 'gate' de Turbo Intruder met en file toutes les requêtes et les tire simultanément.
Un coupon SAVE20 appliqué 20 fois à un panier de 100€ réduit le total à -300€. Certaines plateformes traitent les totaux négatifs comme des crédits ou des remboursements. Même sans total négatif, 100% de remise appliquée plusieurs fois génère des articles gratuits. Dropbox a payé 216$ pour une race condition de coupon ; Reverb.com a payé 1 500$ pour une race de rédemption multiple de carte cadeau.
Pour la plupart des applications web, 20 requêtes concurrentes via l'attaque single-packet HTTP/2 suffisent à exploiter une race de coupon de manière fiable. L'objectif est de s'assurer que toutes les requêtes arrivent au serveur avant qu'une transaction de base de données ne commite. Avec la livraison single-packet HTTP/2 (toutes les requêtes dans un seul paquet TCP), le serveur les reçoit en moins de 1ms. Pour les bases de données très optimisées avec des temps de commit sub-milliseconde, augmenter à 50 requêtes réduit la marge. Engine.BURP2 de Turbo Intruder gère la synchronisation automatiquement.
Le CAPTCHA ne prévient pas les races de coupon. Le CAPTCHA vérifie qu'un humain a initié la requête — il n'empêche pas cet humain d'envoyer la même requête valide 20 fois en concurrence. Le token CAPTCHA est validé une fois sur la première requête ; les requêtes concurrentes suivantes utilisent les mêmes identifiants de session. Un attaquant résout le CAPTCHA une fois, puis utilise Turbo Intruder pour envoyer 20 requêtes d'application de coupon identiques simultanément.
Les bounties pour race de coupon vont de 216$ (Dropbox, duplication de remise à faible impact) à 5 000$+ pour les plateformes où les soldes négatifs génèrent de vraies sorties de cash. La médiane HackerOne pour la manipulation de panier e-commerce est d'environ 500–2 000$. Les races de carte cadeau (Reverb.com : 1 500$, nopCommerce CVE-2024-58248) obtiennent systématiquement des bounties plus élevés car la perte financière est directe et mesurable.
Le header Idempotency-Key de Stripe applique une sémantique exactly-once au niveau du processeur de paiement. Quand un client envoie une rédemption de coupon avec une clé unique, Stripe la traite une fois et met en cache le résultat. Les requêtes concurrentes avec la même clé retournent la réponse mise en cache sans retraitement. Le mécanisme clé est Redis SETNX (SET if Not eXists) : la première requête définit atomiquement la clé, les requêtes concurrentes voient la clé déjà définie et retournent le résultat mis en cache. L'invariant critique : l'acquisition de clé (SETNX) doit être atomique — un check SELECT-puis-INSERT a la même race que le check de coupon lui-même.
Oui. La logique de retry de webhook est une source souvent négligée de races de coupon et de paiement. Quand un fournisseur de paiement envoie un webhook (order.completed, payment.success) et ne reçoit pas de 200 ACK dans le délai, il réessaie — souvent 2–5 fois à intervalles de 30 secondes. Si l'application traite la rédemption de coupon ou l'application de remise à la réception du webhook sans idempotence, chaque retry applique à nouveau la remise. La correction est d'enregistrer l'ID d'événement webhook dans une table DB avec une contrainte UNIQUE sur l'ID d'événement, et de ne pas traiter si l'ID existe déjà.