← Blog
November 4, 202610 min readAppSecVulnerability ResearchBug bountySecurity Engineering

A limit that broke once is not proof: verifying web race conditions

You fire the same request twice, both come back 200, and the single-use coupon applied twice — so out goes the report: “race condition.” But a limit that breaks once is one of the least reliable signals in testing: a client retry, a cached response, two open tabs, or plain network jitter all fake it. A practitioner's guide to telling a one-off fluke apart from a real concurrency bug, landing the race reliably with the single-packet attack, proving the effect actually persisted with a benchmarked differential on resources you own, the un-atomic voucher check behind a 2025 Shopware CVE and the GitLab email race that reached account takeover, and the atomic, locked, uniqueness-constrained fixes that close the window.

By The Xalgorix Team

The report writes itself. You send a single-use discount code, it applies. You send it again a split second later — and it applies a second time. Two 200 OKs, one coupon, and out goes the finding: “race condition — limit bypass.” The trouble is that a limit breaking once is one of the least reliable signals in all of security testing. Concurrency bugs are, by definition, timing-dependent, and timing is exactly what your network, the target's load balancer, a stray client retry, and a caching layer all quietly perturb. A single double-spend can be a real collision — or it can be luck you will never reproduce. The valuable, harder work is proving that it was concurrency, and only concurrency, that crossed the limit: a reproducible, benchmarked deviation from the application's normal sequential behaviour, with a side effect that actually persisted. That, not a lucky second success, is what a race-condition bug costs.

What a web race condition actually is

PortSwigger's Web Security Academy puts the definition plainly: race conditions occur when “websites process requests concurrently without adequate safeguards,” letting threads “interact with the same data at the same time” and collide. The race window is the brief period during which that collision is possible — classically the gap between two database interactions, such as the moment between checking a value and updating it. The best-known family is the limit overrun: a discount code is checked, applied, then marked used; fire the same code twice in parallel and both requests can pass the initial check before either update lands, because the server is briefly in a temporary sub-state where the code still looks unused. Redeeming a gift card repeatedly, reusing a CAPTCHA solution, over-drawing a balance, and bursting past a rate limit are all the same shape.

Underneath, this is a time-of-check to time-of-use (TOCTOU) flaw — CWE-367, a specific case of the broader CWE-362, “concurrent execution using shared resource with improper synchronization.” The check and the act are two steps, and anything that can slip between them can act on a decision that is already stale. Naming it correctly matters, because the fix lives in that gap, not in the endpoint you happened to hit.

Why one lucky overrun is not proof

Here is the asymmetry that makes race conditions different from most of the bugs in this series: the signal you are chasing is indistinguishable, in a single observation, from several perfectly boring explanations. “I sent two requests and both succeeded” is true in all of these, and only one of them is a vulnerability:

  • A client or environment artefact. Your HTTP client silently retried a timeout, two browser tabs both submitted, a CDN served a cached response, or the server is genuinely idempotent and simply returned the same result twice. Nothing collided; you double-counted a single effect.
  • Serialized, not concurrent. Many frameworks lock the session while handling a request — PHP's default session handler is the canonical example — so two requests in the same session run one after the other, not together. You never actually opened the race window, and a real bug can hide behind this exact behaviour.
  • A real collision. Both requests landed inside the window, both passed the check against the same sub-state, and both committed their effect. This — and only this — is the finding, and a single occurrence does not yet tell you which of the three you are looking at.
Two requests, both succeeded
Environment artefact
Retry, two tabs, cache, or a genuinely idempotent endpoint
Serialized by a lock
Same-session requests ran one at a time — window never opened
Real collision
Both passed the check against one stale sub-state, both committed
Only the right-hand branch is a race condition; one observation cannot tell them apart.

The way out of the ambiguity is the discipline the brand is named for: do not trust one run. Establish what sequential behaviour looks like, make the concurrency real and repeatable, and read the proof from state that persisted — then reproduce it. Each of those steps removes one of the innocent explanations above.

Making the window landable: the single-packet attack

The first obstacle to proof is purely mechanical: race windows are often around a millisecond wide, and network jitter smears your requests across far more time than that, so even a real bug refuses to trigger on demand. In 2023, PortSwigger's James Kettle published Smashing the state machine and the single-packet attack, which removes the jitter almost entirely. The idea exploits HTTP/2 multiplexing: send the bulk of 20–30 requests in advance while withholding the one frame that completes each one (an empty END_STREAM frame, or the final byte of a body), then release all the withheld frames in a single TCP packet. The requests arrive effectively simultaneously, bounded by the server's own processing rather than the distance to it.

