Skip to content
All posts
webrevised June 5, 20266 min read

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

  1. check

    balance >= price?

  2. the window

    microseconds of nothing

  3. act

    deduct, grant, issue

Every race in this post is that gap. Twenty requests that all pass the check before any of them reaches the act produce twenty grants against one balance.
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

FamilyWhat you sendWhat you get
Limit overrunN copies of the same redeem/vote/withdrawThe limit applied once, the effect applied N times.
State-machine skipTwo steps of a flow simultaneouslyA later step executes against an earlier step's state - payment confirmed before it was taken.
Single-endpoint confusionTwo different operations on the same object at onceOne request reads a half-written object: partly the old user, partly the new one.
TOCTOU on a resourceUpload/rename/delete concurrently with a validation stepA 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])
Count the successes, not the responses. A race that fires 25 requests and returns 25 'already used' messages while crediting the balance three times is a win the response bodies do not show.

Measuring the outcome, not the responses

Races fail quietly and succeed quietly. Before you fire, decide what you will measure:

  1. Record the state first

    Balance, vote count, item count, list of files - before you send anything.

  2. Fire the group

    All at once, not in sequence.

  3. Read the state again

    And compare. The response bodies are frequently all identical and all misleading.

  4. If nothing changed, vary the count

    5, 20, 100. Race windows are probabilistic and a single attempt proves nothing.

  5. If it half-worked

    Two effects instead of twenty means 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 mkstemp exists.

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

  1. Look for the word 'once'

    Or any limit, quota, or single-use token in the feature list.

  2. Name the check and the act

    Even if you are guessing at the code.

  3. Record the state you expect to change

    This is the measurement, not the responses.

  4. Warm the connection, then fire

    20 identical requests over HTTP/2, in one group.

  5. Re-read the state

    Compare. Do not read the responses.

  6. If it failed, try harder before concluding

    More requests, a warmer connection.

  7. If it worked, find the ceiling

    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.