Nuevos labs cada semana — Accede a todos desde 5€/mes

Nivel IntermedioCon cuenta

Race conditions en marketplace — cupones aplicados N veces, stock negativo, doble checkout

Cómo identificar y explotar race conditions en flujos de e-commerce: cupones de descuento aplicados múltiples veces, productos con stock 0 vendidos, doble checkout para refunds.

Gorka El Bochi11 de mayo de 202614 min

Respuesta rápida

Race conditions en marketplace son el escenario más rentable del 2026 — los devs piensan en SQL transactions pero no en HTTP simultáneo. La regla: cualquier endpoint que lee estado, decide, y luego escribe es vulnerable si la ventana entre read y write supera 1ms. Patrones: cupón aplicado N veces (claim/use no atómico), stock vendido más allá del inventario (decremento no atómico), doble checkout (transferencia inicia antes de bloquear), reembolso aplicado múltiple. Tooling: Turbo Intruder con single-packet HTTP/2 sincroniza requests a <1ms. Bounties típicos: $1.500-$15.000.


Por qué pasan las races

El web es transaccional, las bases de datos son transaccionales — pero el código del medio rara vez lo es. Un endpoint típico de "use coupon":

javascript
app.post("/coupon/use", async (req, res) => {
  const coupon = await db.coupon.findOne({ code: req.body.code });
  if (!coupon || coupon.used) return res.status(400).send("Invalid");
  
  await db.coupon.updateOne({ code: req.body.code }, { used: true });
  await db.cart.applyDiscount(req.user.cart, coupon.amount);
  res.send("OK");
});

El bug: entre findOne (line 2) y updateOne (line 4) hay una ventana de ~5-50ms. Si dos requests llegan en esa ventana, ambos ven used: false, ambos pasan la validación, ambos aplican el discount.

El espacio del problema

Patrón de códigoVentana de raceProbabilidad de explotación
Read → decide → write (mismo proceso)1-10msAlta
Cache check → DB write10-100msMuy alta
Microservices (call A → call B)50-500msCasi garantizada
Webhook + state update100ms-segundosCasi garantizada

Tooling — single-packet attack

Turbo Intruder (Burp extension) implementa el single-packet attack documentado por James Kettle (2023): empaqueta N requests en un solo paquete TCP/HTTP-2, llegando al server simultáneamente. Vence:

  • Network jitter (latencia variable entre requests)
  • Rate limits per-IP que cuentan por segundo
  • Pequeños mutex (<10ms)

Script base

python
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")
    engine.complete(timeout=60)

def handleResponse(req, interesting):
    table.add(req)

gate="race1" hace que las 20 requests se preparen pero esperen — luego openGate las suelta todas a la vez con un solo paquete.

[!info] HTTP/2 ventaja HTTP/2 permite múltiples streams en una conexión TCP. El final frame de los headers de N requests cabe en un solo paquete IP → llegan al server con diferencia de microsegundos, no milisegundos.

<!-- PAYWALL -->

Patrón 1 — cupón aplicado N veces

Marketplace con cupones single-use. Endpoint POST /api/coupons/apply con {"code": "SUMMER20"} aplica 20% descuento.

Test

  1. Crea carrito de 100€.
  2. Captura el request de aplicar cupón en Burp Repeater.
  3. Envía a Turbo Intruder, dispara 20 simultaneous con gate.
  4. Refresh del carrito: ¿tienes 20% × 20 = 400% discount → precio negativo? Bug.

Variantes

  • Discount stack: aplicar el mismo código N veces lo apila aritméticamente.
  • Code consumption: el código se marca used pero el applied se propaga al carrito antes del mark.
  • Different carts simultáneos: aplica el mismo cupón a N carritos paralelos antes de que se marque usado.

Impact framing

  • Discount > 100% → reembolso por encima del precio → "store credit" extraído.
  • Cupón "first order $50 off" usado 50 veces → $2500 robado.

Bounty real: H1 program de booking, $8.400 — combinación con HTTP/2 single-packet bypaseando un rate limit anti-bot.


Patrón 2 — stock negativo

El endpoint de "add to cart" / "reserve product" decrementa stock pero el flow tipico:

javascript
const product = await db.product.findOne({ id });
if (product.stock < requested) return error;
await db.product.updateOne({ id }, { $inc: { stock: -requested } });

20 requests simultáneas, cada una pidiendo el último ítem en stock → todas ven stock: 1 → todas pasan validación → 20 reservas para 1 unidad disponible.

Patrones rentables

  • Limited-edition drop (sneakers, NFT-like collectibles): reserva 20 ítems del único disponible → reventa o exigir entrega de 20.
  • Subscription with free trial: race entre "check if user had trial" y "mark trial as used" → N trials.
  • Discount code single-use per user: lo mismo aplicado por user.

Detección rápida

¿El producto puede ir a stock: -19? Verifica con scan rápido vía API admin (si tienes test) o con product detail page tras race.


Patrón 3 — doble checkout

El endpoint POST /api/checkout/finalize ejecuta:

css
1. Lee balance del user
2. Lee total del carrito  
3. Charge a la tarjeta (call externo a Stripe)
4. Mark carrito as paid
5. Emit order

Ventana entre step 1 (lee balance) y step 4 (mark paid) es enorme porque step 3 tarda 500ms-2s (red externa). Race attack:

