How does a race condition work?
A race condition happens when an application does something in two steps, a check and an act, and assumes nothing changes in between. It checks that a coupon is unused, then marks it used. It checks that a balance covers a withdrawal, then debits it. Between those two steps there is a window, usually a few milliseconds, in which the state the check read is still true even though it is about to change. Send two requests timed to land in that window and both read the pre-change state, both pass the check, and both act.
This is the time-of-check to time-of-use (TOCTOU) pattern. In a web application the shared resource is usually a database row, and the window is however long the application holds between reading a value and committing the write.
Consider a gift-card redemption worth $50, meant to be used once:
POST /wallet/redeem HTTP/1.1
Host: acme.example
Content-Type: application/json
Cookie: session=user_4821
{"code": "GC-4K9P-22XA"}The handler reads the card, sees redeemed = false, credits the account, and sets redeemed = true. Fire this request 20 times simultaneously and, if each copy reads false before any copy writes true, the account is credited many times from one card. The same shape produces coupon reuse beyond its limit, exceeding a per-user purchase cap, overdrawing a balance, and creating more of a rate-limited resource than allowed.
The single-packet attack
Exploiting these reliably used to be hard over the network, because requests sent back to back still arrive spread out by jitter, and the window is tiny. James Kettle of PortSwigger published the single-packet attack in the 2023 research Smashing the state machine, which removed network timing as a variable.
The technique uses HTTP/2. The attacker sends the bulk of 20 to 30 requests while withholding the final frame that completes each one, then releases all the withheld frames together so they arrive in a single TCP packet. Every request is processed at effectively the same instant, regardless of network jitter. In Kettle's benchmarks the single-packet attack cut the spread between requests to about 1ms, against roughly 4ms for the older last-byte-synchronization method over HTTP/1.1, and turned exploits that took hours of attempts into ones that landed in seconds. The practical result: a limit-overrun that was theoretically racy is now dependably exploitable, so "it's a narrow window" is no longer a mitigation.
What does it look like in practice?
- Limit overrun. Redeeming a single-use voucher many times, applying a coupon past its cap, or withdrawing a balance more than once.
- Multi-use of a one-time token. Consuming a password-reset or MFA token more than once if it is validated then invalidated in separate steps.
- State-machine races. Two conflicting transitions fired at once, for example cancel and ship, leaving an object in an inconsistent state. This overlaps with a business logic vulnerability.
- TOCTOU on uniqueness. Two sign-ups racing to claim the same username or two requests both passing a "does this already exist" check and both inserting.
- Racing an authorization check. Firing a request in the window before an access change takes effect, which can compound a broken object-level authorization flaw.
How do you test for it?
- Find check-then-act flows. Anything with a limit, a one-time use, a balance, or a uniqueness constraint: redemptions, coupons, withdrawals, votes, sign-ups, invite acceptance.
- Establish the intended limit. Confirm the feature works once and is rejected the second time when requests are sequential.
- Send them concurrently. Issue the same request many times in parallel. Burp Suite's Repeater can group requests and send them with the single-packet attack over HTTP/2; over HTTP/1.1 use last-byte synchronization.
- Look for the overrun. Check whether the limit was exceeded: more credits than the card is worth, a coupon applied past its cap, a balance driven negative.
- Vary the count and retry. Widen the burst, and repeat, since the window is timing-dependent and one attempt may miss.
How do you fix it?
Close the window: make the check and the act one atomic operation the database serializes, so a second request cannot read stale state.
The reliable pattern is a transaction with a row lock. Read the row FOR UPDATE, which blocks any other transaction from reading it for update until this one commits:
BEGIN;
-- Lock the row. A concurrent redemption blocks here until this commits.
SELECT redeemed, amount FROM gift_cards
WHERE code = 'GC-4K9P-22XA'
FOR UPDATE;
-- Application checks redeemed = false, then:
UPDATE gift_cards SET redeemed = true WHERE code = 'GC-4K9P-22XA';
INSERT INTO wallet_credits (user_id, amount) VALUES ('user_4821', 50);
COMMIT;Other durable options:
- A conditional update.
UPDATE gift_cards SET redeemed = true WHERE code = ? AND redeemed = falseand check the affected-row count. Only one concurrent request updates a row; the rest affect zero rows and are rejected. This does the check and the act in a single statement. - A unique constraint. For "one per user," a unique index on
(user_id, coupon_id)makes the database reject the second insert. Let the constraint enforce it; do not rely on an application check. - An idempotency key. Require the client to send a unique key per logical action and store it with a unique constraint. Replays of the same key return the first result without acting again. This is the standard defense for payment and order endpoints.
What does not work: an application-level check without a lock (that is the bug), an in-memory lock on a multi-instance deployment (each instance has its own), and a rate limiter (it caps request rate, not concurrent access to one row, and the single-packet attack fits inside any reasonable rate). A WAF sees legitimate requests and has nothing to block.
Race condition vs business logic vulnerability
A business logic vulnerability is exploitable with one request that violates a rule. A race condition needs concurrency: the single request is fine, and the flaw only appears when two or more land together. Many limit bypasses are both, reachable by a logic gap in the rule and, where the rule is correct but not atomic, by racing it.
[ Sources ]
Written by Parameter · Last reviewed

