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

Stop reading about bugs and start hunting them

No card · free Academy · cancel anytime

Create free account

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 100€
T=10ms request2 → step 1: balance 100€ (no actualizado todavía)
T=500ms request1 → step 4: paid + balance 0€
T=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.

Free · no account

The checklist I run on every new target

47 checks ordered by cost: first what can get you in trouble, then the cheap stuff, and finally the expensive stuff — which is where the big bounties are. I'll send it to your inbox right now.

Unsubscribe in one click, from any email.

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

4,183

hunters training

68

labs from real hacks

2,125

completions

$14,790

paid out for these bugs

6 flags captured this week·Real hacks from HackerOne · YesWeHack · Bugcrowd·No commitment·Free Academy

The community

This isn't a platform with users.It's people breaking the same things you are.

Inside people ask, share what they found and solve together what would block anyone on their own. It's free to join: no account, no plan, no email required.

Join the BBLABS community on Discord
Join the Discord

Free and open. You don't need a BBLABS account to join.