Key Takeaways

  • Race conditions exploit the gap between when an app checks a condition and when it acts on it — the TOCTOU window
  • The HTTP/2 single-packet attack technique sends multiple requests in one TCP packet, eliminating network jitter and making races reliable
  • High-value targets: payment processing, coupon redemption, account balance transfers, voting systems, and any endpoint with a "once per user" limit
  • Most web frameworks do NOT protect against race conditions by default — developers must explicitly implement locking or idempotency
  • You don't need exotic tools — Burp Turbo Intruder, Python asyncio, or even curl with HTTP/2 multiplexing can trigger races

What Are Race Conditions and Why Should Bug Bounty Hunters Care?

A race condition happens when an application assumes requests arrive one at a time — but you send them simultaneously. The server checks a condition (do you have enough balance?), then acts on it (deduct the balance). If two requests hit the check before either reaches the act, both pass validation and both execute.

This is the TOCTOU pattern: Time of Check to Time of Use. The state is valid at check time but changes before use time — except when two requests race, neither sees the other's changes.

Bug bounty programs care because race conditions cause real financial damage. A race on a payment endpoint can drain accounts. A race on a coupon endpoint means unlimited discounts. A race on a "one free trial per user" endpoint means infinite free trials. These are not theoretical — they happen on production systems every day.

The Five Race Condition Patterns That Pay

1. Limit Bypass — "Once Per User" Becomes "Many Per User"

Any feature that limits an action to once per user (or N times per user) is a race target. The app checks "has this user already done X?" and then records "user did X." Race the check.

Examples:

2. Balance / Inventory Double-Spend

The classic financial race. You have $100. You send two $100 transfers simultaneously. Both requests check your balance ($100 >= $100 ✓), both deduct, and the recipient gets $200 while your balance goes to -$100 (or wraps to $0 if unsigned).

Where to look:

3. Privilege Escalation via State Race

Some applications check your role, then perform an action. If you can change your role between the check and the action — or if two requests with different roles race — you can escalate privileges.

Example: An admin removes your moderator role while you simultaneously perform a moderator action. The role check passes (you're still a moderator when checked), but by the time the action executes, the removal has also completed. The result depends on execution order — sometimes the action succeeds after the role is gone.

4. Multi-Endpoint Races (Business Logic)

Not all races happen on a single endpoint. Some require racing two different endpoints that share state.

Example: Endpoint A checks your subscription status and grants access to premium content. Endpoint B processes your subscription cancellation and refund. Race them: get the refund AND keep access, because each endpoint only locks its own operation.

5. File Upload / Processing Races

Applications that upload a file, validate it, then process it have a race window between validation and processing. Upload a valid file, then immediately overwrite it with a malicious one before processing begins.

This is less common in modern cloud architectures (where files get unique keys), but still appears in legacy systems and self-hosted platforms.

Tools and Techniques

HTTP/2 Single-Packet Attack (The Gold Standard)

Network jitter is the enemy of race condition testing. If your 10 requests arrive over a 50ms window, the server processes them sequentially and the race never triggers. The HTTP/2 single-packet attack solves this.

HTTP/2 multiplexes multiple requests over a single TCP connection. By withholding the final bytes of all requests and then sending them in a single TCP packet, all requests arrive at the server's HTTP/2 layer simultaneously. The server must process them in parallel — there is no network jitter to separate them.

Burp Turbo Intruder setup:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           engine=Engine.BURP2)  # HTTP/2 engine

    # Queue 20 identical requests
    for i in range(20):
        engine.queue(target.req)

    # Send all at once — single-packet attack
    engine.openGate('race1')

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

The key is concurrentConnections=1 — all requests go through one connection, one packet.

Python + asyncio (Free Alternative)

import asyncio
import aiohttp

async def send_request(session, url, data, headers):
    async with session.post(url, json=data, headers=headers) as resp:
        return resp.status, await resp.text()

async def race(url, data, headers, count=20):
    async with aiohttp.ClientSession() as session:
        tasks = [send_request(session, url, data, headers) for _ in range(count)]
        results = await asyncio.gather(*tasks)
        for status, body in results:
            print(f"{status}: {body[:100]}")

asyncio.run(race(
    "https://target.com/api/redeem-coupon",
    {"code": "SAVE50"},
    {"Authorization": "Bearer <token>", "Content-Type": "application/json"},
    count=20
))

This is not as precise as the single-packet attack (asyncio introduces microsecond-level jitter), but it works surprisingly often because many race windows are milliseconds wide.

curl with HTTP/2 Multiplexing

# Send 10 parallel requests over a single HTTP/2 connection
seq 1 10 | xargs -P10 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
  --http2 \
  -X POST https://target.com/api/apply-coupon \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"code":"SAVE50"}'

Step-by-Step: Hunting for Race Conditions

Step 1: Map State-Changing Endpoints

