Le même paiement ou action soumis en concurrence est traité en double avant que les clés d'idempotence ou les verrous DB ne prennent effet.
TL;DR
solde >= montant avant qu'un UPDATE ne commiteUne race condition de transaction dupliquée survient quand la même opération financière — retrait, transfert, paiement, versement ou crédit de compte — est exécutée plus d'une fois parce que le système n'a pas de mécanisme d'idempotence. Contrairement à une race de coupon (qui contourne un compteur d'utilisation), une race de transaction dupliquée exploite l'absence de toute vérification "déjà traité" : l'endpoint traite simplement chaque requête valide qu'il reçoit, quelle que soit la duplication.
La vulnérabilité racine est une étape de déduplication manquante ou non atomique. Les systèmes qui implémentent des clés d'idempotence ont souvent des races subtiles dans la logique de vérification de clé : ils interrogent si la clé existe (SELECT), puis l'insèrent (INSERT), puis traitent la requête — avec une fenêtre de race entre le SELECT et l'INSERT.
La recherche Doyensec "Database Race Conditions in AppSec" (2024) a identifié ce pattern dans plusieurs plateformes fintech de production utilisant Hibernate avec l'isolation READ COMMITTED par défaut.
L'attaque classique de vidange de solde exploite le check-and-debit en deux étapes :
1. VÉRIF : SELECT balance FROM accounts WHERE id=X → balance=100
2. [FENÊTRE DE RACE — N requêtes concurrentes lisent toutes balance=100]
3. DÉBIT : UPDATE accounts SET balance = balance - 80 WHERE id=X AND balance >= 80# PoC de vidange de solde
import asyncio, httpx
async def balance_drain(client: httpx.AsyncClient, balance_url: str,
withdraw_url: str, auth_headers: dict, amount: int, n: int = 8):
pre = (await client.get(balance_url, headers=auth_headers)).json()["balance"]
tasks = [
client.post(withdraw_url, headers=auth_headers,
json={"amount": amount, "destination": "compte_attaquant"})
for _ in range(n)
]
responses = await asyncio.gather(*tasks, return_exceptions=True)
post = (await client.get(balance_url, headers=auth_headers)).json()["balance"]
successes = sum(1 for r in responses if not isinstance(r, Exception) and r.status_code == 200)
return {
"solde_initial": pre,
"solde_final": post,
"retraits_reussis": successes,
"montant_vide": pre - post,
"race_confirmee": successes > 1 and (pre - post) > amount,
}| Variante | Mécanisme | Impact | Bounty typique |
|---|---|---|---|
| Vidange de solde | N retraits concurrents passent la vérification | Solde négatif, vol de fonds | 5K–50K$ |
| Double versement | Race entre déclenchement paiement et flag completion | Débours dupliqué | 2K–20K$ |
| Double-rédemption code OAuth | Échange concurrent avant invalidation | Deux familles de tokens | 2K–20K$ |
| Race token de rafraîchissement | Deux refreshes avant rotation complète | Accès persistant post-révocation | 500–5K$ |
| Duplication de crédit compte | Race sur endpoint claim/reward | Crédits compte en double | 500–3K$ |
La double-rédemption de code OAuth (HackerOne #55140) est l'exemple documenté classique. Un code d'autorisation est échangé deux fois en parallèle avant que le serveur ne le marque atomiquement comme consommé. Les deux requêtes reçoivent des tokens valides. L'attaquant conserve une paire de tokens après que la victime a révoqué l'autre.
La race de rotation de token de rafraîchissement : les flux auth modernes (Auth0, Better Auth) font tourner les tokens de rafraîchissement à chaque utilisation. Deux rafraîchissements concurrents voient tous deux l'ancien token comme valide avant que la rotation ne commite. Les deux reçoivent de nouveaux tokens — l'un survivra à la révocation de la victime.
HackerOne #55140 — OAuth 2 Race (Internet Bug Bounty, 2 500$) Des requêtes d'échange de code d'autorisation concurrentes ont produit deux paires distinctes de tokens depuis un seul code. Le provider invalidait le code en deux étapes SELECT-puis-DELETE. L'attaquant conservait une paire de tokens valide après la révocation complète par la victime.
HackerOne #418767 — Contournement 2FA HackerOne via Race (125 votes) Une race condition dans le propre flux 2FA de HackerOne permettait de contourner l'application de la 2FA. La vulnérabilité exploitait la fenêtre entre l'écriture de session user_id et l'écriture du flag mfa_pending.
Cosmos/starport Faucet Race (HackerOne, 5 000$) Un faucet crypto avec "1 claim par adresse par 24h" appliqué via SELECT-puis-INSERT a permis 30 requêtes concurrentes, toutes passant la vérification d'unicité avant qu'un INSERT ne commite. Récompense de 5 000$.
Tools for Humanity (Worldcoin) — 3 000$ Une race condition de vérification biométrique permettait à plusieurs comptes d'être associés à un seul scan biométrique.
/withdraw, /transfer, /payout, /purchase, /redeem-reward.montant_vide > débit_attendu_unique, la race est confirmée.access_token distincts, la double-rédemption est confirmée.async def duplicate_tx_probe(client: httpx.AsyncClient, balance_url: str,
action_url: str, action_body: dict,
auth_headers: dict, n: int = 10):
pre = (await client.get(balance_url, headers=auth_headers)).json()
tasks = [client.post(action_url, headers=auth_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 == 200)
post = (await client.get(balance_url, headers=auth_headers)).json()
return {"successes": successes, "pre": pre, "post": post, "race_confirmee": successes > 1}# FastAPI + Redis idempotence — exécution exactly-once
@router.post("/withdraw")
async def withdraw(body: WithdrawalRequest, idempotency_key: str = Header(...),
r: redis.Redis = Depends(get_redis)):
idem_key = f"idem:{idempotency_key}"
if not await r.set(idem_key, "processing", nx=True, ex=86400):
cached = await r.get(f"{idem_key}:result")
if cached:
return cached
raise HTTPException(409, "Requête en cours — réessayez")
try:
result = await execute_withdrawal(body)
await r.set(f"{idem_key}:result", result.model_dump_json(), ex=86400)
return result
except Exception:
await r.delete(idem_key)
raise-- Prévenir les transactions dupliquées au niveau DB
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
operation_id UUID UNIQUE NOT NULL,
account_id BIGINT NOT NULL,
amount DECIMAL(15,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO transactions (operation_id, account_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (operation_id) DO NOTHING
RETURNING id;
-- NULL retourné = doublon — retourner le résultat originalBEGIN;
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- Une seule transaction détient le verrou à la fois — aucun retrait dupliqué possible
UPDATE accounts SET balance = balance - $2
WHERE id = $1 AND balance >= $2;
COMMIT;Redis Redlock fournit des verrous distribués mais Martin Kleppmann a identifié une faille fondamentale en 2016 : sous partition réseau ou dérive d'horloge, deux clients peuvent simultanément détenir le verrou. Pour les opérations financières, préférer SELECT FOR UPDATE DB-level à Redis Redlock. Utiliser Redis SETNX uniquement pour les flux non financiers.
Une race de transaction dupliquée survient quand le même paiement, transfert ou action est soumis plusieurs fois en concurrence et traité plus d'une fois avant qu'un mécanisme de déduplication ne prenne effet. Contrairement aux races de coupon, les races de transaction dupliquée exploitent l'absence d'idempotence : le système n'a pas de mécanisme pour détecter et ignorer les opérations re-soumises.
Une application fintech vérifie 'solde >= montant' avant d'exécuter un retrait. Si la vérification (SELECT) et le débit (UPDATE balance = balance - montant) sont des opérations DB séparées non atomiques, N retraits concurrents passent tous la vérification avant qu'un UPDATE ne commite. Chacun retire le montant complet. Solde initial 100€, montant retrait 80€ : toutes les N requêtes passent la vérification et committent, laissant le compte à 100 - 80*N.
Dans OAuth 2.0, un code d'autorisation est à usage unique. Si le marquage comme consommé n'est pas atomique — SELECT pour vérifier la validité puis DELETE en deux étapes — deux échanges concurrents voient tous deux le code comme valide et reçoivent chacun des paires access_token/refresh_token distinctes. HackerOne #55140 (Internet Bug Bounty, 2 500$) a démontré ce pattern.
Stripe exige un header Idempotency-Key unique sur toutes les opérations d'écriture (charges, virements, remboursements). La clé est générée par le client (UUID v4). À la première réception, Stripe traite la requête et met en cache le résultat pendant 24h. Les requêtes suivantes avec la même clé retournent la réponse mise en cache sans retraitement. Cela garantit une sémantique exactly-once.
La recherche Doyensec 'Database Race Conditions in AppSec' (2024) a enquêté sur plusieurs plateformes fintech de production et a constaté que les applications Spring Boot + Hibernate fonctionnant avec l'isolation READ COMMITTED par défaut permettaient systématiquement des attaques de vidange de solde. La cause racine : le pattern ORM read-modify-write de Hibernate (charger l'entité → vérifier le solde → sauvegarder) s'exécute comme deux allers-retours SQL séparés sous READ COMMITTED. Doyensec a recommandé soit d'upgrader à l'isolation SERIALIZABLE, soit d'utiliser SELECT FOR UPDATE dans la transaction, soit de passer à un UPDATE WHERE balance >= amount natif contournant le cycle read-modify-write ORM.
Les flux OAuth 2.1 et OIDC modernes utilisent la rotation des refresh tokens : chaque utilisation d'un refresh token l'invalide et en émet un nouveau. Si deux requêtes concurrentes présentent toutes deux le même refresh token avant que la rotation ne commite, les deux reçoivent de nouveaux tokens distincts. Le deuxième token n'est pas tracé dans la chaîne d'invalidation de la première rotation. Un attaquant qui race une rotation de token conserve un refresh token valide de longue durée après que la victime a révoqué le principal. C'est particulièrement dangereux car les refresh tokens ont une longue durée de vie (jours à mois) — accès non autorisé persistant après que la victime croit s'être déconnectée.
Un rapport valide de race de transaction dupliquée nécessite la preuve read-race-read : (1) GET /account/balance avant le burst — enregistrer le solde pré-race. (2) Exécuter N requêtes POST /withdraw concurrentes avec body et auth identiques via HTTP/2 single-packet. (3) GET /account/balance après le burst — enregistrer le solde post-race. (4) Calculer : si post_balance = pre_balance - montant × N_succès au lieu de pre_balance - montant × 1, la race est confirmée. Inclure le nombre exact de réponses 200 OK, le montant du retrait, les soldes pré/post, et le journal de requêtes HTTP/2 montrant des temps de livraison sub-1ms.
L'idempotence signifie que la même opération peut être soumise plusieurs fois mais produit le même résultat — les soumissions suivantes sont reconnues et le résultat original est retourné. L'atomicité signifie qu'une séquence check-and-act s'exécute comme une unité indivisible — aucune opération concurrente ne peut observer un état intermédiaire. Les deux sont nécessaires : l'idempotence seule échoue si le check de clé d'idempotence est lui-même non atomique (SELECT clé + INSERT clé en deux étapes a une race). L'atomicité seule échoue pour les systèmes distribués. La défense correcte est les deux : acquisition atomique de clé (Redis SETNX ou contrainte UNIQUE DB avec ON CONFLICT) ET logique d'idempotence applicative.
SELECT FOR UPDATE prévient les races de vidange de solde au sein d'une seule transaction de base de données sur une seule instance, mais échoue entre microservices pour deux raisons : (1) les microservices possèdent typiquement des bases séparées — un verrou acquis dans le service A ne peut pas bloquer un write dans la base du service B ; (2) les transactions distribuées (transactions XA, two-phase commit) introduisent leurs propres fenêtres de race pendant la phase prepare-commit et ajoutent une latence significative. La solution cross-service correcte est une clé d'idempotence stockée dans un cluster Redis partagé (SETNX) ou un pattern saga avec transactions compensatoires.