# Winning web race conditions with the single-packet attack

> The exploit window for a race condition is often sub-millisecond, but network jitter between your requests is 1-10ms — so they get serialized and the bug looks dead. The single-packet attack collapses that jitter to zero. Here's the mechanism, the math, and where it pays.

**Key takeaways:**
- The window is the whole problem: race windows are often sub-millisecond, but network jitter between separate requests is 1-10ms, so the server processes them in order and the race never fires.
- The single-packet attack withholds the final frame of 20-30 HTTP/2 requests, then releases them in one ~1,500-byte TCP packet — they land together and jitter stops mattering. Races that were 'only works on localhost' become remote and reliable.
- Warm the connection first (complete the TLS handshake, ramp the TCP window with throwaway requests) or setup latency re-introduces the spread you just removed.
- Limit-overrun is the tutorial. The high-severity bugs are multi-endpoint collisions and hidden sub-states — a half-committed object observed mid-write.

**Series:** Bug Class Deep Dives  
**Published:** July 5, 2026 · 7 min read  
**Canonical:** https://crusaderproxy.com/research/en/posts/winning-the-race-condition-bugs/

---


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 `200`s can be a lie.** Some backends accept all 30 requests, then a database unique constraint silently rejects the duplicates — you see thirty `200`s 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 layer** — `SELECT … 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]({{< relref "posts/finding-idor-bola-with-shadow-replay.md" >}}) — both are business-logic bugs no scanner will ever find for you. [Download Crusader free](https://crusaderproxy.com/#install) and point it at any limit the app swears is enforced.


---

*Original research by Crusader Research. Try Crusader free: https://crusaderproxy.com/#install*
