Crown Jewel Targets
Race conditions are high-severity findings because they break financial, access control, and integrity assumptions that defenders rarely stress-test. Highest payouts come from:
- Monetary/credit systems — double-spending gift cards, coupons, referral bonuses, promotional credits, wallet balances
- Vote/reputation manipulation — upvoting the same content multiple times, gaming leaderboards or trending algorithms
- Account limits bypass — exceeding free-tier quotas, bypassing "one per user" restrictions on invites, trial activations, or API key generation
- Privilege escalation — racing role assignment or permission checks during user creation/upgrade flows
- Deletion bypass — reading or exfiltrating data during a narrow window between "marked for deletion" and "actually deleted"
- Payment flows — charging a card once but receiving multiple fulfillments
Best-paying asset types: Fintech apps, SaaS platforms with credit/subscription models, social platforms with reputation systems, e-commerce checkout flows, OAuth/SSO token endpoints.
Attack Surface Signals
URL Patterns
/vote, /upvote, /like, /favorite
/redeem, /apply-coupon, /use-code, /claim
/purchase, /checkout, /confirm-order, /pay
/transfer, /withdraw, /send-money
/invite, /referral, /accept-invite
/upgrade, /activate, /trial
/delete, /deactivate, /cancel
/follow, /subscribe
Response Headers That Signal Race-Prone Backends
X-RateLimit-* # rate limiting exists, but may not be atomic
X-Request-Id # each request independently tracked
No Cache-Control # stateful ops not idempotent
JavaScript Patterns to Grep
// Single-use action buttons with client-side disable
button.disabled = true
$('#btn').prop('disabled', true)
// Optimistic UI updates (state set before server confirms)
setState({ used: true })
// Sequential async calls without locking
await useVoucher(); await deductBalance();
Tech Stack Signals
- Ruby on Rails without
with_lock/lock!— ActiveRecord doesn't lock by default - Node.js with async/await chains — non-atomic DB reads then writes
- PHP without
SELECT ... FOR UPDATE— common in legacy codebases - Microservices — inter-service calls introduce natural TOCTOU windows
- Redis counters without Lua scripts or
INCRatomicity checks - Message queues — idempotency keys often missing
Step-by-Step Hunting Methodology
-
Enumerate one-time or limited-use actions — Map every endpoint that enforces a "once per user", "limited quantity", or "deduct balance" constraint. These are your primary targets.
-
Understand the state machine — For each target action, identify: (a) what state is read, (b) what state is written, (c) what validation sits between read and write. The gap between read and write is your window.
-
Capture a clean baseline request — Perform the action once legitimately with Burp Suite intercepting. Confirm you get the expected single-use behavior (e.g., coupon marked used, vote counted once).
-
Set up parallel request tooling — Use one of:
- Burp Suite Repeater → "Send group in parallel" (Turbo Intruder for HTTP/2 single-packet attacks)
- Turbo Intruder with
engine=Engine.BURP2for last-byte sync curlwith&backgrounding- Python
threadingorasynciowith pre-built connections
-
Execute the race — Send 10–50 identical requests simultaneously. Key technique: pre-connect and buffer all requests, release the final byte of all simultaneously (single-packet attack when HTTP/2 is available).
-
Analyze responses — Look for:
- Multiple
200 OKwhere only one should succeed - Duplicate success messages
- Database constraint errors (signals the race worked but hit the last-line-of-defense)
- Inconsistent response times (one fast, rest slow = serialized; all same speed = parallel processing)
- Multiple
-
Verify the effect — Check the actual state: Was the credit applied twice? Did the vote count increment multiple times? Is the coupon still marked unused despite two successes?
-
Determine exploitability window — Re-run with decreasing parallelism (5 requests, 3 requests, 2 requests) to understand how tight the window is and reliability of exploitation.
-
Test across account types — Sometimes the race only works for new accounts, specific subscription tiers, or under specific server load. Test varied conditions.
-
Document reproducibility — Record exact timing, number of parallel requests needed, and success rate across 5 independent attempts before reporting.
Payload & Detection Patterns
Turbo Intruder — Basic Parallel Race
# turbo_intruder_race.py
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2) # HTTP/2 single-packet
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if '200' in req.status:
table.add(req)
curl — Parallel Requests (bash)
# Fire 15 simultaneous vote/redeem requests
for i in $(seq 1 15); do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "https://target.com/api/vote" \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"report_id": "12345", "vote": "up"}' &
done
wait
Python asyncio Race
import asyncio, aiohttp
async def race_request(session, url, payload, headers):
async with session.post(url, json=payload, headers=headers) as r:
return await r.text()
async def main():
url = "https://target.com/redeem"
payload = {"code": "GIFT50"}
headers = {"Cookie": "session=XXXXX"}
async with aiohttp.ClientSession() as session:
tasks = [race_request(session, url, payload, headers) for _ in range(20)]
results = await asyncio.gather(*tasks)
for r in results:
print(r[:100]) # print first 100 chars of each response
asyncio.run(main())
Grep Patterns for Source Code Auditing
# Look for read-then-write without locking
grep -rn "find_by\|where.*first" --include="*.rb" | grep -v "lock"
grep -rn "SELECT.*WHERE" --include="*.php" | grep -v "FOR UPDATE"
# JavaScript async without atomicity
grep -rn "await.*get\|await.*find" --include="*.js" -A2 | grep "await.*update\|await.*save"
# Python Django ORM without select_for_update
grep -rn "\.get(\|\.filter(" --include="*.py" | grep -v "select_for_update"
HTTP/2 Single-Packet Check
# Verify target supports HTTP/2 (prerequisite for single-packet attack)
curl -sI --http2 https://target.com | grep -i "HTTP/2\|h2"
Common Root Causes
-
Check-Then-Act without atomic operations — Developer reads state (
if voucher.used == false), then writes state (voucher.update(used: true)) in two separate database operations. Any thread can read the same "unused" state before either writes. -
Missing database-level locking — Using ORM methods like
findorfilterinstead ofSELECT ... FOR UPDATE. The fix is one line but developers don't think about concurrency. -
Optimistic concurrency without version checking — Systems increment counters or mark records without checking if the record changed since it was read.
-
Microservice TOCTOU — Service A validates eligibility, Service B executes the action. No shared atomic transaction spans both services.
-
Client-side "protection" — Developers disable the button in JavaScript after first click, assuming that prevents duplicate submissions. Server-side logic is never hardened.
-
Counter increments outside transactions —
votes_count += 1; save()instead of an atomic SQLUPDATE SET votes = votes + 1 WHERE id = ?. -
Async background jobs — Eligibility checked synchro