The measured effect is dramatic: in Kettle's benchmark from Melbourne to Dublin, the single-packet attack landed requests within a median spread of about 1ms, versus roughly 4ms for the older last-byte-synchronization technique that HTTP/1 still relies on. Two requests can be enough to exploit a limit overrun, but sending the fuller group during discovery helps offset server-side jitter — and where front-end to back-end timing varies, connection warming (a few harmless requests first on the same connection) smooths it out. You do not have to build any of this by hand: it ships as “Send group in parallel” in Burp Repeater and inside Turbo Intruder. One important caveat for honest testing: a client-side delay splits requests back across packets and breaks the technique, so the tooling, not an ad-hoc script, is what makes the result trustworthy.

Queue N requests
final frame withheld
Warm the connection
smooth front/back timing
Release in one packet
all final frames together
~1ms spread
jitter neutralized
Window landable
collision on demand
20–30 HTTP/2 requests, final frame withheld, released in one TCP packet — the window becomes landable.

Real ones: an un-atomic voucher, and a race that reached takeover

The limit-overrun archetype showed up almost verbatim in CVE-2025-7954, a race condition SEC Consult disclosed in the voucher system of Shopware 6 (tested on v6.6.10.4). The advisory's diagnosis is the whole lesson in one line: “validation of voucher codes is not an atomic operation,” so “limited vouchers can be used in multiple simultaneous checkouts.” It is classified as CWE-362. What makes it worth citing in a piece about proof is the disagreement that followed: at disclosure in August 2025 SEC Consult reported that no vendor patch existed, and Shopware had initially classified the issue as “a not security-relevant bug,” reasoning that merchants can spot and cancel malicious orders. The CNA scored it medium (CVSS 4.0 base 6.0); the National Vulnerability Database later rated it high (CVSS 3.1 base 8.1). When the same proven bug draws a “won't fix,” a 6.0, and an 8.1, the reproducible technical fact — a single-use voucher redeemed more than once — is the only part everyone can actually stand on. That is the case for proving the effect rather than arguing the label.

Races are not only coupon abuse, though. Kettle's research walks through CVE-2022-4037, a race in GitLab's email handling where concurrent email-change requests could leave a confirmation token valid for an address the attacker did not own — verified-email forgery that, with GitLab acting as an OAuth provider, escalated to takeover of third-party accounts. GitLab fixed it in 15.5.7, 15.6.4 and 15.7.2 (January 2023; CVSS 6.4). The same primitive — two requests colliding on one record's state — lands on a discount in one app and on account ownership in another. The payload did not change; the wiring behind it did, which is precisely why severity has to be demonstrated per target, not assumed from the bug class.

Verifying it safely: predict, probe, prove

PortSwigger's methodology is a clean three steps, and each one closes off one of the innocent explanations from earlier. Predict:test only security-critical endpoints where two or more requests plausibly operate on the same record — redeeming a single-use code, deducting a balance, consuming a seat or invite, “mark as used” flows, anything with a count and a ceiling. Probe: benchmark the endpoint sequentially first so you know its honest baseline, then send the group in parallel with the single-packet attack (or last-byte sync on HTTP/1) and watch for any deviation — a different response, unusual timing, an error, or a downstream effect like a second confirmation email. Prove: understand the mechanism, cut the batch down to the minimal requests that matter, and confirm the effect persisted and reproduces.

Two practical details decide whether your result is real. Use a separate session per request, or framework session locks will serialize them and bury a genuine bug. And read the proof from persisted state, not the response code: a200 OK on each request is the race-condition equivalent of the empty 200 the rest of this series keeps warning about. The ledger showing two redemptions on a one-use voucher, a balance that went below zero, two seats consumed on a single-seat plan — reproduced across several trials — is the finding. Keep the blast radius to artefacts you created: your own test account, a voucher you generated, a benign and reversible effect. Never run a limit-overrun against a shared production resource whose over-consumption affects real users.

# Verifying a voucher limit-overrun on a resource you created, in a test account.
# The proof is the PERSISTED effect, reproduced — not a single 200.

# 0) Create your own single-use voucher (max_uses = 1) and a throwaway cart.

# 1) Benchmark SEQUENTIALLY first — establish the honest baseline:
#    apply voucher, read state  -> uses = 1, second attempt rejected. Good.
#    Then RESET uses back to 0 — a consumed voucher fails every limit check,
#    which would hide the race no matter how broken the synchronization is.

