manifesto · vol. iv · concurrency without consent

RACE
CONDITIONS.— two threads, one resource, no agreement on order.

A program assumes operations happen in the order it wrote them. The CPU disagrees. So does the kernel scheduler, the network stack, the filesystem, and any attacker patient enough to run a tight loop. Race conditions exist in the gap between when you check a thing and when you use it. The attacker's whole job is to make that gap exploitable.

CWE
362 / 367
primitive
interleave
vector
timing
consequence
corruption · escalation
Athread α
READ
+1
WRITE
READ
+1
WRITE
Bthread β
READ
+1
WRITE
READ
+1
WRITE
SHARED RESOURCE
counter = 3
CHECK · USE · CHECK · USE · GAP · ATTACKER · WINS · ◆ · CHECK · USE · CHECK · USE CHECK · USE · CHECK · USE · GAP · ATTACKER · WINS · ◆ · CHECK · USE · CHECK · USE CHECK · USE · CHECK · USE · GAP · ATTACKER · WINS · ◆ · CHECK · USE · CHECK · USE
Icap. prima
// the four ingredients of every race

how a race actually works.

A race condition isn't a special bug. It's an unavoidable consequence of four ordinary properties stacking up: shared mutable state, concurrent access, a non-atomic operation, and missing synchronisation. Remove any one of these four and the bug is impossible. Forget any one of them and the bug is inevitable.

01

Shared mutable state

A piece of data that more than one thread of execution can read or write — an integer in memory, a row in a database, a file on disk, a counter on a remote API.

int balance = 100; /* shared */
02

Concurrent access

Two or more flows reach that state at overlapping times. Threads, processes, async callbacks, network requests. The CPU and scheduler decide the actual order — not your source code.

thread_create(worker_a); thread_create(worker_b);
03

Non-atomic operation

What looks like one statement is several machine steps. counter++ is read, add, write — three points where another thread can interrupt you and read or change the same value.

counter++; /* mov, inc, mov */
04

No synchronization

Nothing forces one of those flows to finish before the other touches the state. No mutex, no atomic instruction, no transaction, no row lock, no file descriptor — just hope.

// no lock, no atomic, no fence
IIcap. secunda
// six steps from harmless to root

a compendium of races against trust.

Plates i and ii establish the mechanics: serial code is safe, concurrent code without locks is not. Plates iii through v are the three classic exploitation patterns — counter races, file TOCTOU, and bank-style state corruption. Plate vi is the symlink trick that turns any "write to /tmp" into a privileged-file overwrite primitive.

i.

// plate i

The serial baseline.

A single thread of execution. Operations happen in the order written. counter++ means the entire read-add-write sequence completes before the next statement. This is the model every C tutorial assumes — and the model that fails the moment you add a second thread.

Nothing in the source code below changes when we go concurrent. What changes is everything around it.

int counter = 0; for (int i = 0; i < 1000; i++) counter++; // counter is an int. // counter++ compiles to: read counter → add 1 → write counter // in serial code these three steps are uninterruptible from the program's view. RESULT: counter = 1000 ✓ as expected
ii.

// plate ii

Two threads, one counter — the lost update.

Spawn two threads, each running the same loop a thousand times. Expected total: 2000. Actual total: somewhere between 1000 and 2000, varying every run. Why? Because counter++ is three machine instructions, and the OS can preempt you between any of them.

If both threads execute READ at the same moment, both compute the same +1, both write it back. Two increments collapse into one. The bug is silent — no exception, no crash, just a wrong number.

THREAD A
read counter (=5) ↓ preempted add 1 → t = 6 write counter ← 6
THREAD B
(waiting) read counter (=5) add 1 → t = 6 write counter ← 6
RESULT: counter = 6 (expected 7) — one increment LOST
iii.

// plate iii

TOCTOU — check then use.

