Application multiple de coupons à usage unique via race conditions TOCTOU, réutilisation de codes ou combinaisons de parrainage qui contournent les limites de redemption prévues.
TL;DR
SELECT ... FOR UPDATE, des clés d'idempotence, et une recomputation des prix côté serveur.Le stacking de coupons est une vulnérabilité de logique métier dans laquelle un coupon à usage unique, un bonus de parrainage ou un crédit promotionnel est utilisé plus de fois que l'application ne le prévoit. L'instance canonique est une race condition Time-Of-Check / Time-Of-Use (TOCTOU) sur un endpoint /apply-coupon, mais le même résultat business peut être atteint via la réutilisation séquentielle d'un code, le replay cross-account, ou en empilant des types de promotions incompatibles dans une seule commande. La classe de vulnérabilité est mappée à CWE-841 (Improper Enforcement of Behavioral Workflow) et à OWASP A04:2021 — Insecure Design, parce que le défaut n'est pas un filtre d'input manquant mais un contrôle de concurrence ou de policy manquant sur une transition d'état.
Le score de base CVSS v3.1 est de 6.5 (Medium) avec le vecteur CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N. La confidentialité et la disponibilité ne sont pas affectées, mais l'intégrité est élevée car le prix, le total de la commande et les ledgers de revenus sont directement corrompus par l'attaquant. En pratique, l'impact business dépasse fréquemment la note CVSS : un seul attaquant lançant Burp Suite Turbo Intruder peut vider un budget promotionnel à six chiffres en quelques secondes, et la perte évolue linéairement avec l'automatisation plutôt qu'avec la compétence de l'attaquant.
Il est important de distinguer le stacking de coupons de la manipulation de prix générique. La manipulation de prix modifie un total fourni par le client ({"price": -10}) et se corrige en recomputant le total côté serveur. Le stacking de coupons, en revanche, envoie le bon code de coupon vers le bon endpoint avec la bonne identité utilisateur — le serveur échoue simplement à imposer qu'un code égale une redemption lorsque N requêtes concurrentes entrent en collision. Les deux coexistent souvent, mais les contrôles diffèrent : la recomputation côté serveur ne protège pas contre une race condition sur la table des coupons elle-même.
Le flow vulnérable contient une fenêtre mesurable entre la lecture de l'état du coupon et son écriture. Pendant cette fenêtre, des requêtes parallèles observent toutes used = false, toutes procèdent, et toutes commitent une redemption.
L'exploitation se réduit à quatre étapes ordonnées que tout testeur peut reproduire :
POST /apply-coupon valide dans un proxy et la rejouer une fois dans Repeater pour confirmer le comportement de référence.200 OK pour un code à usage unique, une somme de remises dépassant le total du panier, ou un discount_id dupliqué dans les line items de la commande confirme la race.Une requête d'exploitation représentative ressemble à ceci :
POST /checkout/apply-promo HTTP/2
Host: shop.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{"promo_code": "FIRST50", "order_id": "ord_88abc"}Vingt copies de cette requête, gated sur le dernier byte et libérées simultanément, appliqueront FIRST50 vingt fois sur un serveur vulnérable. La fenêtre de race est typiquement de 1 à 50 millisecondes sur une application derrière un CDN et considérablement plus large sur un backend monolithique sous charge.
Une requête qui paraît idempotente — même body, mêmes headers, même JWT — n'est pas sûre par défaut. L'idempotence doit être imposée côté serveur avec une clé, un verrou au niveau de la ligne, ou une contrainte unique. La sémantique HTTP seule ne protège pas une redemption stateful.
La race TOCTOU est le cas canonique, mais le même résultat financier est atteignable par plusieurs chemins non concurrents que les défenseurs manquent souvent.
| Variante | Technique | Impact |
|---|---|---|
| Race parallèle | 20-50 redemptions identiques via HTTP/2 single packet | Coupon utilisé N fois avant qu'aucun état ne soit écrit |
| Réutilisation de code | Code à usage unique invalidé seulement par worker async | Replay du code dans la fenêtre entre confirmation et burn |
| Cross-account | Un même panier partagé consomme des coupons de N comptes gratuits | Remise agrégée sur une commande, création de comptes sans coût |
| Parrainage + coupon | Bonus de parrainage et promo appliqués dans la même transaction | Crédit cumulé, pousse souvent le total à zéro |
| Fidélité + remise | Points et coupon appliqués sans contrôle d'exclusion mutuelle | Prix final négatif, remboursement de la valeur absolue |
| Empilement de prix négatif | Plusieurs remises en pourcentage appliquées séquentiellement | Total final sous zéro, compte crédité |
| Carte cadeau + promo | Carte cadeau consommée d'abord, remise appliquée sur le reste | La remise du coupon devient un crédit gratuit sur la commande suivante |
Les variantes cross-account et parrainage sont particulièrement difficiles à détecter car chaque requête individuelle est bien formée et authentifiée comme un utilisateur légitime différent. La détection ici nécessite une analyse au niveau du graphe (détection de cycles dans le graphe de parrainage, corrélation par device-fingerprint entre comptes) plutôt qu'une validation par requête.
Le chercheur Jack Cable (@cablej) a rapporté en 2016 que l'endpoint de redemption de coupons d'Instacart permettait au même code promo d'être utilisé deux fois via des requêtes concurrentes. Le backend vérifiait le statut de redemption puis appliquait le crédit, mais l'update n'était pas atomique. Instacart a accordé une bounty de $200. Après le patch initial, Cable a trouvé un bypass qui utilisait deux codes différents simultanément, stackant toujours les économies — un rappel que corriger la requête de surface sans corriger le modèle transactionnel sous-jacent laisse la classe de bug intacte.
En janvier 2023, le chercheur @ian a découvert que l'endpoint d'acceptation de fee discount de Stripe était vulnérable à une race condition. En appelant l'endpoint d'acceptation de remise 30 fois en parallèle via Burp Suite Turbo Intruder, il a accumulé $600 000 de volume de transaction sans frais à partir d'une offre promotionnelle unique de $20 000. La perte de Stripe par abus — environ $600 (3 pour cent de $20K) — était bien plus petite que le chiffre médiatique, mais le rapport illustre comment quelques secondes d'automatisation peuvent multiplier une promotion finie par 30x. Stripe a payé $5 000 et a résolu le problème avec une idempotence côté serveur sur l'appel d'acceptation de la remise.
Un rapport Stripe distinct de septembre 2022 a montré que les codes promotionnels portant des limites de redemption explicites (par exemple limit=1) pouvaient être utilisés au-delà de leur plafond via une race condition sur les liens Stripe Checkout. L'exploitation ne nécessitait aucun outillage particulier — seulement un navigateur ouvrant le même lien checkout dans plusieurs onglets simultanément. Stripe a payé $250 et a corrigé le problème avec un suivi atomique de la redemption. La bounty plus faible par rapport à #1849626 reflète la perte directe plus petite, mais la cause racine et la classe sont identiques.
Tous les incidents de stacking de coupons ne sont pas des race conditions. CVE-2020-36841 dans WooCommerce Smart Coupons (versions ≤4.6.0) permettait à des utilisateurs non authentifiés de s'envoyer des chèques cadeaux d'une valeur arbitraire en abusant d'un bypass d'autorisation dans la logique de création de coupons. L'impact financier est fonctionnellement équivalent à une attaque de stacking réussie — les attaquants fabriquaient de la valeur de remise à partir de rien — mais la cause racine est un contrôle d'accès manquant sur l'endpoint de création plutôt qu'une fenêtre TOCTOU. Patché en version 4.6.5. L'avis est tracké sous GHSA-x279-24jv-7gr3.
Une race condition patchée réapparaît souvent sous une forme de requête différente. Après avoir corrigé l'endpoint évident, balayez chaque surface adjacente — application de parrainage, redemption de carte cadeau, conversion de points de fidélité — car elles partagent typiquement le même pattern check-then-update non atomique.
Le test manuel pour le stacking de coupons est rapide une fois les surfaces de remise cartographiées. La procédure est identique pour les coupons, parrainages, cartes cadeaux et redemptions de fidélité.
POST /apply-coupon valide dans Burp Proxy.race-single-packet-attack.py. Configurer 20 requêtes parallèles gated sur le même mot. Ouvrir la gate ; les vingt bytes partent ensemble sur une seule connexion HTTP/2.current_uses du coupon. Plusieurs réponses 200 OK, une somme de remises supérieure au panier, ou current_uses inférieur au nombre de requêtes confirment tous la race.quantity: -1, discount_percent: 150, deux codes valides différents en parallèle, le même code depuis deux comptes sur le même device.Le template Turbo Intruder qui pilote l'exploitation est court :
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')
def handleResponse(req, interesting):
table.add(req)La détection programmatique se concentre sur les signaux fiables à travers les stacks : distribution des réponses, divergence d'effets de bord, et invariants de base de données.
| Signal | Indicateur |
|---|---|
Plusieurs 200 OK pour un code à usage unique | Fenêtre de race confirmée |
| Somme des remises supérieure au total du panier | Pas de clamping plancher à zéro |
coupon_used_count < nombre de requêtes post-exploit | Échec d'écriture atomique |
discount_id dupliqué dans les line items de la commande | Clé d'idempotence manquante |
| Variance du temps de réponse < 5 ms sur le batch | Fenêtre de race serrée présente, exploit fiable |
Les outils open-source qui automatisent cette surface incluent l'extension Burp Race the Web, Turbo Intruder avec le template single-packet, ffuf avec une concurrence élevée (-t 30) pour le probing séquentiel, et des scripts Python custom asyncio plus httpx pour un timing précis. Caido offre une capacité similaire de replay-group pour les équipes ayant migré au-delà de Burp.
BreachVex détecte le stacking de coupons via un probing dédié de race condition sur les endpoints de coupons, promo, parrainage et carte cadeau découverts pendant la cartographie de la surface d'attaque. Le scanner rejoue chaque requête candidate en parallèle, compare la distribution des réponses à une baseline single-shot, et émet un finding CONFIRMED uniquement lorsque la divergence d'effet de bord (multiples redemptions réussies, max_uses dépassé, ou line items de remise dupliqués) est observée, chaque finding confirmé étant adossé à un flux de preuve dual-judge.
Les deux contrôles ci-dessous fonctionnent ensemble. La transaction atomique empêche la race sur la ligne du coupon elle-même, et la clé d'idempotence empêche que la même opération logique soit tentée deux fois sous toute autre forme.
Le pattern vulnérable lit l'état du coupon hors d'un verrou et l'écrit plus tard :
# VULNERABLE — TOCTOU window between check and update
async def apply_coupon(order_id: str, code: str, user_id: str) -> dict:
coupon = await db.fetch_one("SELECT * FROM coupons WHERE code=:code AND used=false", {"code": code})
if not coupon:
raise ValueError("Invalid coupon")
# ...network round-trip, ORM overhead, no lock held...
await db.execute("UPDATE coupons SET used=true WHERE id=:id", {"id": coupon.id})
await db.execute("INSERT INTO redemptions (coupon_id, user_id, order_id) VALUES (:c, :u, :o)",
{"c": coupon.id, "u": user_id, "o": order_id})
return {"discount": float(coupon.discount_amount)}Le pattern corrigé enrobe la lecture et l'écriture dans une seule transaction avec un verrou au niveau de la ligne, afin que les transactions concurrentes bloquent sur SELECT ... FOR UPDATE au lieu de la dépasser en course :
# FIXED — SELECT FOR UPDATE holds a row lock for the duration of the tx
from sqlalchemy import text
async def apply_coupon(order_id: str, code: str, user_id: str) -> dict:
async with get_db() as db:
async with db.begin():
coupon = await db.execute(text("""
SELECT id, discount_amount, max_uses, current_uses
FROM coupons
WHERE code = :code
AND is_active = true
AND (expires_at IS NULL OR expires_at > now())
FOR UPDATE NOWAIT
"""), {"code": code}).fetchone()
if not coupon:
raise ValueError("Coupon not found or expired")
if coupon.current_uses >= coupon.max_uses:
raise ValueError("Coupon exhausted")
existing = await db.execute(text(
"SELECT 1 FROM coupon_redemptions WHERE coupon_id=:cid AND user_id=:uid"
), {"cid": coupon.id, "uid": user_id}).fetchone()
if existing:
raise ValueError("Already redeemed by this user")
await db.execute(text(
"UPDATE coupons SET current_uses = current_uses + 1 WHERE id = :cid"
), {"cid": coupon.id})
await db.execute(text("""
INSERT INTO coupon_redemptions (coupon_id, user_id, order_id)
VALUES (:cid, :uid, :oid)
"""), {"cid": coupon.id, "uid": user_id, "oid": order_id})
return {"discount_amount": float(coupon.discount_amount)}Une contrainte UNIQUE(coupon_id, user_id) sur coupon_redemptions fournit un filet de sécurité imposé par la base : même si un bug de concurrence passe le code applicatif, le second INSERT échoue avec une violation de contrainte plutôt que de double-rédemmer silencieusement.
Pour les flows à plus forte valeur — checkouts portant un paiement, acceptation de fee discount — couplez la transaction atomique à une clé d'idempotence côté serveur dérivée de l'opération, et non d'un header fourni par le client. L'exemple ci-dessous utilise SET NX Redis pour acquérir un lock court-vivant par (user, code, order) et une transaction Knex avec forUpdate pour effectuer la redemption atomiquement :
const Redis = require('ioredis');
const client = new Redis();
async function applyCoupon(req, res) {
const { code, orderId } = req.body;
const userId = req.user.id;
// Idempotency key: one application per (user, code, order)
const idemKey = `coupon:${userId}:${code}:${orderId}`;
const acquired = await client.set(idemKey, '1', 'NX', 'EX', 300); // 5 min TTL
if (!acquired) {
return res.status(409).json({ error: 'Coupon application already in progress' });
}
try {
const result = await db.transaction(async (trx) => {
const coupon = await trx('coupons')
.where({ code, is_active: true })
.forUpdate()
.first();
if (!coupon || coupon.current_uses >= coupon.max_uses) {
throw new Error('Coupon invalid or exhausted');
}
await trx('coupons').where({ id: coupon.id }).increment('current_uses', 1);
await trx('coupon_redemptions').insert({
coupon_id: coupon.id, user_id: userId, order_id: orderId,
});
return { discount: coupon.discount_amount };
});
return res.json(result);
} finally {
await client.del(idemKey);
}
}Deux contrôles supplémentaires complètent une implémentation défendable. Premièrement, recomputez le total de la commande côté serveur à chaque soumission de checkout et clampez le résultat avec max(final_price, 0) afin qu'aucune combinaison de remises ne puisse produire une charge négative. Deuxièmement, ne brûlez jamais les coupons dans un worker asynchrone — la fenêtre entre commit et burn est exactement le bug exploité dans les rapports Stripe et Instacart ci-dessus.
Le rate limiting seul — par exemple « max 3 requêtes /apply-coupon par utilisateur sur 10 secondes » — n'empêche pas une attaque single-packet. Vingt requêtes peuvent quitter la machine de l'attaquant en bien moins d'une milliseconde et arriver à l'application avant qu'aucun rate limiter n'ait eu la chance de les compter. Utilisez les transactions atomiques et les clés d'idempotence ; traitez le rate limiting comme défense en profondeur.
Le stacking de coupons est une faille de logique métier où un coupon à usage unique, un bonus de parrainage ou un crédit promotionnel est utilisé plus de fois que prévu par l'application. L'exploitation canonique est une race condition TOCTOU sur les endpoints `/apply-coupon`, mais le même résultat est atteignable via la réutilisation séquentielle d'un code ou en combinant des types de promotions incompatibles (fidélité + coupon + carte cadeau). Mappé à CWE-841 et OWASP A04:2021 (renommé A06:2025 — Insecure Design). Les cas réels incluent HackerOne #1717650 (Stripe, $250) et HackerOne #1849626 (Stripe, $5K, perte de $600K).
Time-Of-Check / Time-Of-Use (TOCTOU) exploite la fenêtre temporelle entre SELECT (vérifier used=false) et UPDATE (set used=true) lors de la redemption d'un coupon. Si 30 requêtes parallèles arrivent avant qu'aucun UPDATE ne soit committé, les 30 lisent used=false, toutes procèdent à l'insertion d'une remise, et le code à usage unique s'applique 30 fois. Le correctif est l'enforcement atomique via SELECT ... FOR UPDATE dans une transaction, ou une contrainte UNIQUE sur (coupon_id, order_id) afin que les insertions dupliquées échouent au niveau de la base de données.
Publiée par James Kettle (PortSwigger Research, août 2023, « Smashing the State Machine »), l'attaque HTTP/2 single-packet regroupe 30 requêtes ou plus dans la dernière frame d'une seule connexion HTTP/2. Toutes les requêtes arrivent au serveur dans une fenêtre inférieure à 1 ms — transformant les race conditions distantes en race conditions locales. Burp Suite Turbo Intruder fournit un template `race-single-packet-attack.py`. L'outil h2spacex de @ryotkak (août 2024) étend cela à 10 000 requêtes en ~166 ms. C'est ainsi que Stripe a été touché pour $600K (HackerOne #1849626).
En janvier 2023, le chercheur @ian a découvert que l'endpoint d'acceptation de fee discount de Stripe était vulnérable à une race condition (HackerOne #1849626). En appelant l'endpoint 30 fois en parallèle via Burp Suite Turbo Intruder, il a accumulé $600 000 de volume de transaction sans frais à partir d'une offre promotionnelle unique de $20 000 — une multiplication par 30 d'une remise unique. Le coût réel pour Stripe était d'environ $600 (3 % de $20K), mais le rapport illustre comment quelques secondes d'automatisation peuvent multiplier une promotion finie. Stripe a payé $5 000 et corrigé avec une idempotence côté serveur.
Les clés d'idempotence font en sorte que des soumissions répétées de la même opération produisent le même effet exactement une fois. Implémentation : le client génère un header Idempotency-Key UUID par redemption prévue ; le serveur le stocke dans Redis avec SET key value NX EX 300 (NX = uniquement si non existant, TTL 300s). Si le SET échoue, la requête est un duplicata et est rejetée avant tout changement d'état. À combiner avec une contrainte UNIQUE sur (coupon_id, order_id) pour la défense en profondeur. Stripe et Shopify proposent tous deux un support officiel des idempotency keys.
L'isolation de transaction par défaut de PostgreSQL/MySQL est READ COMMITTED, qui autorise des lectures non répétables — deux transactions concurrentes voient chacune used=false, chacune commit la redemption, et seule une coordination au niveau applicatif (locks, idempotency keys, contraintes uniques) empêche le duplicata. Passer à l'isolation SERIALIZABLE force la base à détecter les conflits mais ajoute de la latence et des retry sur rejet. Le correctif robuste est SELECT ... FOR UPDATE avec NOWAIT dans une transaction, plus une contrainte UNIQUE en filet de sécurité.
SELECT ... FOR UPDATE acquiert un verrou au niveau de la ligne pour la durée de la transaction englobante. Les autres transactions tentant le même SELECT FOR UPDATE bloquent jusqu'à ce que la première transaction commit ou rollback. Enroulée autour de la séquence de lecture-puis-écriture du coupon, elle sérialise les tentatives de redemption afin que seule la première requête observe used=false ; les requêtes suivantes bloquent, puis lisent used=true et rejettent. NOWAIT lève une erreur au lieu de bloquer — utile pour un comportement fast-fail sous forte charge parallèle. Nécessite une transaction explicite (BEGIN/COMMIT).