Find the race condition in the money-transfer endpoint
This payment handler debits one balance and credits another. Find the business-logic flaw an attacker exploits with many concurrent requests.
Constraints:
- the balance is read, checked, and written in separate steps
- no lock or transaction wraps the operation
- the attacker can fire thousands of
POST /payrequests in parallel
app.post("/pay", async (req, res) => {
const acc = await db.getAccount(req.user.id);
if (acc.balance >= req.body.amount) {
acc.balance -= req.body.amount; // read-modify-write, no lock
await db.save(acc);
}
});
Diagnose the cause.
A TOCTOU race: read-check-write isn't atomic, so concurrent requests read the same balance, all pass the >= check, and each debits — the attacker spends far more than they have. Fix: make it atomic — a transaction with row-level (pessimistic) locking, or one conditional update (UPDATE ... SET balance = balance - :amt WHERE balance >= :amt).
- ✗Mistaking the race for a CSRF or rate-limiting problem
- ✗Believing the >= check before the write protects under concurrency
- ✗Treating the symptom with a request limit instead of an atomic operation
- →Why does a single conditional UPDATE eliminate the race at its root?
- →How does a pessimistic row lock differ from a request limit here?
The vulnerability
const acc = await db.getAccount(req.user.id);
if (acc.balance >= req.body.amount) {
acc.balance -= req.body.amount; // read-modify-write, no lock
await db.save(acc);
}
The balance read, the >= check, and the write are spread over time and not atomic. With thousands of concurrent POST /pay, all requests read the same balance, all pass the check, and each debits — a TOCTOU race condition that lets the attacker spend more than they have.
The fix
Make the check and write indivisible. Conditional-update variant:
UPDATE accounts
SET balance = balance - :amount
WHERE id = :id AND balance >= :amount; -- 0 rows = insufficient funds
Or a transaction with a pessimistic row lock (SELECT ... FOR UPDATE).
✅ A conditional UPDATE/lock makes the check and debit one atomic step — the race is gone. ⚠️ Rate limiting and CSRF tokens help but do not close the race itself.