| name | frisk-scan-race-conditions |
| description | CTF-style hunt for race conditions and atomicity bugs — read-then-write on money/credits/quotas without locking, non-idempotent webhook handlers, double-spend windows. |
| context | fork |
| agent | Explore |
Race condition & atomicity scan
You are a security researcher in a CTF. Your single job: find places where two concurrent requests (or a replayed webhook) can do something the application meant to allow only once — double-spend a balance, redeem the same coupon twice, claim a free trial twice, exceed a quota, process the same payment twice. Report concrete instances only.
The mental model: you are looking for code that reads a value, decides something based on it, and writes a new value back — without a transaction, row lock, atomic operation, or unique constraint preventing two requests from interleaving.
Coverage protocol
Two runs of this scanner on the same code should produce the same findings. The biggest cause of drift is skimming — stopping at the first few matches. Don't do that:
- Enumerate before judging. List every file/symbol matching "Where to look". Walk the set; don't sample.
- Decide each candidate. For each one, classify as finding / compensating-control-present / out-of-scope, then assemble the JSON.
- Recall over brevity. Borderline cases go in at
medium/low — don't drop them silently.
If your first finding came from the first file you opened, you skimmed. Restart with enumeration.
Where to look
app/Http/Controllers/**, app/Actions/**, app/Services/**, app/Jobs/**, app/Livewire/** — anywhere business logic mutates state.
app/Http/Controllers/**Webhook*, app/Http/Controllers/Webhooks/**, anything matching app/**/Webhook*.php — inbound webhooks from Stripe/Paddle/GitHub/etc.
app/Listeners/** — event listeners that mutate state in response to dispatched events.
database/migrations/** — to confirm whether a unique constraint would have prevented the race (e.g., a unique(['user_id', 'coupon_id']) on a redemptions table).
- Custom queue middleware (
app/Jobs/Middleware/**) — to see whether WithoutOverlapping / unique jobs are in use anywhere.
What counts as a finding
Read-modify-write on a mutable counter without locking
$user->balance -= $amount; $user->save(); (or any PHP-side arithmetic on balance, credits, points, stock, inventory, seats_remaining, coupon_uses_left, quota, usage) without a surrounding DB::transaction(...) and a lockForUpdate() on the read.
- Manual
$row->count = $row->count + 1; $row->save(); instead of $row->increment('count') (atomic in SQL) — flag when the column is a contended counter.
- Check-then-act:
if ($coupon->uses_left > 0) { … decrement } without a row lock or atomic decrement with a where('uses_left', '>', 0) guard.
Webhook handlers without idempotency
- A handler that mutates state based on an external event ID (Stripe
evt_…, Paddle alert id, GitHub delivery id) but does not first check whether that event ID has already been processed — no processed_at column, no firstOrCreate on the event id, no unique constraint, no Spatie webhook-client persistence.
- A payment-success handler that grants entitlements / extends subscriptions without a unique index on
(provider, event_id) and without checking it.
Free-once / one-per-user flows without a unique constraint
- Trial activation, referral bonus, signup credit, "first order" discount: a check like
if (!$user->had_trial) { $user->had_trial = true; $user->save(); … grant; } where had_trial is a boolean on the user row (racy), with no unique constraint backing it.
Job-level non-idempotency
- Jobs dispatched in response to user actions that mutate balance/credits/etc. without
Unique/WithoutOverlapping middleware and without database-level idempotency, where double-dispatch is plausible (e.g. a button users can double-click that dispatches a job, with no debounce).
ShouldBeUnique not implemented on jobs whose collision is obviously a bug (e.g. ProcessRefundJob, GrantCreditsJob).
Cache stampede / read-then-write on cache
Cache::get($key); if (!$value) { compute; Cache::put($key, …); } for an expensive operation that should be locked via Cache::lock(...). Only flag where the computed side-effect is itself sensitive (e.g. generating a one-time token), not generic memoization.
Filesystem races
- Two-step
if (!Storage::exists($path)) { Storage::put($path, …); } where the path is user-derived — second writer overwrites first.
What does NOT count
- Atomic operations:
$model->increment(...), $model->decrement(...), updateOrCreate, firstOrCreate, upsert, or update(['col' => DB::raw('col + 1')]).
- Code inside a
DB::transaction and using lockForUpdate() on the read of the counter, or using sharedLock() / optimistic locking via a version/updated_at check.
- Operations on per-user-scoped resources where two concurrent same-user requests aren't a realistic threat (e.g. updating the user's own profile name).
- Pure read endpoints.
- Anywhere the database unique constraint clearly makes the race a no-op (the second insert fails) and the failure is handled.
Severity guidance
high — Money, credits, refunds, billing, coupons, inventory, subscription state, or webhook handlers that grant entitlements — race-able with realistic concurrency.
medium — Quota/usage counters, trial/referral bonuses, one-per-user flows without backing unique constraints, jobs that should be ShouldBeUnique.
low — Cache stampede windows on expensive idempotent computations, filesystem two-step writes where the blast radius is small.
Output
End your response with a single fenced JSON block, nothing after it:
{
"scanner": "frisk-scan-race-conditions",
"findings": [
{
"intensity": "high",
"message": "CouponController@redeem reads `$coupon->uses_left` and saves a decremented value without `lockForUpdate` or a transaction — two concurrent requests can both pass the `uses_left > 0` check and double-redeem.",
"subject": "app/Http/Controllers/CouponController.php:48",
"url": null
}
]
}
If you find nothing, return "findings": []. Do not pad.