Skip to content
TRIAGERS
Deep Dives · 10 min read · TRIAGERS™ Team

Triaging Race Condition Reports: Reproduction, Impact, and Fair Effort

How to triage race condition reports: single-packet attacks, verifying state rather than status codes, environment sensitivity, and what counts as real impact.

Race conditions are the only common bug class where a competent triager can follow the reporter's steps exactly and see nothing happen. The report says "send 20 requests in parallel and the coupon applies twice." You send 20 requests in parallel, the coupon applies once, and now you have to decide whether the reporter got lucky, you got unlucky, or the bug does not exist. Closing as not-reproducible is the wrong default here more often than in any other class.

The underlying reason is that the exploit window is often sub-millisecond. Whether two requests land inside it depends on network jitter between the tester and the server, TLS handshake timing, connection warm-up, which worker process handles each request, and how the database's isolation level behaves under contention. Change any of those and the same PoC flips from reliable to impossible.

Establish that it is actually a race

Before spending an hour on concurrency tooling, run the sequential test. Send the same request twice, one after the other, and see what happens.

If the second sequential request also succeeds, this is not a race. It is a missing check: the limit is never enforced, and concurrency was incidental to the reporter's discovery. That distinction matters because a plain missing-limit bug is usually easier to exploit, more severe, and a different fix (add validation) than a race (add a lock, a unique constraint, or an atomic update). Reports that get filed as "race condition" and are actually "no limit enforcement at all" are common, and rating them as flaky low-severity races underpays a straightforward logic bug.

The second check is whether the observed duplicate has any state behind it. Two HTTP 200 responses do not mean two withdrawals occurred. Plenty of stacks return success from both requests while the database rejects the second write on a unique constraint, or while an idempotency key collapses them into a single operation. Verify at the artifact layer every time: the account balance, the ledger rows, the coupon's redemption counter, the number of seats consumed, the count of issued gift cards. Screenshot the state, not the response codes.

Single-packet attacks and last-byte sync

If you are still sending concurrent requests with a thread pool and hoping, you are testing timing jitter rather than the application. Two techniques remove most of that variance and should be the baseline for both reporters and triagers.

The single-packet attack works over HTTP/2 (and HTTP/3). Because HTTP/2 multiplexes streams over one connection, you can pack the complete final frames of 20 to 30 requests into a single TCP packet. The server receives them all at once and network jitter drops out of the equation entirely, compressing arrival spread from tens of milliseconds down to under one. Burp Repeater exposes this as "Send group in parallel (single-packet attack)", and Turbo Intruder supports it through the HTTP/2 engine.

Last-byte sync is the HTTP/1.1 equivalent. Open N connections, send each request except its final byte, wait for the connections to settle, then send all the final bytes together. Turbo Intruder's gate mechanism does this:

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=30,
                           engine=Engine.THREADED)
    for i in range(30):
        engine.queue(target.req, gate='race1')
    engine.openGate('race1')

def handleResponse(req, interesting):
    table.add(req)

Two details that decide whether an attempt is meaningful:

  • Warm the connections first. A cold TLS handshake or a lazily initialized connection pool adds variance that swamps the window you are trying to hit. Send a few throwaway requests on each connection before the real batch.
  • Watch server-side variance. If the endpoint's own processing time swings by 50ms depending on cache state, synchronizing arrival does not synchronize the critical section. Sending a lightweight "delay-inducing" request in parallel is a known trick, but for triage it is usually enough to run more attempts.

When a reporter's PoC is a bash loop with & at the end of each curl, the absence of a result on your side proves very little. Ask for the request group or Turbo Intruder script before you conclude anything.

What counts as demonstrated impact

The bar is a violated invariant with a consequence, shown in application state. Some examples that clear it:

  • Double-spend. A withdrawal, transfer, or purchase that debits once but delivers twice. Check whether it loops: a bug that can only ever double is bounded, and a bug where 30 parallel requests all succeed is unbounded.
  • Single-use redemption reuse. Coupons, gift cards, referral bonuses, promo credits, invite codes, free trials. The invariant is "redeemable once" and the state shows N redemptions.
  • Limit bypass with a security consequence. Exceeding a paid seat count, exceeding an upload quota, bypassing the attempt counter on OTP or 2FA verification, bypassing a password-reset token attempt limit. The OTP case is the one worth prioritizing, because it converts a 6-digit code from 1-in-a-million into something brute-forceable.
  • Privilege or state confusion. Accepting and revoking an invitation concurrently to end up in a group you should not be in, or upgrading and downgrading a role to land in an inconsistent permission state.

And some that do not clear it, at least as submitted:

  • Theoretical TOCTOU. "The code reads the balance and then writes it without a transaction" is a code observation. Without a demonstration that the resulting state violates something, it is a hardening suggestion.
  • Idempotent duplicates. Two requests that both set liked = true, both mark a notification read, or both write the same value. The end state is identical to the single-request case.
  • Cosmetic duplicates. Two identical audit-log rows, two copies of the same webhook delivery, two notification emails. Annoying, occasionally worth an Informative, rarely more. The exception is when the duplicate itself is the payload, for instance a notification email that includes a one-time token and now exists twice.
  • Races the attacker cannot win against their own account only. If the only party harmed is the attacker, there is no impact to rate.

