Race conditions: spending the same balance twice
Limit overruns, TOCTOU on the filesystem, and the single-packet attack that removes network jitter from the equation. How to recognise a race in a feature description and how to actually win one.
A race condition is a logic bug with a stopwatch attached. The application checks something, then acts on it, and between those two moments the thing it checked changes. Nothing about the code is wrong when you read it line by line - it is wrong only because two copies of it run at once.
These are worth recognising because they are invisible to every scanner and to most code review. The bug is not in a line; it is in the gap between two lines, and you find it by reasoning about what the feature *promises* rather than about what it does.
The shape: check, then act
def redeem(user, code):
voucher = db.get(code)
if voucher.used: # <- the check
return "already used"
credit(user, voucher.amount) # <- the act
voucher.used = True
db.save(voucher)Twenty simultaneous requests all read used == False before any of them writes used = True, and all twenty credit the account. The fix is a transaction or a lock; the absence of one is the bug. Every race below is this shape with different nouns.
The four families
| Family | What you send | What you get |
|---|---|---|
| Limit overrun | N copies of the same redeem/vote/withdraw | The limit applied once, the effect applied N times. |
| State-machine skip | Two steps of a flow simultaneously | A later step executes against an earlier step's state - payment confirmed before it was taken. |
| Single-endpoint confusion | Two different operations on the same object at once | One request reads a half-written object: partly the old user, partly the new one. |
| TOCTOU on a resource | Upload/rename/delete concurrently with a validation step | A file passes validation and is then swapped before use. |
Limit overrun
The archetype. Gift-card redemption, rate limits, one-per-account promotions, vote counters, and "claim your flag" endpoints that decrement a stock counter. Send twenty and count what happened.
State-machine skip
Multi-step flows - checkout, registration, 2FA enrolment - assume the steps happen in order because the UI presents them in order. Firing step 3 while step 2 is still in flight often lands in a state the developer never drew on the whiteboard. This overlaps heavily with reading a design for flaws: the race is just a way of reaching an unreachable state.
TOCTOU on the filesystem
The classic non-web version, and it appears in privilege-escalation challenges as often as in web ones. A setuid program checks access(path, W_OK) and then open(path); you swap path for a symlink to something else in between. In a web app the same pattern is an upload that is validated, then moved, then processed - and you replace the file between validate and process.
Winning the race
The window is usually microseconds. Sending twenty requests in a loop will not hit it - by the time the second request leaves your machine the first has already committed. You need genuine simultaneity, and network jitter is the enemy.
HTTP/1.1: last-byte synchronisation
Open N connections. Send each request *except its final byte*. Wait for all N connections to be quiet. Then send the final byte on every connection at once. The requests are already parsed and buffered server-side, so the remaining work after your last byte is minimal and the arrival times converge to within a millisecond or so.
HTTP/2: the single-packet attack
HTTP/2 multiplexes many requests over one connection, so twenty complete requests can be placed in *one TCP packet*. They arrive at the same instant by construction, and network jitter is eliminated rather than merely reduced. This is the technique that made previously-unwinnable races routine, and it is what Burp's "send group in parallel" does when the target speaks HTTP/2.
import asyncio, httpx
async def fire(n=25):
# http2=True is the point: one connection, one packet, n streams.
async with httpx.AsyncClient(http2=True) as c:
reqs = [c.post("https://target/redeem", json={"code": "ABC"}) for _ in range(n)]
return await asyncio.gather(*reqs, return_exceptions=True)
for r in asyncio.run(fire()):
print(getattr(r, "status_code", r), getattr(r, "text", "")[:80])Measuring the outcome, not the responses
Races fail quietly and succeed quietly. Before you fire, decide what you will measure:
- Record the observable state first - balance, vote count, item count, list of files.
- Fire the group.
- Read the state again and compare. The response bodies are frequently all identical and all misleading.
- If nothing changed, vary the count (5, 20, 100) and retry. Race windows are probabilistic and a single attempt proves nothing.
- If it half-worked - two effects instead of twenty - the endpoint has partial locking and you have still found the bug.
Where they hide in CTF challenges specifically
- A shop with a balance. Buy the expensive item by racing purchases of a cheap one, or race a refund against a purchase.
- A flag endpoint gated on a counter. "Only the first 3 solvers" is a counter, and a counter is a race.
- A file upload followed by a scan. Replace the file after the scan and before the use.
- A password reset token invalidated after use. Race two resets and one may issue a token for the other's account.
- Any admin-approval flow. Submit and approve simultaneously; the approval may read the pre-submission record.
- Directory creation in a shared temp path. The web version of the symlink race, and the reason
mkstempexists.
One more that is easy to miss: the same object edited through two different endpoints. A profile updated by /settings and by /api/v1/user may take different code paths with different validation. Racing them can leave the object with one endpoint's value and the other endpoint's authorisation, which is how a race becomes a privilege escalation rather than a counting bug.
Testing for a race, in order
- Read the feature list for the word "once", or for any limit, quota, or single-use token.
- Identify the check and the act, even if you are guessing at the code.
- Record the state you expect to change.
- Warm the connection, then fire 20 identical requests over HTTP/2 in one group.
- Re-read the state. Compare, do not read the responses.
- If it did not work, retry with more requests and with a warmer connection before concluding the endpoint is safe.
- If it did, work out how many times you can repeat it - most of these scale until something else breaks.
The reason to try this early rather than late is cost. A race takes four minutes to test and needs no payload, no encoding, and no filter bypass. On any endpoint that enforces a limit, it is one of the cheapest experiments available and it either works immediately or does not.