Race Condition Vulnerabilities: Testing TOCTOU Flaws in Web Applications

An e-commerce site allows each user account to redeem a referral code exactly once. The check is simple: look up the code in the database, verify it hasn't been used, apply the discount, then mark it as used. Straightforward. Except an attacker fires 50 simultaneous POST requests before the database has a chance to mark the code as used. The check passes in all 50 threads because none of them see the updated state yet. All 50 requests succeed. The attacker receives 50x the discount they were entitled to — and the application's one-time limit never actually triggered.

This is a race condition. It's one of the most underappreciated vulnerability classes in web application security, and it's far more common than most teams expect. Unlike SQL injection or XSS, race conditions don't leave obvious signatures in request parameters. They exploit the gap between when your application checks a condition and when it acts on it — a gap that exists in almost every multi-step operation that enforces a limit.

What TOCTOU Actually Means

TOCTOU stands for Time-of-Check to Time-of-Use. It describes the fundamental structure of the vulnerability: there is a moment when the system checks whether an operation is permitted (the check), and a separate moment when it actually performs the operation (the use). If those two moments are not atomic — meaning another thread or process can modify the relevant state between them — an attacker can exploit the gap.

The classic TOCTOU pattern looks like this in pseudocode:

# Thread A and Thread B execute this simultaneously
def redeem_coupon(user_id, code):
    coupon = db.query("SELECT * FROM coupons WHERE code = ? AND used = 0", code)
    if not coupon:
        return error("Invalid or already used")

    # --- GAP: both threads are here at the same time ---

    db.execute("UPDATE coupons SET used = 1, redeemed_by = ? WHERE code = ?", user_id, code)
    apply_discount(user_id, coupon.value)
    return success()

If Thread A and Thread B both complete the SELECT before either executes the UPDATE, both see used = 0. Both pass the check. Both apply the discount. The database ends up with used = 1 (the last write wins), but the damage is done — the discount was applied twice.

The window between check and use can be microseconds wide. That's enough. On a loaded server with multiple worker processes, the operating system scheduler practically guarantees that concurrent requests will interleave at exactly the wrong moment if you send enough of them simultaneously.

Types of Race Conditions in Web Applications

Limit Overruns

This is the most common category. Any endpoint that enforces a numeric limit — and implements that limit as a check-then-modify sequence — is a candidate. Common examples include:

The unifying pattern: the application reads a value, decides it's within bounds, then modifies it. Any concurrent request that reads the same value before the modification completes will make the same decision.

Double-Spend and Double-Withdraw

Financial applications are particularly sensitive to this category. A user requests a withdrawal of their full account balance. If the application reads the balance, confirms sufficient funds, then processes the withdrawal — and two simultaneous withdrawal requests both read the original balance before either completes — both withdrawals may succeed, leaving the account in a negative state (or processing a fraudulent double payment).

This is directly analogous to the classic double-spend problem in distributed systems. The application-layer check is not sufficient. The balance read and the balance deduction must happen as a single atomic operation, or the race window remains open.

Payment processing systems that call out to external payment providers introduce an additional complication: the window between "charge the card" and "record the transaction in our database" can be hundreds of milliseconds wide. If the user can trigger two concurrent checkout requests during that window, both may succeed — the payment provider charges twice, or the order fulfillment system ships twice.

Privilege Escalation During State Transitions

Some race conditions don't involve numeric limits at all. They exploit transitions in state that the application treats as instantaneous but aren't. Consider:

File-Based Race Conditions

Server-side file operations introduce their own TOCTOU surface. An application that accepts file uploads, validates them (checks the extension, scans for malware, verifies the MIME type), then moves the file to a processing directory may be vulnerable if an attacker can swap the file content between the check and the move. Similarly, temp file creation using predictable paths can allow symlink attacks where an attacker creates a symlink at the temp path before the application writes there. These are more common in server-side processing pipelines and batch jobs than in typical API endpoints, but they're worth noting in any application that handles file processing.

Testing Methodology

Step 1: Identifying Candidates