Crawl the target and identify every endpoint that modifies server-side state. Focus on:

If calling an endpoint twice gives you an error the second time, that endpoint has a limit — and limits are race targets.

Step 2: Understand the Expected Behavior

Before racing, establish the baseline. Send the request once normally. What happens? Send it again. What error do you get? This tells you what the check-then-act pattern looks like.

Example baseline:

The race goal: get multiple 200 OK responses instead of one 200 and one 400.

Step 3: Fire the Race

Send 10-50 identical requests simultaneously using your chosen tool. Count the successes.

What to look for:

Step 4: Increase Confidence

One successful race could be a fluke. Run it 5-10 times and track your success rate. If you get duplicate state changes in 3 out of 10 attempts, that's a reliable race condition — not a one-off glitch.

Also vary the parallelism. Some races only trigger with exactly 2 concurrent requests. Others need 20+. Start with 10 and adjust.

Step 5: Measure the Impact

Triagers need to see business impact, not just "I sent requests fast." Quantify it:

Common Defenses (And How to Test If They Work)

Database-Level Locking

SELECT ... FOR UPDATE locks the row until the transaction commits. This is the strongest defense. Test it by racing — if the app uses proper row locking, you'll get exactly one success and the rest will block or fail.

Unique Constraints

A unique index on (user_id, coupon_id) prevents duplicate redemptions at the database level. The race will produce duplicate key errors for the losing requests. This works but only protects against exact duplicates — it won't catch balance races.

Idempotency Keys

The client sends a unique key with each request. The server rejects duplicate keys. Test by sending the same idempotency key in parallel — if the defense works, only one succeeds. Then test WITHOUT the key (or with different keys) to see if the race still works.

Application-Level Mutexes

Redis locks, in-memory mutexes, or distributed locks. These are often implemented incorrectly — the lock might not cover the entire check-then-act window, or it might use a non-atomic check-and-set operation (which is itself raceable). Always test even when you see locking code.

Writing the Report

Race condition reports get rejected more than most vulnerability types because triagers can't easily reproduce them. Make reproduction foolproof:

  1. State the precondition: "Account has $100 balance" or "Coupon SAVE50 has not been used"
  2. Provide the exact tool and config: Include your Turbo Intruder script or Python code
  3. Show before and after: Screenshot the balance/state before the race, then after
  4. Include all request/response pairs: Show that multiple requests got success responses
  5. Quantify the success rate: "Succeeded in 7 out of 10 attempts with 20 parallel requests"
  6. State the business impact: Don't say "race condition exists." Say "attacker can generate unlimited store credit at $10 per race attempt"

Real-World Patterns From Public Disclosures

GitHub — Duplicate Sponsorship Charges

Researchers found that racing the GitHub Sponsors payment endpoint could create duplicate charges. The check for "already sponsored this month" and the "create sponsorship" action were not atomic. GitHub paid this as a High severity finding.

Shopify — Gift Card Double-Spend

Multiple researchers have reported gift card balance races on Shopify stores. Apply a $50 gift card to two orders simultaneously — both orders get the discount, but the card is only debited once. This is the textbook double-spend pattern.

HackerOne — Invitation Acceptance Race

Racing the "accept program invitation" endpoint could add a researcher to a program multiple times, bypassing the single-acceptance check. Low severity but a clean example of the limit-bypass pattern.

Checklist: Race Condition Hunting

Frequently Asked Questions

Do I need Burp Suite Pro to test for race conditions?

No. Turbo Intruder is a free Burp extension that works with Burp Community Edition. You can also use Python with asyncio/aiohttp, the race-the-web Go tool, or curl with HTTP/2 multiplexing. Burp Pro's Repeater group-send feature is convenient but not required.

How wide is a typical race window?

It varies enormously. A race between two SQL queries in the same request handler might have a window of 1-5 milliseconds. A race involving an external API call or queue processing might have a window of 100ms+. The single-packet attack works because it eliminates network jitter — your requests arrive within microseconds of each other, which is enough for most windows.

Will race condition testing break the target application?

It can. Double-spend races can create negative balances or orphaned records. Always test on staging environments when available, use test accounts, and start with low-impact endpoints (voting, following) before testing financial operations. If the program's policy prohibits automated testing, ask before running race tests.

Are race conditions out of scope for most programs?

No — most programs accept race conditions if you can demonstrate real impact. However, some programs explicitly exclude "theoretical" race conditions or require proof of financial impact. Read the program policy. If it says "no automated testing," the single-packet attack might violate that rule — clarify with the program first.

What's the difference between a race condition and a TOCTOU bug?

TOCTOU (Time of Check to Time of Use) is a specific type of race condition where the vulnerability is in the gap between checking a condition and acting on it. All TOCTOU bugs are race conditions, but not all race conditions are TOCTOU — some involve ordering issues, deadlocks, or atomicity failures that don't follow the check-then-act pattern.

Advertisement