Most web apps do sensitive work in two steps. First they check a condition, then they act on it. Is there balance left? Then deduct it. Is the coupon unused? Then redeem it. Between those two steps sits a tiny window, and if two requests both pass the check before either one commits the action, the app does the guarded thing twice. The single packet attack is the technique that makes hitting that window reliable, turning a flaky timing bug into one an attacker can trigger on demand. This post walks the mechanism defensively, using an invented gift card app, and shows why the fix is atomic steps, not just a rate limit.
The window between check and action
Take an invented store, Acme Gift Cards. A card holds a balance, and redeeming it runs code that looks harmless:
1. balance = SELECT amount FROM cards WHERE code = 'GC-4821' 2. if balance < requested: reject 3. UPDATE cards SET amount = amount - requested WHERE code = 'GC-4821' 4. issue store credit for `requested`
Read top to bottom, this is fine. A card with 50 dollars of balance can only be redeemed for 50 dollars. The problem is that steps 1 and 3 are separate. The app checks the balance, and then, a moment later, it subtracts from it. If a second request runs step 1 while the first request is still between its own step 1 and step 3, both read the same balance of 50, both pass the check, and both proceed to redeem. This is a business logic vulnerability: every request is individually valid, and the flaw is the assumption that the check and the action are one indivisible step.
This class of bug has a name, time of check to time of use, and a cousin called limit overrun, where a per user or per resource cap gets exceeded because many requests count against it at once. Both live in the same gap. The only hard part for an attacker is arrival timing: to land two redeems in the same window, the two requests have to reach that code within a few milliseconds of each other.
Why timing used to make the single packet attack hard
Over a network, “send two requests together” does not mean “they arrive together.” Each request is a separate stream of packets, and every packet picks up a slightly different delay: queueing, routing, retransmits, the receiver’s own scheduling. That variation is called jitter. You might fire twenty redeem requests in a loop, but by the time they reach the server they are smeared across tens of milliseconds. The window you are aiming for might be one millisecond wide. So the race fires sometimes and not others, and an attacker cannot tell whether a failed attempt means the bug is absent or just that the timing missed.
Security researcher James Kettle at PortSwigger published the fix for the attacker’s timing problem in 2023, in research titled “Smashing the state machine,” which won first place in PortSwigger’s Top 10 Web Hacking Techniques of that year. The idea removes network jitter as a variable so the race stops depending on luck.
The bug was always there. The single packet attack just removes the noise that was hiding it, so a race that fired one time in fifty now fires almost every time.
How the single packet attack removes the jitter
The technique uses HTTP/2, which lets many requests share one connection as separate streams. The attacker prepares 20 to 30 redeem requests but does not finish them. For each request, it sends everything except the final byte or two, holding back the last frame that tells the server the request is complete. The server now has 20 to 30 requests parked, each waiting on its last piece.
Then the attacker sends the withheld final frames of all of those requests inside a single TCP packet. One packet arrives at the server as one unit. The server reads it, sees that every parked request is now complete, and hands them all to be processed at essentially the same instant. There is no per request jitter left, because there is no per request packet. The requests were separated across the network while their bodies were in flight, and they get completed together by one arrival.
Here is a simplified timeline for the Acme case:
t0 attacker opens one HTTP/2 connection
t1 sends redeem requests #1..#20, each missing its final byte
-> server parks all 20, none can run
t2 sends ONE TCP packet carrying the final byte of all 20
-> server completes #1..#20 together
t3 all 20 run step 1 (check balance = 50) before any runs step 3
-> all 20 pass the check
t4 all 20 run step 3 and step 4
-> card redeemed ~20 times against a 50 dollar balance
Because the requests enter the check at once, they all read the pre deduction balance. Each one sees enough money, passes, and commits a redeem. A card worth 50 dollars can pay out many times over. Swap the nouns and the same shape covers withdrawing one balance twice, using a single use invite more than once, applying one discount repeatedly, casting more votes than allowed, or slipping past an anti brute force counter. For a fuller tour of the underlying bug, see our writeup on race conditions and limit overrun.
Why a rate limit does not fix it
The instinct is to throttle the endpoint. Rate limiting helps against slow, repeated abuse, but it is the wrong tool here. A rate limiter usually reads a counter and then decides, which is its own time of check to time of use gap. Twenty requests that arrive in the same instant can all read the counter at zero and all pass before any of them increments it, so you have added a second race in front of the first. Rate limiting shapes traffic over seconds. The single packet attack operates inside a few milliseconds, underneath that resolution.
The real fix: make check and action one step
The durable fix is to close the window so the check and the action cannot be split. The database is the right place to enforce this, because it can make a read and a write atomic.
- Lock the row you are about to change. Read the card with
SELECT ... FOR UPDATEinside a transaction. The first request locks the row, and the others wait for it to commit instead of reading a stale balance. When they finally read, the deduction is already applied. - Make the update conditional and atomic. Instead of read then subtract, do it in one statement:
UPDATE cards SET amount = amount - :r WHERE code = :c AND amount >= :r. The check lives inside the write. If the balance is too low, the row does not match and zero rows change, so a losing request simply does nothing. - Let a unique constraint catch duplicates. For single use tokens, coupons, or invites, put a unique index on the used marker so a second redeem of the same code violates the constraint and fails at the database, not in application logic.
- Use idempotency keys. Require the client to attach a key to a redeem, and store it. A repeat with the same key returns the first result instead of running the action again.
- Take a per resource lock. When the work spans several statements, hold one lock keyed to the resource, for example the card code, so only one operation on that card runs at a time.
The common thread is that each of these removes the gap rather than trying to win the timing race. Rate limiting can still sit on top as defense in depth, but it is not the control that closes the bug.
Closing
The single packet attack is not really a new bug. It is a way to make an old one, a check and an action that were never atomic, fire on command by deleting the network noise that used to hide it. That is why these findings are hard to catch by matching known payloads: every request is valid, and the flaw only shows up when you reason about how the steps fit together. UnboundCompute is an autonomous researcher built to do exactly that, reasoning about an app’s logic and proving a race is real before reporting it. You can read more on our about page.
Frequently asked questions
What is the single packet attack?
It is a technique that makes web race conditions reliable. By sending the final pieces of many HTTP/2 requests inside one TCP packet, all of them reach the server at the same instant, removing the network timing jitter that used to make the race fire only sometimes.
What bugs does it exploit?
Time of check to time of use and limit overrun flaws, where an app checks a condition then acts and many requests slip into the gap. Examples include redeeming one gift card many times, withdrawing a balance twice, or using a single use coupon repeatedly.
Does rate limiting stop it?
Not reliably. A rate limiter usually reads a counter then decides, which is its own check to action gap, and it works over seconds while the attack operates inside a few milliseconds. It can sit on top as defense in depth but does not close the bug.
How do you fix it?
Make the check and the action one atomic database operation, using row locking or a conditional update that carries the check inside the write, add unique constraints for single use tokens, and use idempotency keys so a repeat does not run the action twice.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