A report that shows a violated invariant but not the loop is worth asking about, because the difference between "coupon redeems twice" and "coupon redeems 28 times in one batch" is often a full severity band. That kind of concrete follow-up question is exactly what our guide to writing reports that get paid asks researchers to preempt.

Environment sensitivity

Races are the class most likely to behave differently in your environment than in the reporter's, for reasons that have nothing to do with the report's honesty.

  • Instance count. A single application instance with an in-process mutex is safe. Scale it to four pods behind a load balancer and that mutex protects nothing, because each pod has its own. Staging with one replica will not reproduce a bug that only exists in production's fleet. The reverse also happens: production has a Redis-based distributed lock that staging lacks.
  • Database isolation level. PostgreSQL READ COMMITTED (the default) allows the classic read-modify-write race. SERIALIZABLE does not, and will abort one transaction instead. MySQL's REPEATABLE READ with gap locking behaves differently again. A test environment configured differently from production will give you the wrong answer in either direction.
  • Rate limiting and WAF. A layer that queues or serializes bursts can hide a race entirely. If your attempts are returning 429s partway through the batch, you are testing the rate limiter.
  • Account state. Fresh coupon vs already-partially-redeemed, zero balance vs funded, new account vs one with a cached entitlement record. Reset state between every attempt or your later attempts test something different from your first.
  • Connection reuse and HTTP version. If the target downgrades your HTTP/2 request to HTTP/1.1 at an edge proxy, the single-packet attack silently degrades into ordinary parallelism.

Record which of these applied when you write the verdict. "Not reproducible on staging (single replica, SERIALIZABLE isolation)" is a useful sentence. "Not reproducible" is not.

How much reproduction effort is fair

There is no universal number, but there is a defensible floor, and it is well above one attempt.

A reasonable baseline before closing a race report as not reproducible:

  1. At least 10 batches, with fresh state for each, not 10 requests total.
  2. Varied concurrency. Try 5, 20, and 50 parallel requests. Some races only land at low concurrency because high concurrency triggers a queue or a connection limit that serializes everything.
  3. Both sync techniques. Single-packet over HTTP/2 if the target supports it, last-byte sync otherwise.
  4. Warmed connections, and confirmation you are not being rate limited mid-batch.
  5. State verified after each batch, at the database or account level rather than by response code.
  6. A second environment if available. If production is the only place with multiple replicas, that changes what the test on staging means.

If all of that fails, go back to the reporter with specifics rather than a template close: which technique you used, how many attempts, what concurrency, what the post-batch state showed, and a request for their exact tooling and account preconditions. Most disputed race reports resolve at that step, and the ones that resolve in the reporter's favor usually turn on a precondition nobody wrote down. This is the same reproduce-carefully-then-rate discipline covered in our triage workflow; races just move the cost of skipping it much higher.

Flip the effort question around too. If a reporter needed 200 attempts to land it once, that is a genuine exploitability constraint and belongs in the severity discussion. It does not make the bug invalid, since an attacker with a script has patience the reporter did not, but a race that lands 1 time in 200 against a fraud-monitored payment flow is a different risk from one that lands every time.

Severity reasoning

Rate races on the value of the invariant broken, whether the attacker can repeat it, and whether the damage is recoverable.

Scenario Rough impact
Unbounded withdrawal or transfer duplication (N successes per batch) Critical
OTP, 2FA, or reset-token attempt-limit bypass High
Bounded double-spend (exactly 2x, non-repeatable) on real money High to Medium
Repeatable coupon, credit, or referral-bonus reuse at low value Medium
Paid-tier or seat-limit bypass Medium
Duplicate rows with no business consequence Low to Informative
Code-level TOCTOU with no demonstrated state violation Informative

Three modifiers move these around. Repeatability is the largest: bounded-to-2x and unbounded are different bugs with the same root cause. Reversibility matters next, since a duplicated ledger entry that reconciliation catches the same day costs less than a duplicated payout that leaves the platform. Detection sits alongside it: a race that fires silently outranks one that trips a fraud rule on the second attempt. Our severity guide treats scale as a first-class input for the same reason, and races are where scale is decided by whether the attacker can run the batch again tomorrow.


Race reports fail in the queue more often than they fail on the merits, because a single unsuccessful batch looks exactly like a false report. TRIAGERS™ runs on-demand triage teams who reproduce with proper sync techniques and verify state rather than status codes. If your concurrency findings keep stalling, get in touch.

Keep reading
The Triage Brief

New articles, straight to your inbox.

Practical triage writing, published a few times a month. Unsubscribe anytime.

Or grab the RSS feed.

Drowning in unread reports?

Lease an expert triage team that validates, reproduces and rates every submission, so your engineers only see signal.

Get a triage team