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.
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.
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.
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.
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.
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
- 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.
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 })
}// GOOD: Use a database transaction with row-level locking
export async function POST(request: Request) {
const { amount } = await request.json()
const result = await db.transaction(async (tx) => {
// SELECT ... FOR UPDATE locks the row
const credits = await tx.query(
`SELECT * FROM credits WHERE user_id = $1 FOR UPDATE`, [userId]
)
if (credits[0].balance < amount) {
throw new Error("Insufficient")
}
await tx.query(
`UPDATE credits SET balance = balance - $1 WHERE user_id = $2`,
[amount, userId]
)
return { redeemed: amount }
})
return NextResponse.json(result)
}// GOOD: Use atomic operations
await db.query(
`UPDATE credits SET balance = balance - $1
WHERE user_id = $2 AND balance >= $1
RETURNING balance`,
[amount, userId]
)
// If balance < amount, zero rows updated — no race possibleLimits and configuration
| Setting | Value | Note |
|---|---|---|
| Copies per endpoint | 10 | CONCURRENT_REQUESTS; the SAST-guided variant fires 5 |
| Flag threshold | 2 successes | 200, 201 or 204 among the copies |
| Methods replayed | POST, PUT, PATCH | DELETE is not replayed; the replay list in config is these three |
| Endpoints per scan | 10 | unique by path, first seen first; a cooperative time budget stops early |
| Input | intercepted authenticated requests | only mutations that carried a body and already succeeded once during the crawl |
| Severity · confidence | HIGH · 0.80 | the success count varies run to run, so it lives in the description, not the title |
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.