Intermediate levelWith account

Race conditions in a marketplace — coupons applied N times, negative stock, double checkout

How to identify and exploit race conditions in e-commerce flows: discount coupons applied multiple times, products with 0 stock sold, double checkout for refunds.

Gorka El BochiMay 11, 202614 min

Quick answer

Marketplace race conditions are the most profitable scenario of 2026 — devs think about SQL transactions but not about simultaneous HTTP. The rule: any endpoint that reads state, decides, and then writes is vulnerable if the window between read and write exceeds 1ms. Patterns: coupon applied N times (non-atomic claim/use), stock sold beyond inventory (non-atomic decrement), double checkout (transfer starts before locking), refund applied multiple times. Tooling: Turbo Intruder with single-packet HTTP/2 synchronizes requests to <1ms. Typical bounties: $1,500-$15,000.


Why races happen

The web is transactional, databases are transactional — but the code in between rarely is. A typical "use coupon" endpoint:

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");
});

The bug: between findOne (line 2) and updateOne (line 4) there's a window of ~5-50ms. If two requests arrive in that window, both see used: false, both pass the validation, both apply the discount.

The problem space

Code patternRace windowExploitation probability
Read → decide → write (same process)1-10msHigh
Cache check → DB write10-100msVery high
Microservices (call A → call B)50-500msAlmost guaranteed
Webhook + state update100ms-secondsAlmost guaranteed

Tooling — single-packet attack

Turbo Intruder (Burp extension) implements the single-packet attack documented by James Kettle (2023): it packs N requests into a single TCP/HTTP-2 packet, arriving at the server simultaneously. It beats:

  • Network jitter (variable latency between requests)
  • Per-IP rate limits that count per second
  • Small mutexes (<10ms)

Base script

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" makes the 20 requests prepare but wait — then openGate releases them all at once with a single packet.

[!info] HTTP/2 advantage HTTP/2 allows multiple streams on one TCP connection. The final frame of the headers of N requests fits in a single IP packet → they reach the server microseconds apart, not milliseconds.

<!-- PAYWALL -->

Pattern 1 — coupon applied N times

Marketplace with single-use coupons. Endpoint POST /api/coupons/apply with {"code": "SUMMER20"} applies a 20% discount.

Test

  1. Create a cart of 100€.
  2. Capture the apply-coupon request in Burp Repeater.
  3. Send to Turbo Intruder, fire 20 simultaneous with a gate.
  4. Refresh the cart: do you have 20% × 20 = 400% discount → negative price? Bug.

Variants

  • Discount stack: applying the same code N times stacks it arithmetically.
  • Code consumption: the code is marked used but the applied propagates to the cart before the mark.
  • Different simultaneous carts: apply the same coupon to N parallel carts before it's marked used.

Impact framing

  • Discount > 100% → refund above the price → "store credit" extracted.
  • Coupon "first order $50 off" used 50 times → $2500 stolen.

Real bounty: H1 booking program, $8,400 — combination with HTTP/2 single-packet bypassing an anti-bot rate limit.


Pattern 2 — negative stock

The "add to cart" / "reserve product" endpoint decrements stock but the typical flow:

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

20 simultaneous requests, each asking for the last item in stock → all see stock: 1 → all pass the validation → 20 reservations for 1 available unit.

Profitable patterns

  • Limited-edition drop (sneakers, NFT-like collectibles): reserve 20 items of the only available one → resale or demand delivery of 20.
  • Subscription with free trial: race between "check if user had trial" and "mark trial as used" → N trials.
  • Single-use discount code per user: the same, applied per user.

Quick detection

Can the product go to stock: -19? Verify with a quick scan via the admin API (if you have test access) or with the product detail page after the race.


Pattern 3 — double checkout

The POST /api/checkout/finalize endpoint runs:

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

The window between step 1 (reads balance) and step 4 (mark paid) is huge because step 3 takes 500ms-2s (external network). 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ó!

Result: two paid orders but only one was charged. Variant: charge 100€ × 2 to the card but you only receive 1 order → the attacker requests a refund for 1 and keeps the other.

The inverted attack: refund race

The /api/orders/X/refund endpoint can run N times before marking the order as 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
       ...

Original payment 100€, refunds N × 100€ → the attacker's balance goes from 0 to 1000€ with 10 races.


Pattern 4 — referral code / signup bonus

The signup endpoint checks whether the referral code is used by this 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 on step 1: 20 simultaneous signups with the same referral → 20 new users created → 20× credits to the referrer.

Marketplace with a €5 bonus per referral → race 20 → €100 stolen. Scaled with sock puppets → thousands.


Pattern 5 — KYC / verification status

Apps with KYC require verification before high-value operations (transfer >1000€). If the endpoint:

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

is rate-limited but not race-protected, and there's an endpoint:

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

A race between verify (which passes or doesn't) and the transfer can slip the transfer through while KYC is processed async. A less common bug but high severity in fintech.


How to distinguish a real race from "the server queues them"

Some servers serialize requests to the same resource (optimistic lock, mutex). Identify:

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

If they all respond 200 but the final state reflects only 1 operation → successful race but later deduplication → the bug may exist with reduced impact. Investigate the final state (DB query, balance check).


Improving the hit probability

  • HTTP/2 vs HTTP/1.1: H2 synchronizes better (single packet with many streams).
  • Geographic proximity: use a server close to the target. If the target is in us-east, launch from us-east. Reduces jitter.
  • Pre-warmed connection: turbo-intruder keeps connections open → the TCP handshake doesn't count toward the race.
  • Repeat & average: no race attack hits 100% — repeat 5-10 times, observe the maximum parallelism achieved.

[!warning] Test responsibly Race exploits can corrupt state in production. Test in the program's sandbox if it exists. If not, limit races to 5-10 parallel (not 100), work on your own account, and warn before testing.


Reporting

  • Severity factor: financial (refund/discount/transfer) → typically Critical. Non-monetary logic flaw (free trial abuse, vote stuffing) → High/Medium.
  • Minimal PoC: a video recording the cart before (normal price) → cart after (negative price). No reason to extract N × real money.
  • Suggested fix: row-level lock (SELECT ... FOR UPDATE), unique constraint on (couponId, userId), idempotency keys, transactional findAndModify with the check included.

Hunting checklist

  • Map endpoints that touch money/stock/permissions/credits.
  • For each endpoint: does it read state, decide, and then write? Window = race surface.
  • Capture the request in Burp → Turbo Intruder → single-packet H2 with a gate.
  • Coupon apply: race 20 parallel → discount > 100% or applied N times?
  • Stock: race 20 parallel on the last item → negative stock?
  • Checkout: race 5 parallel on the same cart → single payment but multiple orders?
  • Refund: race 10 parallel → balance += 10 × price?
  • Referral signup: race with the same code → N referrals for 1?
  • KYC / verification status: race between verify and a high-value operation.
  • Confirm with a scan of the final state (DB / API) — 200 status codes don't guarantee they passed.
  • Report with a minimal PoC (5-10 races max) + suggested fix (row-level lock).

Practice single-packet H2, coupon stacking, negative stock and double checkout in race conditions labs.

Practice this in a lab

Race Conditions Marketplace

Solve

Keep learning · free account

Save your progress, unlock advanced payloads and rank your flags.

Create account
Premium · 1 more technique

There's an extra payload at the end

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

Unlock

€7.99/mo · cancel anytime

Related articles

hunters training
711

hunters training

labs from real reports
55

labs from real reports

completions
1,205

completions

in bounties practiced
$213,970

in bounties practiced

46 flags captured this week·Real reports from HackerOne · Bugcrowd · Intigriti·No commitment·Free Academy