The single-packet attack and the races click-twice-fast misses
How HTTP/2 last-byte synchronisation removes network jitter from race testing, and why the real TOCTOU lives at the database isolation level.
Click twice fast is theatre
The naive race test is two requests sent in sequence, maybe from two browser tabs. The problem is the requests do not arrive together. Each one rides its own TCP path, gets its own scheduling, and lands whenever the network feels like delivering it. The spread between them is tens of milliseconds. The window you are trying to hit, the gap between a server reading a value and writing it back, is often under a millisecond. You are throwing two darts a metre apart at a coin a centimetre wide. You will miss, conclude there is no race, and move on. The bug was there.
What the single-packet attack actually does
Over HTTP/1.1 you reduce jitter with last-byte synchronisation: you send all of each request except its final byte, wait, then release the last bytes together. HTTP/2 makes it cleaner. You put every request you want to race into a single TCP packet using multiple streams, and the server receives them in one read. They are processed as close to simultaneously as the machine allows, with the network removed from the equation. This is the single-packet technique, and the practical effect is that a race which fired one time in a thousand over the open internet now fires reliably.
The real bug lives at the isolation level
The synchronisation is just delivery. The vulnerability is what the database does when two transactions read the same row before either writes. Picture a coupon redemption:
-- transaction reads current state
SELECT used FROM coupons WHERE code = 'WELCOME50'; -- used = false
-- application decides: not used, proceed
UPDATE coupons SET used = true WHERE code = 'WELCOME50';
INSERT INTO credits (account, amount) VALUES ('attacker', 5000);That SELECT then UPDATE is the time-of-check-to-time-of-use gap. Under the
default READ COMMITTED isolation level, two concurrent transactions both run
the SELECT, both see used = false, both decide to proceed, and both insert
the credit. The single UPDATE is idempotent and hides the problem, but the
INSERT ran twice. The coupon paid out twice from one code.
The fix is the same thing whose absence is the bug: an atomic guarded write
(UPDATE ... WHERE used = false and check the affected-row count), a row lock
(SELECT ... FOR UPDATE), a unique constraint, or SERIALIZABLE isolation. If
the developer split check and use into two statements without a lock between
them, the window is real and the single-packet attack walks straight through it.
How I test it cleanly
I send a burst of identical one-time requests in a single packet and then count outcomes, not responses. Five HTTP 200s mean nothing on their own; what matters is the side effect. So I check the durable state afterward:
POST /api/coupon/redeem HTTP/2
Host: example.com
{"code":"WELCOME50"}Fire 30 of these synchronised, then read the balance. If a single-use coupon credited the account more than once, the check-then-use gap is unguarded. Same shape for an invite that should bind one account but binds several, a withdrawal that clears against a balance read before it was debited, or a referral bonus claimed in parallel before the counter increments.
Why this is worth the trouble
The findings here are quiet and expensive. They do not throw errors, they do not reflect anything, and they pass every functional test because functional tests run one request at a time. The only way they surface is to remove the jitter, widen the window deliberately, and measure the side effect rather than the status code. Once you stop thinking of a race as “two clicks” and start thinking of it as “two transactions reading the same row before either commits,” you know exactly where to look and exactly what to send.