Race Conditions & TOCTOU
Exploit timing windows in CTF web apps - parallel requests, limit bypasses, upload races, and time-of-check/time-of-use flaws.
Race Conditions & TOCTOU
Race conditions happen when the order of operations on shared state matters, but the app does not enforce a single safe ordering. In CTF web challenges, you usually win by sending many requests at once so two actions interleave in a way the author did not expect - for example redeeming a one-time code twice, spending the same balance twice, or slipping a payload between “check” and “use”.
TOCTOU (time-of-check to time-of-use) is the classic pattern: the server checks something (quota, file type, ownership), then acts - and you change the world between those steps (CWE-367).
Where Race Bugs Hide in CTF
| Feature | What breaks | What you try |
|---|---|---|
| Coupon / promo code | “One use” enforced only in app logic | Parallel POST with same code |
| Wallet / credits | Read balance → subtract → write | Double-spend burst |
| Inventory | “Stock > 0” then decrement | Two purchases at same stock |
| Email / password change | Token valid once, two tabs | Use token twice in parallel |
| Rate limit | Counter in Redis without atomic decr | Burst past limit |
| File upload | Validate extension → move to web root | Race swap file after check |
Black-Box Testing Workflow
- Map stateful endpoints: anything that checks a condition and then updates a row, file, or session.
- Send a burst of identical or complementary requests (20–500 is common in CTF).
- Look for extra successes: HTTP 200 on both “redeem”, double balance change, or inconsistent JSON.
Sending Parallel Requests
Burp Suite
- Send the request to Repeater, then use Send group (or Turbo Intruder) to fire many copies in parallel.
- Turn inter-request delay off; use maximum concurrency the server allows.
Python (threading)
import threading
import requests
URL = "http://target/redeem"
COOKIE = {"session": "..."}
BODY = {"code": "PROMO123"}
def fire():
r = requests.post(URL, cookies=COOKIE, data=BODY, timeout=5)
print(r.status_code, r.text[:200])
threads = [threading.Thread(target=fire) for _ in range(50)]
for t in threads:
t.start()
for t in threads:
t.join()Python (asyncio) - good for high concurrency:
import asyncio
import aiohttp
async def main():
async with aiohttp.ClientSession() as session:
tasks = [
session.post("http://target/redeem", data={"code": "PROMO123"})
for _ in range(100)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
print(getattr(r, "status", r))
asyncio.run(main())TOCTOU: Upload Then Move
Sometimes the server:
- Saves upload to
/tmp/abc123.jpg - Checks magic bytes / extension
- Moves to
/var/www/uploads/abc123.jpg
If you can replace /tmp/abc123.jpg between (2) and (3) with a symlink or different file (depends on FS permissions), you can win - in CTF this is rare but shows the mental model.
More common: two uploads with the same predictable name or race on session file ID.
HTTP/2 and Single-Packet Tricks
Some servers multiplex requests on one connection. Tools like Turbo Intruder or race-focused scripts try to land requests in the same window. If a challenge mentions “single-packet” or “HTTP/2”, read the latest writeups on request smuggling vs race - they are different bugs, but the tooling (Burp HTTP/2, custom framing) overlaps.
Defensive Patterns (Recognize Weak Fixes)
| Wrong fix | Why it still races |
|---|---|
| Check-then-update in two queries | Two threads both pass the check |
| Application-level mutex only | Multiple workers / multiple servers |
| SELECT then UPDATE without transaction | Classic lost update |
Stronger (what writeups cite as the fix):
UPDATE wallets SET balance = balance - 10 WHERE balance >= 10 AND id = ?- Serializable isolation or
SELECT ... FOR UPDATE - Idempotency keys on payment endpoints
- Single-threaded queue per user resource
Last updated on