# 2) Probe CONCURRENTLY — against a fresh/reset voucher (uses = 0), fire N
#    identical "apply voucher" requests as one group (Burp "Send group in
#    parallel" / Turbo Intruder, HTTP/2 single-packet). Use a SEPARATE session
#    per request so session locks don't serialize them.

# 3) PROVE it from state, not the status code:
#    read the ledger  -> uses = 2 on a max_uses = 1 voucher   <-- the collision
#    RESET to uses = 0 and repeat the probe 5x -> it reproduces <-- not luck
#    (each trial MUST start from the same unused state, or step 2's check
#     passes once and the result is noise, not proof.)

# 4) Narrow down: drop to the two requests that matter. Extra requests can
#    overwrite the vulnerable sub-state and make a real bug look flaky.

This is also where automated testing hits an honest ceiling, ours included. A scanner can replay a request in parallel, but landing a sub-millisecond window reliably, running a clean sequential benchmark to compare against, using fresh sessions, and — hardest of all — knowing which persisted state means a limit was broken here are properties of the application's business logic, not of the request. Reliably telling a lucky retry from a re-exploited, reproduced collision is exactly the detection-to-proof gap that exploit-verification exists to close, and it is why fully autonomous race-condition testing stays genuinely hard: the ceiling is business logic, and no tool infers for free what a given count is allowed to reach.

The traps

  • One success is luck, not proof. A single double-spend is a lead. Reproduce it across trials before you call it a bug.
  • A 200 is not the effect. Two OK responses can both describe the same single change. Read the persisted state — the count, the ledger, the balance — and confirm it actually doubled.
  • Same-session locking hides the bug. If a framework serializes requests in one session, you never opened the window. Use a separate session per request.
  • More requests can mask a real race. Extra parallel requests can overwrite the vulnerable sub-state. Once you see a hit, narrow to the two that matter.
  • Client-side delays break the timing. A scripted delay splits requests across packets and ruins the single-packet attack; a flaky result then says more about your tooling than the target.

The fix that holds

You cannot out-test your way to safety here; you close the window in code. The core move is to make the check and the act a single atomic operation, so the database — not a sequence of application steps — serializes concurrent writers on the row. A conditional update that increments only while under the limit, and acting only when it reports a row changed, collapses the TOCTOU gap entirely. Back it with a uniqueness constraint as defence in depth, and never lean on one storage layer (a session) to protect another (the ledger).

-- Vulnerable: check-then-act. The gap between SELECT and UPDATE is the race
-- window — two requests both read uses = 0 and both decide they may apply.
SELECT uses, max_uses FROM vouchers WHERE code = ?;     -- time of check
--   ... application decides uses < max_uses, applies the discount ...
UPDATE vouchers SET uses = uses + 1 WHERE code = ?;     -- time of use

-- Fixed: make the decision atomic. One statement both checks and increments,
-- and the database serializes concurrent writers on the row.
UPDATE vouchers SET uses = uses + 1
  WHERE code = ? AND uses < max_uses;                   -- affected rows: 1 or 0
--   act on the discount ONLY if affected_rows == 1; otherwise reject.

-- Defence in depth: let the datastore refuse the impossible state outright,
-- and don't use a session to guard what only the database can serialize.
ALTER TABLE redemptions ADD CONSTRAINT uq_redeem UNIQUE (voucher_id, user_id);

The same shape generalizes, with one caveat worth stating plainly: wrapping a multi-step check-then-act in a transaction is not on its own enough. At a default isolation level like PostgreSQL's Read Committed, each statement reads its own snapshot, so two transactions can both read uses = 0, both approve, and both commit their increments — the transaction boundary alone does not serialize them. To close a genuinely multi-step sequence, hold a row lock across both steps with SELECT ... FOR UPDATE, or use serializable isolation with retry on conflict. Better still, prefer the conditional atomic write above over read-modify-write, and remove the sub-states that make the window exist in the first place. The point is not any one statement — it is that none of them may leave a moment where a second request acts on a decision that is already out of date.

The one-line version: a limit breaking once can be a retry, a cache, a lock, or luck — a race condition is none of those until you prove it. Benchmark the endpoint sequentially, make the concurrency real with the single-packet attack and a separate session per request, and read the proof from persisted state that actually doubled and reproduces across trials. The reproduced effect, not the second 200, is the distance between “it happened once” and proof.

Ready to see it prove a bug?

Start scanning on Cloud →