Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
A race condition in a web application happens when server code performs a check, then later performs an act based on that check, and the two steps are not atomic — a second request can slip through the window between them before the first request’s write lands. Limit-overrun is the most common exploitable pattern: any endpoint that enforces a single-use or bounded-quantity rule (redeem this coupon once, don’t withdraw more than your balance, only one free trial per account) by reading current state and writing new state in separate steps can be tricked into applying that action more times than the limit allows, simply by firing multiple requests close enough together that they all read the *pre*-write state.
For years these bugs were considered low-value because network jitter made them hard to win reliably over the internet. James Kettle’s 2023 research on the single-packet attack technique changed that: by using HTTP/2’s ability to send many request streams within a single TCP packet, an attacker can force a server to receive dozens of requests within microseconds of each other, making races reliably winnable even against remote, internet-facing targets. Since then race conditions have become a mainstream, high-payout bug-bounty category rather than an academic curiosity.
Attack Prerequisites
Limit-overrun races require a specific, common code shape and the ability to fire near-simultaneous requests:
- A check-then-act sequence with no atomicity — the server reads the current state (balance, usage flag, remaining count) in one step and writes the updated state in a later, separate step, with no row lock, transaction isolation, or atomic compare-and-swap between them.
- Concurrent request handling — the server (or its worker pool / load balancer) can process more than one request for the same resource at the same time, which is true of essentially every modern multi-threaded or multi-process web backend by default.
- A way to land multiple requests inside the race window — either many parallel connections with careful timing, or (far more reliably) an HTTP/2 single-packet attack that submits all request streams together.
How It Works
Consider any “only once” or “only up to N” business rule implemented as read-state → business-logic → write-state. If two requests for the same action arrive close enough together, both can complete their *read* before either completes its *write* — both see the pre-action state (“not yet redeemed”, “balance sufficient”), both proceed through the business logic, and both write, so the limit that was supposed to allow this exactly once is applied twice (or N times, for N parallel requests). The vulnerability is not in any single request’s logic — each one is individually correct — it is in the missing atomicity across concurrent executions of that logic.
Winning this window reliably over a network historically required either extreme proximity to the target or luck, because HTTP/1.1 requests sent from separate TCP connections arrive with independent, jittery latency. The single-packet attack sidesteps this: HTTP/2 multiplexes many logically separate request streams over one TCP connection, and by holding back the final frame of each request until all requests are queued, then releasing them together in one write (and ideally one TCP packet), the server’s network stack delivers them essentially simultaneously — collapsing the race window from milliseconds of jitter to sub-millisecond, and making even tight windows reliably exploitable.
Limit-overrun is one of several race sub-classes: the same primitive also enables multi-endpoint races (racing a password-reset request against an email-change confirmation, for instance) and time-of-check/time-of-use bugs in authentication flows. Limit-overrun is simply the easiest to reason about and the most directly tied to financial or quota impact.
Vulnerable Code / Configuration
A textbook non-atomic check-then-act: coupon redemption implemented as a SELECT followed later by an UPDATE, with no transaction or row lock binding them together:
// VULNERABLE: check and act are two separate, unsynchronized statements
app.post('/redeem', async (req, res) => {
const coupon = await db.query(
'SELECT * FROM coupons WHERE code = ?', [req.body.code]);
if (!coupon || coupon.used) {
return res.status(400).send('Invalid or already used');
}
// ---- RACE WINDOW: a concurrent request can pass the same check here ----
await applyDiscount(req.user, coupon.value);
await db.query('UPDATE coupons SET used = 1 WHERE code = ?', [coupon.code]);
res.send('Applied');
});
// N simultaneous requests with the same code can all read used=0 before any
// of them writes used=1, so the discount is applied N times.
JavaScriptWalkthrough / Exploitation
Establish a baseline first: redeem the coupon (or perform the limited action) once normally and confirm a second sequential attempt is correctly rejected — this proves the *logic* is right and any bypass must come from timing, not a missing check.
Burp Suite’s Turbo Intruder extension is the standard tool for this. Its examplePoC-derived race template opens a *gate* that queues every request without sending it, then releases them together over HTTP/2 as a single-packet attack:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2) # HTTP/2 single-packet attack
# queue N identical redemption requests behind a gate
for i in range(20):
engine.queue(target.req, gate='race1')
# release all 20 requests together, minimizing jitter between them
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
PythonRun it against the redeem request captured in Burp, then inspect the results table: if more than one of the 20 responses reports success (“Applied” instead of “Invalid or already used”), the race won, and the account’s balance/order history should now show the discount applied multiple times — confirm the actual state, not just the HTTP responses, since some apps return a misleading 200 even when the write was later rejected.
For a quick manual check without scripting, Burp Repeater’s native “Send group in parallel” feature (added in 2023) sends a saved group of requests using the same single-packet technique — useful for a fast sanity check before committing to a full Turbo Intruder run.
Note: Limit-overrun isn’t limited to coupons — the same pattern shows up in withdrawal/transfer endpoints (parallel withdrawals each individually within balance, together overdrawing it), “claim reward” or “start free trial” flows, vote/like counters meant to be capped per user, and file/API quota enforcement. Anywhere the phrase “only once” or “only up to N” appears in a spec, assume it’s implemented as check-then-act until proven otherwise.
Opsec / Testing tip: Connection warming matters: send one throwaway request to the target first so TCP/TLS setup latency doesn’t skew the timing of your race attempt, then fire the real batch. Also account for last-byte synchronization — Turbo Intruder’s gate mechanism exists specifically to hold every request back until the final byte, then release them together, rather than sending them one after another as fast as possible.
Detection and Defense
The fix is to make the check and the act a single atomic operation, not to add more logic around the existing two-step pattern:
- Use an atomic conditional update and check the affected-row count instead of a separate
SELECT:UPDATE coupons SET used = 1 WHERE code = ? AND used = 0, then verify exactly one row was affected before applying the discount. - Wrap the check and the write in a database transaction with an appropriate isolation level, or use
SELECT ... FOR UPDATEto take a row lock that blocks concurrent readers until the first transaction commits. - Use a database unique constraint to make double-application structurally impossible (e.g. a unique index on
(user_id, coupon_id)in a redemptions table) rather than relying purely on a boolean flag. - Use a distributed lock (Redis
SET NX/ Redlock, or an equivalent mutex) for actions that span multiple services or aren’t naturally expressible as one SQL statement. - Require idempotency keys on financial or state-changing operations so retried/duplicated client requests collapse into a single server-side effect.
- Treat rate limiting as defense-in-depth only — it reduces the number of attempts an attacker can make, but a sufficiently narrow race window can still be won within a rate limit’s burst allowance.
Real-World Impact
PortSwigger’s 2023 research “Smashing the state machine: the true potential of web race conditions” (James Kettle) demonstrated the single-packet attack technique against a wide range of production-style targets, uncovering unlimited discount-code redemption, repeatable free-trial abuse, and balance overdrafts that had gone unnoticed under traditional, jitter-limited race testing. The technique’s publication, along with its adoption directly into Burp Suite’s Repeater and Intruder tooling, moved race-condition testing from a niche technique into a standard step of modern web application assessments.
Conclusion
Any endpoint that enforces a one-time or bounded action by checking state and writing it back in separate steps is a race condition waiting for enough concurrency to expose it, and the single-packet attack means that concurrency is now trivial to generate reliably over the network. Fix it at the data layer — atomic conditional updates, transactions with row locks, and unique constraints — rather than trying to out-race the attacker with faster application code.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments