Winning web race conditions with the single-packet attack

Most write-ups tell you a race condition is a “timing window between check and use.” True, and useless — because the reason your race attacks fail isn’t that you don’t understand the concept. It’s that the window is smaller than the internet is precise.

A typical race window — the gap between an app reading balance and writing balance - amount — is measured in microseconds to a few milliseconds. The jitter between two HTTP requests you send at the same instant, by the time they arrive and get scheduled on the server, is routinely 1 to 10 milliseconds, and worse across the public internet. So your “simultaneous” requests arrive in a neat little line, the server processes them one at a time, the limit holds, and you conclude the endpoint is safe. It isn’t. You just lost the race to network jitter before the server ever got a vote.

The entire craft of exploiting web races is a fight to make N requests arrive in the same instant. Win that, and the “unexploitable” limit falls in one shot.

Why last-byte sync was never quite enough

The old HTTP/1.1 trick — last-byte synchronization — gets you close: open ~20 connections, send every request except its final byte, then fire all the final bytes together. It works, and for years it was the state of the art. But it has an unfixable flaw: those are 20 separate TCP connections. Each has its own congestion window, its own path timing, its own kernel scheduling. Even withholding the last byte, arrival still smears across a few milliseconds — enough to lose a microsecond window. Last-byte sync turns a 1-in-1000 race into maybe 1-in-10. Better, still flaky, still “only reproduces from an EC2 box in the same region.”

The single-packet attack: one packet, no jitter

Here is the primitive that changed it, from James Kettle’s Smashing the State Machine research. HTTP/2 multiplexes many requests over one connection. So instead of racing 20 connections, you:

  1. Send 20-30 requests over a single HTTP/2 connection, but withhold the final frame of each (the bit that tells the server “this request is complete”).
  2. When all are staged, release every final frame together — and because they’re tiny, all 20-30 fit inside a single ~1,500-byte TCP packet (one network MTU).
  3. That one packet is delivered atomically. The server sees all 20-30 requests become “complete” in the same instant and schedules them together.

Network jitter is now irrelevant, because there is no “between requests” — there’s one packet. The 10ms of internet noise that used to serialize you is gone. A race that only worked on localhost now works from your laptop over WiFi, reliably, in a single attempt. This is the difference between “theoretically vulnerable” and a screenshot in your report.

There’s a catch that eats a lot of first attempts: connection warming. If your attack packet is also the first thing on the connection, it eats the TLS handshake round-trips and TCP slow-start, and the requests spread back out. Fire a handful of throwaway requests first (a few GET /s) to complete the handshake and ramp the congestion window, then stage the attack. Skip this and you’ll swear the technique doesn’t work.

Past limit-overrun: what the good bugs actually look like

“Redeem a coupon twice” is the tutorial. The findings that pay are these three shapes:

Limit-overrun on one endpoint. The classic, and still everywhere money lives:

  • Gift cards / store credit. Apply one $50 card to 10 carts in a single packet → $500 of credit, because all 10 read “balance: $50” before any of them decrements it.
  • Withdrawals / transfers. Withdraw $100 from a $100 balance 5 times — each request reads the balance before any debit lands. Double-spend, straight up.
  • 2FA / OTP rate limits. An endpoint that allows “3 code attempts, then lockout” checks the attempt counter before incrementing it. Send 30 guesses in one packet and all 30 read “attempts: 0” and pass the gate — you get 30 tries per window instead of 3. That’s often the whole difference between a brute-force being infeasible and being a lunchtime job.

Multi-endpoint collisions. The subtle, high-severity class: two different requests timed to collide. The canonical example is email verification — fire confirm-email (for an address you control) and change-email (to the victim’s address) together, and land in a state where the victim’s email is marked confirmed on your account. The check-and-act straddles two endpoints, so single-endpoint locking doesn’t save them.

Hidden sub-states. Kettle’s deeper point: many operations pass through an intermediate state that’s never supposed to be observable — a user row that exists but isn’t finished, a session that’s authenticated but not yet scoped, a file that’s created but not yet ACL’d. A race lets you observe or act during that sub-state. These don’t announce themselves as “limits,” which is exactly why they survive. You find them by racing a read against a write and watching for the read to return something that shouldn’t exist yet.

Detection: count the side effect, not the 200s

The trap that produces false negatives and false positives: judging by HTTP status. Two specifics that will save you:

  • A wall of 200s can be a lie. Some backends accept all 30 requests, then a database unique constraint silently rejects the duplicates — you see thirty 200s and no bug. Verify the actual side effect: did the balance really move twice? Did two rows really get created? The response code is not the outcome.
  • A single win is a finding. You do not need all 30 to succeed. If 29 return “coupon already used” and one extra redemption landed, the limit is broken — you only needed it to break once. Don’t discard a run because it was mostly rejections.

For the invisible sub-state bugs, benchmark first: send a batch and record the normal spread of responses, then look for the outlier — the one response that’s a different length, a different state, an object that shouldn’t be visible. The anomaly is the sub-state leaking.

The gotchas that cost you the bug

  • No connection warming → your first packet is slow and spread out. Warm it.
  • Idempotency keys. If the client sends a unique key per action and the server honors it, your 30 duplicates collapse to one. Strip or vary the key and retry.
  • Constraints as noise, not verdicts. A unique index can defeat this race while a different endpoint on the same object has none. A failed race is information about one code path, not a clean bill of health.
  • State freshness. A coupon that’s genuinely single-use will “confirm” nothing after the first legitimate use — start from a clean, unused object each run.

Why it’s defended (and how)

For your report’s remediation section: the fix is never “add a check.” It’s atomicity at the data layerSELECT … FOR UPDATE, a unique constraint, an atomic decrement (UPDATE … SET balance = balance - 1 WHERE balance >= 1), or optimistic locking with a version column. Anything that does read-then-write in application code, across two statements, is racing itself. Idempotency keys close the duplicate-submission variant.

The point

Race conditions aren’t rare — they’re rarely reproduced, because the technique to land requests simultaneously was, until recently, unreliable enough that people tested the obvious coupon endpoint, failed, and moved on. The single-packet attack removes the flakiness. That reframes every limit, every “you can only do this once,” and every multi-step flow in the app as a candidate you can test in one packet and two seconds. The bugs were always there; now the timing is free.

Crusader speaks HTTP/2, so it can stage the single-packet attack directly — warm the connection, withhold the final frames, release them together, and diff the outcomes. Pair it with the access-control work in Finding IDOR and BOLA — both are business-logic bugs no scanner will ever find for you. Download Crusader free and point it at any limit the app swears is enforced.

Frequently asked questions

What is the single-packet attack?
A technique that places 20-30 complete HTTP/2 requests inside one TCP packet. You send each request but withhold its final frame, then release all the final frames together, so the requests arrive at the server simultaneously. That eliminates the network jitter which otherwise serializes requests and closes the race window. It was introduced in PortSwigger research by James Kettle and works over HTTP/2 and HTTP/3.
Why do reliable race conditions need HTTP/2?
Over HTTP/1.1 you can only approximate simultaneity with last-byte synchronization across many separate TCP connections, and jitter between those connections still smears arrival by milliseconds. HTTP/2 multiplexes many requests over a single connection, so you can pack them into one packet and the arrival is genuinely simultaneous. HTTP/1.1 attacks fall back to the older, less reliable method.
What is connection warming and why does it matter?
Sending a few throwaway requests before the attack so the TLS handshake is complete and the server-side connection is ready. Without it, handshake round-trips and TCP slow-start delay your attack packet unevenly and re-introduce the timing spread the single-packet attack exists to remove.
Try Crusader free →