A privileged process verifies a property of the world (a file's owner, a permission bit, a balance), then acts on that property. Between verify and act, an attacker changes the world. The privileged process trusted the verification and acted on a stale fact.

The classic Unix example: access("/tmp/x", W_OK) followed by open("/tmp/x", O_WRONLY). Between them, the attacker swaps /tmp/x for a symlink to /etc/passwd. access() said yes. open() opens passwd. Privileged code writes attacker-chosen content.

PRIVILEGED PROC (root)
access("/tmp/x", W_OK) → ok ↓ scheduler gap open("/tmp/x", O_WRONLY) write(fd, "trusted log line")
ATTACKER (unprivileged)
(waiting) unlink("/tmp/x") symlink("/etc/passwd", "/tmp/x") (returns) (returns)
RESULT: /etc/passwd OVERWRITTEN by privileged process — root escalation
iv.

// plate iv

Bank double-spend — concurrent withdrawals.

Account balance is $100. Two simultaneous requests arrive, each asking to withdraw $80. The naive backend: read balancecheck balance ≥ 80balance -= 80write balance. Both requests can pass the check (balance was $100 when they read it). Both subtract $80. Both write back $20.

$160 leaves the account. Final balance: $20 instead of -$60. The check told the truth. The check was just stale by the time the write happened. Real cases: cryptocurrency exchanges, in-app purchase double-redemption, gift-card refund duplication.

REQUEST 1 (-$80)
SELECT balance → 100 if 100 ≥ 80 ✓ ↓ overlap UPDATE balance = 20 return SUCCESS
REQUEST 2 (-$80)
(overlap) SELECT balance → 100 if 100 ≥ 80 ✓ UPDATE balance = 20 return SUCCESS
RESULT: balance = $20 (expected -$60) · $160 withdrawn from $100 account
v.

// plate v

Symlink race — privileged temp files.

A setuid program creates a working file at a predictable path: /tmp/myprog.tmp. Between deciding the path and opening it, the attacker — running in a tight loop — deletes the file and replaces it with a symlink pointing at /etc/shadow. The privileged open(O_CREAT|O_WRONLY) follows the symlink and writes attacker-chosen content into shadow.

Mitigation: never use predictable paths (mkstemp is the safe API), never re-open by path after a check (use file descriptors), and pass O_NOFOLLOW when you must open by path.

SETUID BINARY (root)
stat("/tmp/myprog.tmp") → ENOENT ↓ window fd = open(..., O_CREAT, 0644) write(fd, log_data) close(fd)
ATTACKER LOOP
while true: unlink("/tmp/myprog.tmp") symlink("/etc/shadow", "/tmp/myprog.tmp") unlink("/tmp/myprog.tmp") ...
RESULT: /etc/shadow corrupted by privileged write — root password compromised
vi.

// plate vi

Web HTTP race — same idea, network speed.

Web applications have the same shape. Click "redeem coupon" 50 times in parallel; if the backend's "has user redeemed?" check isn't atomic with the redemption write, the user redeems 50 coupons. Submit two simultaneous "transfer to friend" requests; both pass the balance check; both succeed.

Modern frameworks have async checked-then-mutated patterns everywhere. The fix is the same as for threads: the check and the use must be one atomic step — usually a single SQL UPDATE … WHERE balance ≥ amount with a row lock, or a database transaction with serializable isolation.

POST /coupon/redeem ×50 (fired in parallel) // backend pseudo-code: if (db.get("user.has_redeemed") == false): ← all 50 requests pass here db.set("user.has_redeemed", true) issue_credit(user, 100) ← all 50 issue credit RESULT: 5,000 credit issued for one coupon // fix: db.update("UPDATE u SET redeemed=true WHERE redeemed=false") → returns rows affected exactly one row will return 1; the rest return 0; only that one issues credit
IIIcap. tertia
// touch the gap

field study — watch the threads collide.

A real interleaving simulator. Two threads, with their operations laid out as cards along parallel rails. The scheduler picks one thread at a time to advance; the shared resource updates as ops fire. Toggle synchronisation and watch the same workload produce correct results — or not.

step /
[ field-study · ready ] [ choose a scenario, set parameters, press the action button ]  
IVcap. quarta
// six controls in depth

how the gap closes.

No single defense covers every variant. Locks serialise threads. Atomics make a single read-modify-write uninterruptible. Compare-and-swap lets you retry under conflict. File descriptors close the path-rebind window. Database transactions push the problem onto a system that's good at it. The cheapest defense — design — is to avoid sharing in the first place.

🔒

Mutex / lock

Serialise the critical section. Only one thread holds the lock at a time; everyone else waits. Coarse but correct. The cost is contention: a busy lock becomes a bottleneck. Use the smallest possible critical section.

lock(); counter++; unlock();

Atomic operations

Single-instruction primitives that read-modify-write without preemption. __atomic_fetch_add in C, AtomicInteger.incrementAndGet in Java. Free of contention up to the point where the cache line itself becomes hot.

__atomic_fetch_add(&counter, 1, __ATOMIC_SEQ_CST);

Compare-and-swap

Lock-free pattern: read old value, compute new, attempt to swap, retry on conflict. Foundation of optimistic concurrency, used inside databases, schedulers, and language runtimes.

do { old = x; } while (!CAS(&x, old, old+1));

File descriptors

Once a file descriptor is open, the kernel binds you to that inode — symlinks and unlinks don't matter. Replace path-based check-then-use (access+open) with open+fstat+fchown+fchmod.

fd = open(p, O_NOFOLLOW); fstat(fd, &st);

DB transactions

Make the entire check-and-mutate one statement, or wrap it in a transaction with row-level locks (SELECT ... FOR UPDATE) or serializable isolation. The database becomes responsible for ordering.

UPDATE acc SET bal=bal-80 WHERE id=1 AND bal>=80;

design away the share

The cheapest race is the one you don't have. Immutable data structures, single-writer/multi-reader patterns, message-passing actors, share-nothing parallelism. If the state isn't shared, it can't be raced.

// no shared state ↔ no race possible