This is a ledger lab, not a “drain the wallet” script. Target is a 50-line Python service with a SQLite table accounts(id, owner, balance, version). Goal: show a lost update when two threads increment without version, show the same increment stop at one-winner with WHERE version=?, and show a deterministic IDOR that needs no concurrency at all. Mixing those two tickets is how teams ship the wrong fix.
1Figure 1. Negative balance can be a race or an IDOR. The SQL tells you which.
2xfer_naive: check balance; UPDATE (lost update, two 200s)
3xfer_ver: UPDATE ... WHERE version=? (one 200, one 409)
4xfer_id: missing owner==user (IDOR, no race)
Lab layout
1labs/ledger_lab/
2 schema.sql
3 app.py # two endpoints: /xfer (body) and /xfer_id (idor-shaped)
4 race_client.py # two threads, same account, lab only
1CREATE TABLE accounts (
2 id INTEGER PRIMARY KEY,
3 owner TEXT NOT NULL,
4 balance INTEGER NOT NULL,
5 version INTEGER NOT NULL DEFAULT 0
6);
7INSERT INTO accounts(id, owner, balance, version) VALUES
8 (1, 'alice', 100, 0),
9 (2, 'bob', 100, 0);
1# app.py — excerpt, 127.0.0.1
2def xfer_naive(src, dst, n):
3 db = sqlite3.connect(DB)
4 a = db.execute("SELECT balance FROM accounts WHERE id=?", (src,)).fetchone()
5 if a["balance"] < n:
6 return "insufficient", 409
7 db.execute("UPDATE accounts SET balance = balance - ? WHERE id=?", (n, src))
8 db.execute("UPDATE accounts SET balance = balance + ? WHERE id=?", (n, dst))
9 db.commit()
10 return "ok", 200
11
12def xfer_ver(src, dst, n):
13 db = sqlite3.connect(DB)
14 db.execute("BEGIN IMMEDIATE")
15 a = db.execute("SELECT balance, version FROM accounts WHERE id=?", (src,)).fetchone()
16 if a["balance"] < n:
17 db.rollback()
18 return "insufficient", 409
19 cur = db.execute(
20 "UPDATE accounts SET balance = balance - ?, version = version + 1 "
21 "WHERE id=? AND version=?",
22 (n, src, a["version"]),
23 )
24 if cur.rowcount != 1:
25 db.rollback()
26 return "conflict", 409
27 db.execute("UPDATE accounts SET balance = balance + ? WHERE id=?", (n, dst))
28 db.commit()
29 return "ok", 200
Authn in the lab is a header X-User: alice. I do not pretend this is a real session.
Artifact: lost update (concurrency)
Start both accounts at 100. Two threads each move 80 from alice(1) → bob(2) through xfer_naive. Each thread’s check sees 100.
1$ python3 race_client.py naive
2t0 status=200 body=ok
3t1 status=200 body=ok
4$ sqlite3 /tmp/ledger.db 'SELECT id,owner,balance,version FROM accounts'
51|alice|-60|0
62|bob|260|0
Alice is at -60. Both checks passed. Neither UPDATE used version. App log:
12026-04-11T09:12:01.001 xfer_naive src=1 dst=2 n=80 user=alice read_balance=100
22026-04-11T09:12:01.002 xfer_naive src=1 dst=2 n=80 user=alice read_balance=100
32026-04-11T09:12:01.010 commit src_balance=-60
Same client against xfer_ver:
1$ python3 race_client.py ver
2t0 status=200 body=ok
3t1 status=409 body=conflict
4$ sqlite3 /tmp/ledger.db 'SELECT id,owner,balance,version FROM accounts'
51|alice|20|1
62|bob|180|0
One winner, version bumped, alice never negative. That is the concurrency fix.
SQL I want in review, next to the bad one:
1-- bad: no predicate on the value we checked
2UPDATE accounts SET balance = balance - 80 WHERE id = 1;
3
4-- good: compare-and-swap on version
5UPDATE accounts SET balance = balance - 80, version = version + 1
6 WHERE id = 1 AND version = 0;
7-- require rowcount == 1
BEGIN IMMEDIATE in SQLite takes a write lock so the SELECT+UPDATE is one writer. Postgres equivalent is SELECT ... FOR UPDATE or the same version predicate without relying on app-tier mutexes (two nodes).
Artifact: IDOR (authorization), no race required
1@app.post("/xfer_id")
2def xfer_id():
3 user = request.headers.get("X-User", "")
4 src = int(request.form["src"])
5 dst = int(request.form["dst"])
6 n = int(request.form["n"])
7 # BUG: never checks accounts.owner == user
8 return xfer_ver(src, dst, n)
One request, no threads:
1curl -sD - -H 'X-User: mallory' \
2 -d src=1 -d dst=2 -d n=10 \
3 http://127.0.0.1:5000/xfer_id
4# HTTP/1.0 200 OK
5# ok
1$ sqlite3 /tmp/ledger.db 'SELECT id,owner,balance FROM accounts'
21|alice|90
32|bob|110
Mallory moved alice’s money. version worked. Authz did not. A lock will not fix this.
Correct check:
1row = db.execute(
2 "SELECT owner, balance, version FROM accounts WHERE id=?", (src,)
3).fetchone()
4if row["owner"] != user:
5 log.info("authz_fail user=%s src=%s", user, src)
6 return "forbidden", 403
12026-04-11T09:18:44 POST /xfer_id user=mallory src=1 dst=2 n=10
2 authz_fail user=mallory src=1
3 result: 403
4# balances unchanged
Failed-auth (missing header) is a third line, also not a race:
12026-04-11T09:18:50 POST /xfer_id user=- src=1
2 result: 401
Analysis: which ticket is it?
| Symptom | Parallelism needed? | SQL / code shape | Fix |
|---|---|---|---|
Balance negative, two 200s, same src | yes | UPDATE without version / rowcount | CAS / FOR UPDATE |
Balance moves, caller is not owner | no | missing owner == user | bind object to subject |
| Admin route with a user token | no | missing role check | function-level authz |
| Double coupon, two 200s | yes | used=1 without WHERE used=0 | see web-race note |
If a tester needs Burp Turbo Intruder, think race. If a tester needs one changed account_id, think IDOR. If they needed both, file two bugs.
Sanitized reproduction
Race:
1$ python3 race_client.py naive
2# alice balance -60 (lab seed, not a customer)
ASAN analog — a C helper I used to show the lost update is a data race on a plain int (same as the Go counter note):
1/* bal.c — two pthreads, no mutex */
2static int bal = 100;
3void *t(void *p) {
4 if (bal >= 80) bal -= 80;
5 return 0;
6}
1$ clang -fsanitize=thread -g -o bal bal.c -lpthread
2$ ./bal
3==================
4WARNING: ThreadSanitizer: data race
5 Read of size 4 at 0x[REDACTED] by thread T2
6 Write of size 4 at 0x[REDACTED] by thread T1
7==================
8bal=-60
TSan is the crash. I do not need a production debit API.
IDOR:
1$ curl -s -o /dev/null -w '%{http_code}\n' -H 'X-User: mallory' -d src=1 -d dst=2 -d n=1 \
2 http://127.0.0.1:5000/xfer_id
3200 # before owner check
4403 # after
Mitigation
- Object-level authz: every debit keyed by
owner = session.user(or a membership table), in the same statement as the balance change if possible. - Concurrency:
versioncolumn orUPDATE ... WHERE balance >= :nwithrowcount==1, plus a CHECK(balance >= 0)as a last-resort constraint. - Do not “fix” IDOR with a mutex.
- Do not “fix” a race with a frontend confirmation dialog.
- Log
user,src,version,rowcount,remainafter commit. Negative remain /rowcount=0are the alerts. - Ledger tables are append-only in real systems; the lab UPDATE is a teaching simplification. The invariant (one debit, owner-checked, serialized) still holds on an insert-only journal.
1ALTER TABLE accounts ADD CONSTRAINT bal_nonneg CHECK (balance >= 0);
2-- naive double-debit then raises:
3-- CHECK constraint failed: bal_nonneg
The CHECK would have turned the naive race into a 500. Still a bug; better than silent negative money. Versioned UPDATE is the actual fix.
What I file after this lab
- Seed: alice 100 / bob 100 / version 0
xfer_naive× 2 threads × 80: alice -60, two 200s, TSan data race on the C twinxfer_ver× 2: one 200, one 409conflict, alice 20, version 1xfer_idas mallory: 200 without owner check, 403 with it — not a race- Fix:
WHERE id=? AND version=?+owner=userin the same transaction; CHECK on balance - Out of scope: a client that drains a real ledger
Commands appendix
1sqlite3 /tmp/ledger.db < schema.sql
2python3 app.py
3python3 race_client.py naive
4python3 race_client.py ver
5clang -fsanitize=thread -g -o bal bal.c -lpthread