Skip to main content
All articles

Race conditions are not hard any more: the single-packet attack and what it broke

Web race conditions were dismissed as theoretical because network jitter made the window unhittable. Then HTTP/2 removed the jitter. The technique, the sub-states nobody models, and how to build code that cannot race.

7 min read0 views

For years, web race conditions were triaged as low severity with a standard justification: the window is microseconds wide, the network adds milliseconds of jitter, so exploitation is theoretically possible and practically not.

That reasoning was correct, and it stopped being correct in 2023.

James Kettle's single-packet attack, presented at DEF CON 31 and published through PortSwigger Research, eliminates network jitter entirely. The results reported are stark: four to ten times more effective than previous methods, and on one real-world vulnerability, successful in around 30 seconds where the previous best technique took over two hours.

If your threat model still says "races are hard to exploit remotely", it is three years out of date.

Why jitter used to be the defence

To exploit a race you need two requests to arrive at the server within the window between a check and a use. Send them sequentially over HTTP/1.1 and they are separated by at least one round trip. Send them in parallel over separate connections and they are separated by whatever jitter the network introduces, typically milliseconds, against a window of microseconds.

You could fire thousands of requests and hope. That is loud, slow, and unreliable, which is exactly why the finding got downgraded.

What changed

HTTP/2 multiplexes: multiple requests share one connection as interleaved frames. Two complete HTTP/2 requests fit inside a single TCP packet.

That single packet arrives at the server as one unit. The requests are processed by different threads, but they begin from the same instant. The jitter between them is gone, because there is no between. The technique scales comfortably to 20–30 requests in one packet.

For HTTP/1.1 there is an analogue, last-byte synchronisation: send all of each request except its final byte, wait, then send all the final bytes together. Less precise, still far better than naive parallelism.

Both are implemented in Turbo Intruder and in Burp Repeater's tab-group send. The practical upshot is that this is now a button, not a research project, which is the part defenders need to absorb.

Where the money is: limit overrun

The canonical class. Any resource with a usage count, where the check and the decrement are not atomic:

  • Gift cards and store credit. Redeem a £50 card twenty times in one packet.
  • Discount codes limited to one use per customer.
  • Withdrawals and transfers. Balance checked, then debited.
  • Rate limits and OTP attempt counters. Ten attempts becomes limitless.
  • Invitations, referrals, promotional signups.
  • Voting, likes, "one per user" anything.
  • Inventory. Sell the last unit thirty times.

The vulnerable shape is always the same:

balance = get_balance(user)      # check
if balance >= amount:            # decide
    do_the_thing()               # act
    set_balance(user, balance - amount)   # update -- too late

Between reading and writing there is a window. Twenty concurrent requests all read the same starting balance, all pass the check, and all act.

The part most people miss: sub-states

The more interesting half of Kettle's research is not the packet trick. It is the observation that applications contain far more temporary inconsistent states than anyone models.

A "single" operation is usually several: write a row, update a session, send an email, invalidate a cache, write an audit record. Between any two of those steps, the system is in a state that never appears in the design document, and a concurrent request can observe it.

Examples worth testing for:

  • Email change confirmation. Change your address to an attacker-controlled one and confirm an old pending token at the same moment; some implementations validate the token against one record and apply the change to another.
  • Password reset. Two resets in flight where the second token is issued before the first is invalidated.
  • Multi-step registration, where a user row exists before its permissions are set. A request landing in that gap sees a user with no restrictions.
  • Object creation followed by ACL assignment. The window between "created" and "secured" is a real window.
  • 2FA enrolment, where the account is briefly both "2FA enabled" and "not yet verified".
  • Payment capture and order fulfilment, if they are not one transaction.

The generalisation is useful: anything a developer describes as "and then" is a race window. "We create the user and then assign the role." "We charge the card and then mark the order paid."

Testing

  1. Find endpoints that mutate shared state, anything with a limit, a balance, a counter, a status transition, or a uniqueness constraint.
  2. Establish the baseline. One request, note the effect. You cannot recognise an anomaly without it.
  3. Send 20–30 identical requests in one packet with Turbo Intruder or a Repeater tab group.
  4. Compare outcomes. Did the balance go negative? Did the counter fall behind the number of successes? Are there duplicate rows that should be unique? Did you get more of something than you paid for?
  5. Try heterogeneous races too. Two different requests timed together, which is what finds the sub-state bugs. Confirm-old-token alongside change-email is the pattern to start from.
  6. Warm the connection first. A cold TLS handshake and connection setup add variance; send an unrelated request first so the connection is established and the server's caches are warm.
  7. Do not do this against production without explicit, specific, written authorisation. A successful limit-overrun test against a real payment system creates real financial transactions. This is one of the few techniques where the proof of concept is the damage, so agree the target, the resource and the rollback in advance.

Fixing it

There is only one robust answer, and it is not application-level locking.

1. Make the database enforce it. A UNIQUE constraint on (user_id, promo_code) makes double redemption impossible regardless of concurrency, because the guarantee lives where the serialisation happens. This is the strongest and cheapest fix available.

2. Atomic conditional updates. Do not read-then-write. Write conditionally, and check what the database says you changed:

-- The whole check-and-act in one atomic statement
UPDATE accounts
   SET balance = balance - 50
 WHERE user_id = 42
   AND balance >= 50;
-- zero rows affected == insufficient funds. There is no window.

The equivalents: INSERT ... ON CONFLICT DO NOTHING, MongoDB's findOneAndUpdate with a condition, Redis' INCR and SET NX, and DynamoDB conditional writes.

3. Transactions with the right isolation level. SERIALIZABLE, or SELECT ... FOR UPDATE to lock the row for the transaction's duration. Note that the default isolation level in most databases (READ COMMITTED) does not prevent this. A fact that surprises people who believe "it is in a transaction" is the same as "it is safe".

4. Idempotency keys. For payment and order APIs, require a client-supplied key, store it with a uniqueness constraint, and return the original result for repeats. This also solves retries and double-submits, which are the same bug arriving by accident.

5. Collapse "and then". If two steps must both happen, put them in one transaction. If they cannot be in one transaction, because one of them is an external API, make the operation resumable and idempotent rather than assuming it completes.

What not to rely on: application-level mutexes (they do not span processes or instances), "check again after acting" (the second check races too), and rate limiting (it reduces attempts, and a single packet only needs one).

Detection

  • Multiple requests to the same endpoint from one session with near-identical timestamps, sub-millisecond spacing. This is the direct signature and it is visible in any log with millisecond precision.
  • HTTP/2 requests arriving with identical or near-identical receive times on one connection.
  • Business-logic invariant violations, which is the detection that actually matters: negative balances, redemption counts exceeding limits, more child rows than the parent allows, duplicate values in columns that should be unique. Run these as scheduled assertions against your own data. A race that succeeded leaves evidence in the database even when it left none in the logs.
  • Repeated identical POST bodies within a very short window.

Take this away

The defence for web race conditions used to be the network, and the network stopped defending you in 2023. Anything protected by a limit that is checked and then applied is exploitable in about thirty seconds by someone with Burp and no special skill.

The fix has not changed and never will: do not check and then act. Make the database do both at once, and let a constraint violation be the answer.


Further reading

Was this useful?

Share

Tags

  • web security
  • api security
  • detection engineering
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

API Penetration Testing

Authorisation, object-level access and abuse testing against your API surface.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.