ini
T=0    request1 → step 1: balance 100T=10ms request2 → step 1: balance 100€ (no actualizado todavía)
T=500ms request1 → step 4: paid + balance 0T=500ms request2 → step 4: paid pero el balance fue de 100€ cuando empezó!

Resultado: dos órdenes pagadas pero solo se cobró una. Variante: cobro 100€ × 2 a la tarjeta pero solo recibes 1 order → atacante pide refund de 1 y se queda con la otra.

El attack invertido: refund race

Endpoint /api/orders/X/refund puede ejecutarse N veces antes de marcar la order como refunded:

ini
T=0    request1: refund 100€ → balance += 100
T=5ms  request2: refund 100€ → balance += 100   (order todavía no marcada como refunded)
T=10ms request3: refund 100€ → balance += 100
       ...

Pago original 100€, refunds N × 100€ → atacante balance pasa de 0 a 1000€ con 10 races.


Patrón 4 — referral code / signup bonus

El endpoint de signup chequea si el referral code está usado por este IP/device:

sql
1. Validate referral code
2. Create user
3. Credit referrer with bonus
4. Credit new user with bonus
5. Mark referral as redeemed (vinculado a user nuevo)

Race en step 1: 20 signups simultáneos con el mismo referral → 20 users nuevos creados → 20× créditos al referrer.

Marketplace con bonus de €5 por referido → race 20 → €100 robado. Escalado con sock puppets → millares.


Patrón 5 — KYC / verification status

Apps con KYC requieren verificación antes de operaciones de alto valor (transfer >1000€). Si el endpoint:

bash
POST /api/account/verify-kyc → marca user como verified

está rate-limited pero no race-protected, y existe un endpoint:

arduino
POST /api/transfer/large → require user.kycVerified

Race entre verify (que pasa o no pasa) y la transferencia puede colar la transferencia mientras KYC se procesa en async. Bug menos común pero alta severidad en fintech.


Cómo distinguir race real de "el server las cola"

Algunos servers serializan requests al mismo recurso (lock optimista, mutex). Identificar:

python
# Tras race attack, observa los status codes en orden
[200, 200, 409, 409, 409, 200, ...]    ← parcialmente protegido
[200, 200, 200, 200, 200, 200, ...]    ← race exitoso (todos pasan)
[200, 409, 409, 409, ...]              ← solo 1 pasa, los demás rechazados → bien protegido

Si todos responden 200 pero el estado final solo refleja 1 operación → race exitoso pero deduplicación posterior → puede que el bug exista pero impacto reducido. Investiga el estado final (DB query, balance check).


Mejorando la probabilidad de hit

  • HTTP/2 vs HTTP/1.1: H2 sincroniza mejor (single packet con muchos streams).
  • Geographic proximity: usa servidor cercano al target. Si target está en us-east, lanza desde us-east. Reduce jitter.
  • Connection pre-warmed: turbo-intruder mantiene conexiones abiertas → el handshake TCP no cuenta para el race.
  • Repeat & average: ningún race attack hit 100% — repite 5-10 veces, observa máximo paralelismo conseguido.

[!warning] Test responsible Race exploits pueden corromper estado en producción. Test en sandbox del program si existe. Si no, limita races a 5-10 paralelos (no 100), trabaja en tu propia cuenta, y avisa antes de testing.


Reporting

  • Severity factor: financiero (refund/discount/transfer) → Critical típico. Logic flaw no monetario (free trial abuse, vote stuffing) → High/Medium.
  • PoC mínimo: video grabando carrito antes (precio normal) → carrito después (negative price). Sin razones para extraer N × dinero real.
  • Fix sugerido: row-level lock (SELECT ... FOR UPDATE), unique constraint on (couponId, userId), idempotency keys, transactional findAndModify con check incluido.

Hunting checklist

  • Mapea endpoints que tocan dinero/stock/permisos/credits.
  • Para cada endpoint: ¿lee estado, decide, y luego escribe? Ventana = race surface.
  • Captura request en Burp → Turbo Intruder → single-packet H2 con gate.
  • Cupón apply: race 20 paralelos → ¿discount > 100% o N veces aplicado?
  • Stock: race 20 paralelos al último ítem → ¿stock negativo?
  • Checkout: race 5 paralelos al mismo carrito → ¿pago único pero órdenes múltiples?
  • Refund: race 10 paralelos → ¿balance += 10 × precio?
  • Referral signup: race con mismo code → ¿N referidos por 1?
  • KYC / verification status: race entre verify y operación de alto valor.
  • Confirma con scan del estado final (DB / API) — los status codes 200 no garantizan que pasaron.
  • Reporta con PoC mínimo (5-10 races máx) + fix sugerido (row-level lock).

Labs relacionados

Practica single-packet H2, cupón stacking, stock negativo y double checkout en labs de race conditions.

Practica esto en un lab

Race Conditions Marketplace

Resolver

Sigue aprendiendo · cuenta gratis

Guarda tu progreso, desbloquea payloads avanzados y rankea tus flags.

Crear cuenta
Premium · 1 técnica más

Hay un payload extra al final

El single-packet attack con HTTP/2 que vence rate limits y races contra mutex pequeños, ganando $8400 bounty en marketplace de bookings.

Desbloquear

5 €/mes · cancela cuando quieras

Artículos relacionados