A single-use voucher is a promise: redeem it once, the balance drops to zero, done. The interesting question for an attacker is not can I redeem it but what happens if I redeem it three times before the database has finished telling itself I redeemed it once. On one programme I worked, the answer was that a £25 voucher became £75 of account credit, applied in a single burst of requests roughly 40 milliseconds wide. This is the anatomy of that bug: a textbook time-of-check to time-of-use (TOCTOU) race, why it survives normal testing, how I detected it with Turbo Intruder, how I proved impact without actually stealing money, and the two-line-of-intent fix that kills it.
The check and the use are two different moments
Most redemption code reads like this in pseudo-form, and it looks perfectly safe until you stare at the gap:
def redeem(voucher_code, account_id):
v = db.query("SELECT balance, status FROM vouchers WHERE code = %s", voucher_code)
if v.status != "ACTIVE" or v.balance <= 0: # <-- TIME OF CHECK
raise Rejected("voucher not redeemable")
apply_credit(account_id, v.balance) # the gap
db.execute("UPDATE vouchers SET status='REDEEMED', balance=0 "
"WHERE code = %s", voucher_code) # <-- TIME OF USE
The check (is this voucher still ACTIVE?) and the use (mark it REDEEMED) are separated by application logic and at least one network hop to the database. Under a single sequential user that gap is invisible. But if several requests reach the SELECT line before any of them reaches the UPDATE, every one of them sees status = ACTIVE, every one passes the check, and every one calls apply_credit. The final UPDATE runs N times but only the first transition matters; the credit, however, has already been applied N times. There is no row lock and no idempotency guard, so the database happily lets concurrent transactions read the same stale state.
The reason this slips through QA, manual pentests, and even most automated scanners is that they are sequential by nature. You click redeem, you see one credit, the voucher greys out, and the flow looks airtight. The bug only exists in the time dimension, and you have to attack it with concurrency.
Detecting it with Turbo Intruder
Single-packet attacks are the right tool here. The goal is to make the server process all redemption requests in the same instant, eliminating jitter from the network so the requests genuinely overlap inside the vulnerable window. Burp's Turbo Intruder, with its last-byte synchronisation gate, does exactly this. I captured the legitimate POST /api/v1/vouchers/redeem request and dropped it into this harness:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
engine=Engine.BURP2)
# Queue 30 identical redemption requests, hold the final byte back
for i in range(30):
engine.queue(target.req, gate='race1')
# Release all of them at once -> requests land within ~1-2ms of each other
engine.openGate('race1')
def handleResponse(req, interception):
table.add(req)
The gate parameter is the whole trick: each request is sent up to its final byte, then parked. openGate releases the held bytes for all 30 connections simultaneously, so the server sees a synchronised flood. On a modern stack the HTTP/2 single-packet variant tightens the spread to well under a millisecond, which is what let me reliably hit a 40ms server-side window.
What I watched for in the results table:
- More than one
200 OKwith a success body for a voucher that should be redeemable once. - The account balance afterwards being a multiple of the voucher value rather than equal to it.
- The voucher row still ending up
REDEEMED(the bug does not corrupt the voucher state; it over-applies the credit, which is what makes it sneaky).
First run: three of the thirty requests succeeded before the row flipped to REDEEMED. Account credit went from £0 to £75 on a £25 voucher. Replaying confirmed it was deterministic enough to be a real, reportable issue rather than a one-off fluke.
Proving impact without committing fraud
This is the part responsible disclosure lives or dies on. You must demonstrate financial impact without actually extracting value, and you must leave an audit trail that protects you. My approach:
- Use only my own test accounts and my own legitimately issued vouchers. No third-party objects ever entered the test.
- Cap the concurrency to the minimum that reproduces the effect (I found three successes with bursts of ten, not hundreds).
- Document the before and after balance with timestamps and the raw Turbo Intruder results table, then immediately raise a support ticket to have the over-credited balance voided rather than spending it.
- Quantify reach, not just the proof: a £25 voucher yielding 3x is a 200% loss multiplier; scaled across a promo campaign of, say, 50,000 vouchers, that is a six-figure exposure. That sentence is what moves a report from Medium to High.
I packaged this as an accepted submission with a short screen recording of the synchronised burst and the resulting balance. Framing it around loss multiplier and campaign blast radius, rather than "I got free money", is what got it triaged as High rather than dismissed as a self-inflicted edge case.
Remediation: make the use atomic
There are two complementary fixes, and a serious payments flow should have both.
Row-level locking collapses the check and the use into one atomic step. Inside a transaction, select the voucher FOR UPDATE so concurrent transactions block instead of reading stale state:
BEGIN;
SELECT balance FROM vouchers WHERE code = $1 AND status = 'ACTIVE' FOR UPDATE;
-- second concurrent txn now WAITS here until the first COMMITs
UPDATE vouchers SET status='REDEEMED', balance=0 WHERE code = $1;
COMMIT;
The losing transactions wake up after the commit, re-read status = 'REDEEMED', and fail the check cleanly. Better still, make the state transition itself the guard with a conditional update and check the affected row count: UPDATE vouchers SET status='REDEEMED' WHERE code=$1 AND status='ACTIVE' returns 1 for exactly one winner and 0 for everyone else.
Idempotency keys defend the credit-application side and the wider API. Require a client-supplied Idempotency-Key header, persist it with a unique constraint, and return the stored result on replay. Now even a retried or duplicated request applies the credit at most once, regardless of timing.
Takeaways
- TOCTOU bugs live in the gap between a check and a write; if that gap crosses a network hop without a lock, it is racing.
- Sequential testing will never find them. Turbo Intruder's gate, or HTTP/2 single-packet, is the detection primitive.
- Prove impact with your own assets, cap concurrency, void the gains, and report in terms of loss multiplier and blast radius.
- Fix with both
SELECT ... FOR UPDATE(or a conditionalUPDATEchecking row count) and idempotency keys. Either alone is fragile; together they make the operation genuinely once-only.