Not every endpoint is worth race testing. Focus your effort on endpoints that meet all three of these criteria:

  1. They check something — a balance, a usage count, a boolean flag, a token's validity
  2. They modify state — they update a record, decrement a counter, invalidate a token, record a transaction
  3. The limit has meaningful business impact — bypassing it gives the attacker something valuable (money, access, discounts, quota)

Candidates to prioritize in almost every application: coupon/promo code redemption, password reset flows, account balance operations, subscription upgrade/downgrade, referral credit systems, free trial activation, invitation link consumption, and any one-time-use token endpoint.

During recon, map the application's state-changing POST and PUT endpoints. Any that return a specific error message like "already used," "limit reached," or "insufficient balance" are explicitly enforcing a check-based limit — they've told you exactly where the TOCTOU window lives.

Step 2: Burp Suite Parallel Requests

Burp Suite Pro's Repeater has supported parallel request sending since the "single-packet attack" technique was popularized in James Kettle's research at PortSwigger. The workflow:

  1. Capture the target request in Burp Proxy.
  2. Send it to Repeater.
  3. Duplicate the tab as many times as needed (typically 20–50 copies for limit bypass tests).
  4. In Repeater, select all tabs and use "Send group in parallel" (not "in sequence").
  5. Burp will use HTTP/2 multiplexing where available to send all requests within a single TCP connection, eliminating network jitter between them.
  6. Review the responses — if multiple requests receive a "success" response that should only fire once, the endpoint is vulnerable.

For targets that only support HTTP/1.1, Burp's Turbo Intruder extension provides more control over timing. You can stagger request timing to probe different parts of the race window, or use the "single-packet" technique with HTTP/1.1 pipelining where supported.

Step 3: Python Threading for Custom Race Tests

For more control — or when you need to test outside of Burp — a simple Python threading script gives you direct control over concurrency. The key is to start all threads before any of them fire, so they hit the server as close to simultaneously as possible:

import threading
import requests

SESSION_COOKIE = "your-session-cookie"
TARGET_URL = "https://target.example.com/api/redeem"
PAYLOAD = {"code": "SUMMER50"}

def redeem():
    r = requests.post(TARGET_URL,
                     json=PAYLOAD,
                     cookies={"session": SESSION_COOKIE})
    print(f"Status: {r.status_code}, Response: {r.text[:100]}")

threads = [threading.Thread(target=redeem) for _ in range(20)]
[t.start() for t in threads]
[t.join() for t in threads]

This script creates 20 threads and starts them all before any have completed. The OS scheduler will interleave them, and the requests will arrive at the server within a very tight window. A more precise approach uses a threading.Barrier to synchronize thread execution — all threads wait at a barrier until all 20 are ready, then release simultaneously:

import threading
import requests

SESSION_COOKIE = "your-session-cookie"
TARGET_URL = "https://target.example.com/api/redeem"
PAYLOAD = {"code": "SUMMER50"}
NUM_THREADS = 20

barrier = threading.Barrier(NUM_THREADS)

def redeem():
    barrier.wait()  # all threads wait here until all are ready
    r = requests.post(TARGET_URL,
                     json=PAYLOAD,
                     cookies={"session": SESSION_COOKIE})
    print(f"Status: {r.status_code}, Response: {r.text[:100]}")

threads = [threading.Thread(target=redeem) for _ in range(NUM_THREADS)]
[t.start() for t in threads]
[t.join() for t in threads]

The barrier approach produces a tighter burst. All 20 threads prepare their requests simultaneously and release together, maximizing the probability of hitting the server's race window. For asyncio-based testing, asyncio.gather() provides similar concurrency with lower per-thread overhead at higher request counts.

The Single-Packet Attack and HTTP/2 Multiplexing

Traditional race condition testing is frustrated by network jitter. Even if you fire 20 requests "simultaneously," TCP round-trip time means each request arrives at the server at a slightly different moment. On a remote target, a 20ms RTT spread across 20 requests means the requests may arrive up to 20ms apart — far wider than most race windows.

HTTP/2 solves this with multiplexing: multiple requests can be sent within a single TCP connection, in a single packet, arriving at the server at exactly the same instant. The server's application layer receives all of them simultaneously and has to process them concurrently — which is precisely what you need to reliably trigger a race condition.

