DAST scanner · special

Race Conditions

A balance check and the write that follows it are two separate steps. Two requests that arrive together can both pass the check before either writes. isitsecure provokes exactly that by sending identical copies of a mutation at the same instant and counting how many the server accepts.

DASTHighCriticalBusiness logicTOCTOU

The race: check, gap, write

The endpoint reads a balance, checks it covers the amount, then writes the new balance. Nothing stops a second request from reading the same old balance in the gap between the check and the write. Two lanes, one row in the database.

time Request A redeem 50 Request B redeem 50 the gap READ balance = 100 CHECK 100 ≥ 50 passes WRITE 100 − 50 = 50 READ balance = 100 CHECK 100 ≥ 50 passes WRITE 100 − 50 = 50 credits row user_id = 7 balance 100 50 should be 0 Spent 100 of balance, got 2× the value. Two identical POST /redeem requests arrive within the same few milliseconds.

1Two identical requests, A and B, each ask to redeem 50 credits from a balance of 100. They arrive together, so the server handles them on two concurrent workers against the same row.

2Read. Both workers read the balance before either has changed it. Both see 100.

3Check. Both compare 100 ≥ 50 and both pass. The gap between this check and the write is the vulnerability: the value being checked is already stale for one of them.

4Write (A). A writes 100 − 50 and the row becomes 50. Correct so far.

5Write (B). B also writes 100 − 50, computed from the 100 it read earlier, so the row stays at 50 instead of reaching 0. The user spent 100 of balance and received twice the value.

How the scanner tests it

The scanner does not reason about the code. It picks the state-changing requests the authenticated crawl already saw succeed, fires N identical copies of one at the same instant over one shared connection, and counts how many the server accepts. A sequential control replay goes after the burst (so it cannot use up a single-use resource first): if the server rejects it, the endpoint enforces a limit on replays, and two or more concurrent successes mean the check did not hold.

State-changing requests seen during the authenticated crawl POST /api/wallet/redeem {"amount": 50}balance deduction POST /api/coupons/apply {"code": "SAVE20"}one-time code PUT /api/polls/9/vote {"choice": 2}one vote per user POST /api/users {"email": …}unique constraint GET /api/walletread only, skipped Scanner race_condition_scanner replays A's own session N = 10 identical copies Target POST /api/wallet/redeem read · check · write on every copy one httpx client · one shared connection · all copies in flight together responses 201 201409 201409 409 409 409 409 409 409 409 3 of 10 succeeded2xx count ≥ 2flag: HIGH · confidence 0.80 1 of 10 succeededbelow the threshold of 2not flagged ten copies leave in one burst, same body, same session headers

1Pick targets. From the requests captured while crawling as the logged-in user, keep the state-changing ones (POST, PUT, PATCH) that carried a body and got a success. Balance deductions, coupon redemption, votes and account creation are the shapes where a double success costs something. Reads are skipped, and one request per path is kept.

2One connection. The copies are sent through a single shared HTTP client, so they travel the same connection and reach the server as close together as the network allows. Separate connections would spread them out and hide the race.

3Fire, then control. Ten identical copies of the request leave in one burst, byte-for-byte the same body and the same session headers as the original.

4Responses. Each copy gets its own status. A server that serialises correctly accepts one and rejects the rest; this one accepted three.

5Decision. Count the copies that returned 200, 201 or 204. Two or more successes while the post-burst control was rejected means the endpoint has a race condition: it is reported as HIGH with confidence 0.80, and the success count goes in the description. If the control was accepted too (an add-comment style endpoint), only an Info note at 0.30 is kept.

6The quiet case. One success and nine rejections is exactly what a locked or atomic implementation produces, so nothing is reported. Any 2xx below the threshold is treated as normal.

Also triggered from code

When the LLM code review flags a route as a check-then-act pattern, SAST-guided DAST generates the same test for that route directly: five concurrent identical mutations, fired even if the crawl never exercised the endpoint.

