Watch a race condition happen, then close it
Task
Write a small program with a time-of-check to time-of-use flaw, demonstrate that parallel requests defeat its check, then fix it by making check and use atomic. TOCTOU is much clearer once you have watched a balance go negative.
Steps
- Write
/tmp/voucher.py: it keeps a balance in a file, and aredeem()that READS the balance, checks it is greater than zero, sleeps briefly, then writes balance minus one and counts the redemption as accepted. The sleep makes the window visible; in real code it is microseconds. Give it the interface the Verify uses:--reset Nsets the balance,--concurrent Kruns K redemptions on threads,--unsafeor--safepicks the version, and the LAST line it prints isaccepted <count> balance <final balance>. - Run ten redemptions sequentially against a balance of five and confirm it stops at zero, as intended.
- Now run ten redemptions CONCURRENTLY with threads, against a fresh balance of five. Record how many redemptions were accepted, and the final balance. Expect a surprise in the balance: every thread read 5 before any wrote, so they all write 4. The shop gave away ten vouchers' worth and the ledger says it gave away one -- the count of accepted redemptions is the measurement that shows it.
- Explain in
/tmp/toctou.mdexactly which two operations the other threads slipped between, and why the check was true for all of them. - Fix it by making check and use atomic: hold an exclusive lock with
fcntl.flockacross both, or use a single atomic operation. - Re-run the concurrent test and confirm the balance now stops at zero. Note that shortening the sleep did not fix it and the lock did.
Verify
python3 /tmp/voucher.py --reset 5 --concurrent 10 --unsafe | tail -1
python3 /tmp/voucher.py --reset 5 --concurrent 10 --safe | tail -1
python3 - <<'PY'
import subprocess
def run(mode):
last=subprocess.run(['python3','/tmp/voucher.py','--reset','5','--concurrent','10',mode],
capture_output=True,text=True).stdout.strip().splitlines()[-1].split()
return int(last[1]), int(last[3]) # accepted <n> balance <b>
ua,ub=run('--unsafe'); sa,sb=run('--safe')
print('unsafe: %d accepted, balance %d | safe: %d accepted, balance %d' % (ua,ub,sa,sb))
assert ua>5, 'the race did not trigger - widen the sleep or add threads'
assert sa==5 and sb==0, 'the locked version still oversold'
PY
The assertions are the verification. The unsafe run must accept more than five redemptions against a balance of five, proving the flaw is real rather than described; the safe run must accept exactly five and land on zero. Do not grade the unsafe run by its balance: when every thread reads before any writes, the balance ends at 4 after ten sales, which looks like one sale. That quiet ledger is the more dangerous half of the bug. If fewer than six are accepted, the window is too small to hit -- widen the sleep, which is simulating a slower operation such as a database round trip.
Notes
The fix that did not work is as instructive as the one that did. Shortening the window makes exploitation harder and leaves the flaw present, which is why the lesson insists the answer is atomicity rather than speed.
This is an independent study companion for CompTIA Security+ SY0-801 and is not produced by or endorsed by CompTIA.