SourCode a FreeFungi app

Challenges FF-009

Toffee CWE-362 A04:2021

Loyalty Loophole

Race condition on reward claims.

Your account has store credit, and you can spend £5.00 of it on a voucher. The shop checks you have enough, then deducts it. Between those two steps there is a tiny gap, and if you ask for a voucher several times at once, every request checks your balance before any of them deducts. They all see the same £5.00, they all say yes, and you walk away with a fistful of vouchers for a single fiver. This is a race condition.

New to this? What is a race condition?

A race condition is when the result depends on the timing of things that overlap. A common shape is check-then-act: the app checks a condition (“do you have enough credit?”) and then acts on it (“deduct the credit”), assuming nothing changed in between.

Fire many requests in parallel and they all pass the check before any of them acts. The fix is to make the check and the act happen as one indivisible step.

Log in as alice, who starts with £5.00 of store credit. Winning a race means sending several requests at the exact same instant, which you cannot do by clicking, so this exercise uses a terminal. Do not click the claim button by hand first: that spends alice’s £5.00, and you want it intact for the race.

1 · Find it

On alice’s account page there is £5.00 of store credit and a Claim a £5 voucher button. That button is meant to work exactly once: £5.00 buys one voucher and the credit drops to £0.00. Check the credit really does say £5.00 before you start. (If it says £0.00, this account has already spent it: see running it again below, or log in as bob, who also starts with £5.00.)

The flaw is the gap inside that one button. Behind it, the server first checks you have £5, then a moment later deducts it and issues the voucher. Land several requests in that gap at once and they all pass the check before any of them deducts.

2 · Exploit it

The command needs alice’s session cookie so the shop treats the requests as her. Open developer tools (right-click → Inspect, or F12 / Cmd+Option+I), go to the Application tab (Chrome/Edge) or Storage tab (Firefox) → Cookies → http://localhost:3000, and copy the value of the ff_session row.

With alice still at £5.00, swap YOUR_COOKIE for that value and run the command below. It fires 10 claims at the same instant and prints the voucher code each one hands back:

seq 10 | xargs -P10 -I{} curl -s -b "ff_session=YOUR_COOKIE" \
  -X POST http://localhost:3000/rewards/claim | grep -oE "Voucher [0-9A-F]+"
1..10 | ForEach-Object -Parallel { curl.exe -s -b "ff_session=YOUR_COOKIE" -X POST http://localhost:3000/rewards/claim } -ThrottleLimit 10 | Select-String -Pattern "Voucher [0-9A-F]+"

Needs PowerShell 7 (pwsh) for -Parallel. On older Windows PowerShell, run the Mac/Linux command in Git Bash or WSL instead.

The terminal prints around ten different voucher codes, one for each claim that succeeded. Now reload alice’s account page: the credit has gone negative, roughly £-45.00. A single £5.00 balance just bought ten vouchers, because all ten claims checked the balance before any of them deducted. (There is no list of vouchers on the page, so the codes in your terminal and the negative credit are the proof.)

Why it matters: races turn into free money, double-spent gift cards, over-drawn balances and bypassed limits. Anywhere a check guards a spend, timing can beat it.

Running it again? The race spends alice’s credit, so a second run needs a fresh £5.00. The first run needs no restart: a freshly started shop already gives alice £5.00. For another go, log in as bob (he also starts with £5.00), or reseed the shop: with Docker that is docker restart SourCode (it reseeds every boot), and from source it is npm run seed. There is no in-app reset button, by design.

3 · Understand it

The handler reads the balance, waits on something, then deducts. That wait is all the gap a parallel request needs:

const me = getCredit(userId);
if (me.credit < 500) return tooPoor();
await checkLoyaltyService();          // <- others slip in here
setCredit(userId, me.credit - 500);   // everyone deducts from the same start
issueVoucher(userId);

4 · Fix it

Make the check and the deduction one atomic operation, so only one request can win. A single conditional UPDATE does it: deduct only if enough is there, and let the database serialise the writes:

✗ Vulnerable
if (getCredit(id) >= 500) {
  await something();
  setCredit(id, credit - 500);
}
✓ Patched
const r = db.prepare(
  `UPDATE customers SET credit_pence = credit_pence - 500
   WHERE id = ? AND credit_pence >= 500`
).run(id);
if (r.changes === 1) issueVoucher(id);

Now two claims cannot both succeed: the first row update wins, the second finds the credit already gone and changes nothing.

The general rule: never check-then-act on shared state across a gap. Use an atomic update, a transaction, or a lock, so the guard and the change cannot be split.