Burp Suite Pro's Turbo Intruder extension implements this natively. The single-packet-attack.py template is designed exactly for race condition testing. It queues all requests, then sends them in a single HTTP/2 DATA frame. The server's kernel accepts all of them at once and hands them to the application worker pool simultaneously. This technique was first documented by James Kettle at PortSwigger and represents the current state of the art for race condition testing in web applications.

For HTTP/1.1-only targets, the technique degrades to "last-byte synchronization" — sending all requests with the final byte withheld, then flushing all final bytes simultaneously. This is less reliable but still significantly better than firing requests sequentially.

Signs of Vulnerability

When you run parallel requests against a vulnerable endpoint, you'll typically see one of these response patterns:

Pattern What it means
Multiple 200 OK success responses for a one-time action Classic limit overrun — the check passed before any update completed
Mix of success and error responses, but more successes than expected Intermittent race — window exists but is narrow; increase concurrency
Identical responses with slightly different response times Suggests requests are being serialized somewhere — look for locking at a different layer
All errors except one, but a side effect was applied multiple times The operation completes multiple times even if only one response claims success — check the actual state in the database
Application error / 500 on some requests Uncaught concurrency exception — often a unique constraint violation that the application didn't handle, which itself confirms a race window

The last point is worth emphasizing: a database unique constraint violation returned as a 500 error is actually evidence that the race condition exists and that the database caught what the application missed. It's a partial fix — the operation didn't succeed — but the application is clearly not handling concurrency correctly, and other endpoints without that constraint may not be protected.

Always verify the actual state change, not just the HTTP response. A coupon endpoint might return an error for 19 out of 20 requests while still applying the discount 20 times if the application code applies the discount before checking whether it already applied it. Check the database directly, or observe the downstream effect (account balance, order history, usage counter) to confirm exploitation.

Real-World Examples

Gift Card Balance Bypass

A retail application allows gift card redemption at checkout. The card's balance is checked, applied to the cart total, then decremented. An attacker with a $10 gift card fires 10 simultaneous checkout requests for $10 items. All 10 read the $10 balance before any write completes. All 10 apply the full balance. The card is decremented to $0 after all threads complete (the final write wins), but the discount was applied 10 times — $100 of value extracted from a $10 card.

Password Reset Token Reuse

A password reset endpoint accepts a token, validates it hasn't been used, sets the new password, then marks the token as used. An attacker who captures a reset token (via phishing or a compromised email account) fires two simultaneous requests with different passwords. Both pass the "token valid and unused" check in parallel. The first request sets password A; the second sets password B. The token is marked used after both complete. The attacker can now log in with password B regardless of whether the legitimate user also completed the flow — and the token wasn't protected from reuse during the race window.

Free Tier Quota Bypass

A SaaS application enforces a 100 API calls/month limit on the free tier. The counter is stored in a usage table and checked at the start of each request: if usage >= 100, reject. An attacker fires 200 simultaneous API calls when their counter is at 99. All 200 read 99 < 100, proceed, and complete. The counter increments to 299. The limit was bypassed entirely for the burst of requests, and the attacker extracted 200 calls instead of 1.

Double Withdrawal in Financial Applications

Perhaps the most financially damaging variant. A wallet application reads the user's balance, confirms it covers the withdrawal amount, processes the outbound transfer via a payment rail, then decrements the stored balance. Two concurrent withdrawal requests for the full balance both read the original amount, both confirm sufficient funds, both initiate outbound transfers. The balance is decremented twice — potentially to a negative value — and two transfers complete. This exact pattern has appeared in real bug bounty disclosures for fintech applications, sometimes with payouts in the five-figure range reflecting the severity.

Remediation

Database-Level Atomicity

The most reliable fix moves the check and the modification into a single atomic database operation. Several patterns achieve this:

SELECT FOR UPDATE acquires a row-level lock on the selected row, preventing any other transaction from reading or modifying it until the current transaction commits:

BEGIN;
SELECT * FROM coupons WHERE code = 'SUMMER50' AND used = 0 FOR UPDATE;
-- if row found:
UPDATE coupons SET used = 1, redeemed_by = 42 WHERE code = 'SUMMER50';
COMMIT;

The lock ensures that a concurrent transaction trying to redeem the same code will block until the first transaction commits. When it unblocks, it re-reads the row and sees used = 1, failing the check. No race window.

Compare-and-swap via conditional UPDATE is often simpler and avoids explicit locking:

UPDATE coupons
SET used = 1, redeemed_by = 42
WHERE code = 'SUMMER50' AND used = 0;
-- check rows_affected: if 0, someone else got there first

This atomically checks and updates in a single statement. The database guarantees that only one concurrent transaction can win — all others will see rows_affected = 0 and know they lost the race.

UNIQUE constraints enforce uniqueness at the database level, turning a race condition into a database constraint violation rather than silent double-application. For systems that record each redemption as a row in a ledger table, a unique constraint on (user_id, coupon_id) ensures that even if two threads get through the application-layer check, only one can insert successfully.

Application-Level Concurrency Controls

When database-level atomicity isn't sufficient or the operation spans multiple systems, application-level locking is required:

Distributed locks via Redis SETNX (SET if Not eXists) allow you to acquire a named lock before entering a critical section:

lock_key = f"redeem:{user_id}:{code}"
acquired = redis.set(lock_key, "1", nx=True, ex=30)  # 30s expiry
if not acquired:
    return error("Request in progress, please retry")

try:
    # perform check and modification
    ...
finally:
    redis.delete(lock_key)

The nx=True flag makes the SET operation atomic — only one caller can acquire the lock. Others receive None and must retry or fail. The expiry prevents deadlock if the lock holder crashes.

Idempotency keys are a complementary pattern borrowed from payment APIs. The client generates a unique key per logical operation (a UUID tied to "this checkout attempt") and includes it in the request. The server stores processed idempotency keys and returns the cached result for duplicate requests, regardless of concurrency. This is the standard pattern used by Stripe and other payment processors to handle client retries safely — and it effectively collapses all concurrent requests with the same key into a single operation.

Mutexes in application code can work within a single-process server, but are not sufficient in distributed deployments where the application runs on multiple nodes. A threading.Lock() in Python protects against races within one process; it does nothing to protect against races between two different server instances.

What Not to Rely On

Application-layer rate limiting — implemented by checking a counter before processing a request — is itself vulnerable to race conditions. A rate limiter that reads a request count, checks if it's below the limit, then increments it is exactly the pattern described throughout this article. Rate limiting must be implemented atomically (Redis INCR with EXPIRE, or atomic database increments) to be race-safe. Never assume that because you have a rate limiter, you're protected from limit bypass races.

Similarly, session-based checks ("I'll remember in the session that this code was used") don't protect against races across multiple sessions or multiple server instances sharing a database. The source of truth for any limit must be the shared persistence layer, and access to it must be atomic.

What Automated Scanners Can and Can't Do

Race condition testing occupies an interesting place in the automated scanning landscape. Pure automated scanners — including Ironimo — can identify candidates for race condition testing with reasonable accuracy. Static analysis of endpoint behavior, response patterns that suggest limit enforcement ("already used," "limit reached," "insufficient balance"), and structural patterns in API responses all point toward endpoints that deserve manual race testing.

What automated scanners can realistically detect:

What requires manual verification:

The practical testing workflow is therefore: use automated scanning to surface the candidates, then apply manual parallel testing with Burp Suite or the threading scripts above to confirm exploitability. Don't write off an endpoint just because the automated scanner didn't flag it as vulnerable — scanner-confirmed safe and actually safe are not the same thing for race conditions.

Race conditions remain one of the few vulnerability classes where the gap between "automated detection" and "confirmed exploitation" is genuinely wide. The manual testing techniques in this article are not optional refinements — they are required for any serious assessment of an application that handles financial operations, one-time tokens, or enforced usage limits.

Ironimo's automated scanning tests for common race condition patterns — including limit bypass on critical endpoints, coupon code reuse, and concurrent modification checks — without manual setup or custom scripts.

Start free scan

← Back to Blog