Chapter 35 — Web race conditions
In one sentence
An operation the developer assumed happens once — redeem a code, apply a discount, withdraw funds, use an invite — can be made to happen many times by firing parallel requests into the window between the check and the commit, and HTTP/2's single-packet attack makes the timing trivially reliable.
Where we are
G1 mentioned race conditions in passing. James Kettle's 2023 "Smashing the State Machine" research turned them from "flaky, rare" into a systematic, high-impact class with a reliable trigger.
How it works
The core: TOCTOU on shared state.
Typical vulnerable pattern:
row = SELECT * FROM gift_cards WHERE code = ? AND used = false; // check
if (row) {
credit_account(row.value); // act
UPDATE gift_cards SET used = true WHERE code = ?; // commit
}
Fire 20 requests with the same code in parallel: all 20 run the SELECT
before any UPDATE lands -> all 20 pass the check -> the card is
redeemed 20 times.
Categories:
LIMIT OVERRUN the "once" operations: gift card / coupon /
voucher / invite / referral / vote / "claim free trial" / withdraw /
transfer / "downvote once" / rate-limit itself.
MULTI-ENDPOINT race two different requests: add-to-cart while
the payment for the OLD total is in flight -> pay less; change email
while a "confirm" for the old email is processing.
SINGLE-ENDPOINT two requests to the same endpoint with different
data collide in a shared step.
PARTIAL CONSTRUCTION / HIDDEN STATE an object briefly exists
half-initialised: user row created but `email_verified`/`mfa_enabled`
not yet written; a session valid before the "is this device
trusted?" check completes -> race a request into that window
(e.g. 2FA bypass, use an account before verification).
TIME-SENSITIVE password-reset token still valid for a
millisecond after use; OTP verify racing the "increment attempts"
counter (unlimited guesses).
The single-packet attack (why it's reliable now):
Over HTTP/1.1, "last-byte sync" (send all requests but hold back the
final byte, then release them together) still leaves network jitter of
a few ms -> races are flaky.
Over HTTP/2, you can put the final frames of 20-30 requests into ONE
TCP packet. The server receives them together; jitter between them is
sub-millisecond. Burp's Turbo Intruder (`engine=Engine.BURP`,
`gate`/`sendRequests` with the single-packet option) and the Repeater
"send group in parallel (single-packet)" feature do this for you.
Defence — make the operation atomic:
[ ] DB-level guarantees:
- a UNIQUE constraint / partial index that a second insert
violates ("redemption(card_id) UNIQUE").
- conditional UPDATE that both checks and mutates:
`UPDATE gift_cards SET used=true WHERE code=? AND used=false`
then act only if `rows_affected == 1`.
- `SELECT ... FOR UPDATE` (row lock) within the transaction.
- atomic balance: `UPDATE accounts SET balance = balance - :amt
WHERE id=:id AND balance >= :amt` (rows_affected check).
- SERIALIZABLE isolation (accept the retry-on-conflict cost) for
complex invariants.
[ ] Optimistic locking: a `version` column; update `WHERE version=:v`;
retry on mismatch.
[ ] Idempotency keys: client sends a unique key; the server records it
and returns the first result for duplicates (also fixes retries,
Ch 28).
[ ] Single-use tokens enforced by an atomic "consume" (delete-returning
/ compare-and-swap), not read-then-mark.
[ ] Application-level locks (Redis `SET NX` with a TTL, Postgres
advisory locks) keyed by the resource -- with care around lock
expiry.
[ ] Don't rely on rate limiting as the fix -- it reduces attempts, not
the fundamental race, and the single-packet attack often fits
under the limit.
[ ] Test: a harness that fires N parallel identical requests and
asserts exactly one succeeds.
Worked example
2FA bypass via a partial-construction race, and the atomic fix.
Login flow:
1. POST /login (password OK) -> creates a session row with
`mfa_passed = false`, returns a session cookie, redirects to
/2fa.
2. POST /2fa (code) -> if code OK, `UPDATE sessions SET
mfa_passed=true`.
3. Every other endpoint: `if (!session.mfa_passed) redirect /2fa`.
RACE: between step 1 and step 2, the attacker (who has the password but
NOT the 2FA code) fires 30 parallel requests to `GET /account` using
the session cookie from step 1, via the single-packet attack. The
check `if (!session.mfa_passed)` is read from a slightly stale
session/cache for some of them, OR the session is briefly usable before
the guard middleware is fully wired for that request -> one request
slips through and returns the account page -> 2FA bypassed.
(Real variants: racing the session-creation with an API call; racing
`mfa_passed` read against a cache; racing before `mfa_required` is
computed.)
FIX:
- The session is NOT a valid authenticated session until MFA
completes. Represent it as a distinct short-lived "mfa_pending"
token that grants access to /2fa ONLY -- not a real session cookie.
No other endpoint accepts it. There is no window.
- `POST /2fa` atomically swaps the pending token for a real session
(delete-returning the pending row, insert the session, in one
transaction).
- Guard is fail-closed and evaluated from the DB (or a
strongly-consistent store), not a cache, for the auth decision.
- Test: fire 50 parallel `GET /account` with the pending token ->
assert all 50 get 401/redirect.
Practice (75 min)
- PortSwigger Academy "Race conditions" track: limit overrun, multi- endpoint, single-endpoint, partial construction, time-sensitive; use Turbo Intruder and Repeater's single-packet "send group in parallel".
- Locally: the gift-card example with
check -> act -> mark. Redeem it 10x with a parallel harness. Then fix four ways (conditional UPDATE + rows_ affected; UNIQUE redemption row;FOR UPDATE; idempotency key) and re-test each. - Build the "apply coupon while paying" multi-endpoint race; fix with a transaction that recomputes and locks the order total at capture (Ch 27 / G1 Ch 45).
- Build the OTP-verify race (unlimited guesses by racing the attempt counter);
fix with an atomic decrement
WHERE attempts_left > 0. - Add a reusable "parallel N, assert exactly one success" test helper to your test suite and point it at every "once" operation.
Build it in Go (25 min) — a limit-overrun race, and the one-statement fix
A gift card worth one redemption, 20 requests released at the same instant. The vulnerable handler checks the balance, waits for the "database", then writes. The fixed handler does both in one conditional update.
// race: the "limit overrun" web race condition (Chapter 35). A gift card
// worth 100 is redeemed by 20 parallel requests. Check-then-act lets many of
// them win; a single conditional update lets exactly one win.
//
// go run ./race
// go run -race ./race # note: the race detector reports NOTHING here
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"time"
)
// db stands in for a database: every method is ONE atomic statement.
type db struct {
mu sync.Mutex
balance map[string]int
}
func (d *db) get(card string) int { // SELECT balance FROM cards WHERE id = $1
d.mu.Lock()
defer d.mu.Unlock()
return d.balance[card]
}
func (d *db) set(card string, v int) { // UPDATE cards SET balance = $2 WHERE id = $1
d.mu.Lock()
defer d.mu.Unlock()
d.balance[card] = v
}
// debitIfEnough is the fix as ONE statement:
//
// UPDATE cards SET balance = balance - $2 WHERE id = $1 AND balance >= $2
//
// The check and the write can't be separated, so no request can slip between them.
func (d *db) debitIfEnough(card string, amount int) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.balance[card] < amount {
return false
}
d.balance[card] -= amount
return true
}
func vulnerable(d *db) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bal := d.get("gift-1") // 1. check
if bal < 100 {
http.Error(w, "insufficient balance", http.StatusPaymentRequired)
return
}
time.Sleep(5 * time.Millisecond) // the round trip to the database: the race window
d.set("gift-1", bal-100) // 2. act, on a value that may be stale
w.Write([]byte("redeemed"))
}
}
func fixed(d *db) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !d.debitIfEnough("gift-1", 100) {
http.Error(w, "insufficient balance", http.StatusPaymentRequired)
return
}
w.Write([]byte("redeemed"))
}
}
func attack(name string, h func(*db) http.HandlerFunc) {
d := &db{balance: map[string]int{"gift-1": 100}} // worth ONE redemption
srv := httptest.NewServer(h(d))
defer srv.Close()
// 20 requests released at the same instant (the "single-packet attack"
// does this over one HTTP/2 connection to remove network jitter).
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
wins := 0
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
resp, err := http.Post(srv.URL, "text/plain", nil)
if err == nil {
if resp.StatusCode == http.StatusOK {
mu.Lock()
wins++
mu.Unlock()
}
resp.Body.Close()
}
}()
}
close(start)
wg.Wait()
fmt.Printf("%-12s 20 parallel redemptions of a 100-credit card: %2d succeeded, final balance %d\n",
name, wins, d.get("gift-1"))
}
func main() {
attack("vulnerable", vulnerable)
attack("fixed", fixed)
}
$ go run ./race
vulnerable 20 parallel redemptions of a 100-credit card: 20 succeeded, final balance 0
fixed 20 parallel redemptions of a 100-credit card: 1 succeeded, final balance 0
$ go run -race ./race
vulnerable 20 parallel redemptions of a 100-credit card: 20 succeeded, final balance 0
fixed 20 parallel redemptions of a 100-credit card: 1 succeeded, final balance 0
What to notice:
- 20 wins on a card worth 1. Every request read the balance (100) before any of them wrote it, and then each wrote "100 − 100".
- Go's race detector reported nothing, correctly. There's no data race:
every read and write is properly locked. This is a logic race across two
atomic operations, which no memory-model tool can find. Only design (a single
conditional statement,
SELECT ... FOR UPDATE, or a unique constraint) or testing that fires requests in parallel finds it. - The fix is the SQL in the comment:
UPDATE cards SET balance = balance - $2 WHERE id = $1 AND balance >= $2, then check that exactly one row was affected.
Exercises:
- Implement the fix with a database you use (Postgres or SQLite): first with
SELECT ... FOR UPDATEin a transaction, then with the conditionalUPDATE. Measure both under 200 parallel requests. - Apply the same pattern to "one coupon per user" with a unique index on
(user_id, coupon_id), letting the database reject the duplicate. - Reproduce the attack over HTTP/2 against your staging service using Burp's single-packet attack, and confirm the fix holds.
Common mistakes
SELECT ... check ... then UPDATE. The canonical bug. Make the check and mutation one atomic statement or lock the row.- Trusting
rows_affectedisn't. Always branch on it after a conditional update; don't assume success. - Rate limiting as the fix. Reduces attempts; the single-packet attack often fits in one burst under the limit.
- Read-then-mark for single-use tokens. Use an atomic consume (delete-returning / CAS).
- Auth/session state that's briefly valid before a guard applies. Model the pre-verification state as something that grants nothing.
- Never testing concurrency. Add a parallel-fire assertion for every "once" operation; run it in CI.
- Optimistic locking without a retry. A version-mismatch that just errors out can be a DoS or a UX break; retry with backoff.
Check yourself
- Describe the check/act/commit pattern that races, with a concrete example.
- Why is the HTTP/2 single-packet attack more reliable than HTTP/1.1 last-byte sync?
- Name four categories of web race condition.
- Give three database-level ways to make a "redeem once" operation atomic.
- Why is rate limiting not a fix for races?
- How do you make a partial-construction (e.g. pre-2FA) window unexploitable?
Further reading
- Research: PortSwigger — "Smashing the State Machine: The True Potential of Web Race Conditions" (James Kettle, 2023). Read it fully; the single-packet technique and the taxonomy come from here.
- Labs: PortSwigger Academy race-conditions track; Turbo Intruder docs.
- Fundamentals: database isolation levels (read committed vs repeatable read
vs serializable),
SELECT FOR UPDATE, advisory locks — PostgreSQL docs. - Older but relevant: "Race Conditions on the Web" (Josip Franjković), the classic Facebook/Instagram limit-overrun reports.