The fix: make the check and the write one step

Either hold a row lock from the read until the write, so the second request has to wait and then reads the real value, or move the check into the write itself so the database performs both atomically. The scene uses the Starbucks shape: both requests try to spend the whole balance.

1 · Row lock: SELECT … FOR UPDATE Aspend 100 Bspend 100 SELECT … FOR UPDATE lock taken · balance = 100 100 ≥ 100 passes UPDATE balance = 0 COMMIT lock released SELECT … FOR UPDATE blocked: waiting for the lock lock acquired · balance = 0 0 ≥ 100 fails throw "Insufficient" ROLLBACK nothing written credits row user_id = 7 balance 100 0 A holds free B holds free spent 100, got 100 2 · Atomic: the check lives inside the UPDATE UPDATE credits SET balance = balance − $1 WHERE user_id = $2 AND balance ≥ $1 RETURNING balance one statement: no read, no gap, no lock to hold in application code A 1 row updated RETURNING 0 B 0 rows updated WHERE matched nothing → Insufficient

1Lock. Inside a transaction, A reads the row with SELECT … FOR UPDATE and takes a row lock. B issues the same statement a moment later and blocks: it cannot read the row until A's transaction ends.

2A completes. A checks 100 ≥ 100, writes balance 0 and commits. Committing releases the lock.

3B reads the truth. B's read now returns 0, the committed value. Its check 0 ≥ 100 fails, it throws "Insufficient" and rolls back. The second request was serialised behind the first, so it checked a real balance.

4Atomic alternative. Fold the check into the write: UPDATE … WHERE balance ≥ $1 RETURNING balance. The database evaluates the condition and applies the change as one operation. A updates one row; for B the WHERE matches nothing, so zero rows update and there is no gap to race.

Why it matters

What a double success buys
  • Double-spend: redeem credits or gift cards twice.
  • Free purchases: exploit payment flows to get products without paying.
  • Bypass limits: vote multiple times, create duplicate accounts.
  • Data corruption: concurrent writes leave the database inconsistent.
Real-world breaches

Starbucks (2015). Researchers demonstrated a race condition in Starbucks' gift card system that allowed duplicating gift card balances by simultaneously transferring the same balance to multiple cards.

Docker runc, CVE-2019-5736 (2019). A TOCTOU race condition in runc allowed a malicious container to overwrite the host runc binary, gaining root-level access on the host system.

Vulnerable vs fixed code

The vulnerable handler reads, checks and writes as three separate operations. The fixed versions either hold a row lock across all three or let the database do the check inside the write.

// BAD: Check-then-act without locking
export async function POST(request: Request) {
  const { amount } = await request.json()

  // Read current balance
  const credits = await db.query("credits", `user_id = '${userId}'`)
  const balance = credits[0].balance

  // Check
  if (balance < amount) return error("Insufficient")

  // GAP: Another request can read the same balance here

  // Act
  await db.update("credits", { balance: balance - amount })
  return success({ redeemed: amount })
}

Limits and configuration

SettingValueNote
Copies per endpoint10CONCURRENT_REQUESTS; the SAST-guided variant fires 5
Flag threshold2 successes200, 201 or 204 among the copies
Methods replayedPOST, PUT, PATCHDELETE is not replayed; the replay list in config is these three
Endpoints per scan10unique by path, first seen first; a cooperative time budget stops early
Inputintercepted authenticated requestsonly mutations that carried a body and already succeeded once during the crawl
Severity · confidenceHIGH · 0.80the success count varies run to run, so it lives in the description, not the title
What it stays quiet about

The test needs a mutation the crawl saw succeed with a body, so an endpoint the logged-in user never touched is not replayed unless code review flags it. A server that legitimately accepts several copies (a balance large enough for two redemptions, or an endpoint that is idempotent by design) will still count as two successes; the finding reports the count so the reader can judge.