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?

SymptomParallelism needed?SQL / code shapeFix
Balance negative, two 200s, same srcyesUPDATE without version / rowcountCAS / FOR UPDATE
Balance moves, caller is not ownernomissing owner == userbind object to subject
Admin route with a user tokennomissing role checkfunction-level authz
Double coupon, two 200syesused=1 without WHERE used=0see 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: version column or UPDATE ... WHERE balance >= :n with rowcount==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, remain after commit. Negative remain / rowcount=0 are 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 twin
  • xfer_ver × 2: one 200, one 409 conflict, alice 20, version 1
  • xfer_id as mallory: 200 without owner check, 403 with it — not a race
  • Fix: WHERE id=? AND version=? + owner=user in 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