«
◀
OP-26 // RACE DAY
CAMPAIGN 3 // RACE CONDITIONS // TOCTOU
0
XP
T1
RANK
0%
DETECT
↻
RESET
CAMPAIGN 3 — THE GHOST PROTOCOL
RACE CONDITIONS // TOCTOU // OWASP A08
OP-26
SOFTWARE INTEGRITY // RACE CONDITION // TOCTOU
RACE DAY
CODE ANNOTATOR · CONCURRENT SIMULATOR · BID LOG READER · ATOMIC BUILDER
OPERATION RACE DAY — CAMPAIGN 3
⏳
SITUATION
33H 45M REMAINING
This is the Syndicate’s payday — Kane is about to auction twenty operations’ worth of stolen access to the highest bidder, the buyer list a who’s-who of every agency and cartel that ever paid Vantage. Rousseau pulled the auction URL out of corporate filings; MENDAX has the platform’s technical spec through her source inside Vantage. The whole thing runs off a hardened front in a Berlin data-haven.
The bidding engine uses a check-then-act pattern. Before processing a bid, the platform checks whether the escrow has sufficient funds. Then it processes the bid. These two steps are not atomic — there is a ~200ms gap between them. In a concurrent system, that gap is exploitable.
Shadow does not need to win. Shadow needs to stall the auction long enough to extract the buyer list from failed-bid logs. Every failed bid resets the submission timer. The buyers get frustrated. Holt gets nervous. The logs accumulate.
The bidding engine uses a check-then-act pattern. Before processing a bid, the platform checks whether the escrow has sufficient funds. Then it processes the bid. These two steps are not atomic — there is a ~200ms gap between them. In a concurrent system, that gap is exploitable.
Shadow does not need to win. Shadow needs to stall the auction long enough to extract the buyer list from failed-bid logs. Every failed bid resets the submission timer. The buyers get frustrated. Holt gets nervous. The logs accumulate.
WHAT IS A RACE CONDITION?
FEYNMAN PRIMER
Imagine two people trying to buy the last item in a shop at the same time. The system checks availability before either purchase is fully completed. Both are allowed to proceed. So more items are sold than actually exist.
⏳ The auction platform assumes no two bids land in its 200ms processing window. Shadow is going to prove that assumption wrong — repeatedly.
INTERCEPTED CALL // HOLT TO LEGAL COUNSEL // 09:47
“The auction platform was built to our specification by a contracted development team. All standard testing was completed. The bidding mechanism was validated against normal operational parameters. We did not anticipate adversarial concurrent load.”
◈ MENDAX: “Normal operational parameters. He tested it as if no one would fight it.”
MENDAX — DARK CHANNEL // OP-26 BRIEF
08:34
MENDAX
“Thirty-three hours to the gavel. The bidding engine has a race condition — we don’t need to win Kane’s auction, we need to jam it open and bleed the buyer list out of the failed-bid logs. Stall it. Op 26.”
Op 25 — Subdomain takeover that planted a ghost redirect on an abandoned Vantage asset, intercepting internal traffic.
TARGET
nexus-bank.nexus-global.int
IP ADDRESS
10.47.1.26
OS / SERVER
Ubuntu 22.04 · Node.js 18.x · No atomic locking
KEY SERVICES
/api/transfer · Balance check not atomic · In-memory state
ATTACK SCOPE
Concurrent TOCTOU race — double-spend on transfer approval
ATTACKER NODE
shadow@sigma9 · 10.99.0.1
📋 ROOM TASKS
01
✓
Understand why non-atomic check-then-act creates an exploitable race window
02
▶
Fire concurrent transfer requests to exploit the double-spend
03
○
Classify which operations are race-condition vulnerable
04
○
Identify the fix (atomic DB transactions + mutex locking)
05
○
Submit the capture-the-flag token
CAPTURE THE FLAG
Complete the exploit lab. The flag appears below once Phase 2 is done. Copy and submit it here for +50 XP.
PHASE 01
SPOT THE BUG
ANNOTATE
SIGMA-9 CAPTURE LOG — HOW WE FOUND IT
02:47 UTC
> mapped vantage-ops.net wallet API — /withdraw checks balance, then debits
> timed the gap between the read and the write: ~80ms window with no lock
> FOUND: two requests fired inside that window both see the same balance — both succeed
MENDAX: "One cookie on the table, two hands reaching at once. The kitchen only counted once."
MENDAX — HOW THIS IS ACTUALLY DONE
LIVE
MENDAX
The trick is timing. If you send withdrawals one after another, the balance updates between each and you're stopped. You have to land them in the same instant — before the first debit is written, so both still see the full balance.
$ seq 30 | xargs -P 30 -I{} curl -s -X POST \
https://vantage-ops.net/api/withdraw -d 'amount=500'
# -P 30: fire all 30 requests in parallel, no waiting
MENDAX
Thirty requests hit the check-then-debit gap simultaneously. The account only had 500, but several read "balance: 500" before any write landed — each one approves.
$ curl -s https://vantage-ops.net/api/balance
{"withdrawn": 4000, "balance": -3500} # double-spend confirmed
MENDAX
We pulled 4000 out of a 500 account — the ledger went negative. Your call, Shadow — how wide do we open this?
💡 Every line of the vulnerable code is correct on its own. The bug is in the gap between two correct lines — a gap that only matters when two requests arrive at the same time.
MENDAX
ENCRYPTED
Two implementations. Same function. One creates a race window. One eliminates it.
Tap the line in the VULNERABLE version where the race condition becomes possible. Then tap the line in the FIXED version that closes it.
Tap the line in the VULNERABLE version where the race condition becomes possible. Then tap the line in the FIXED version that closes it.
▶ VULNERABLE — TAP THE DANGEROUS LINE
👉 Tap the line where the race window opens
✓
BUG IDENTIFIED AND FIX UNDERSTOOD
The race window is not a mistake — it is the logical consequence of two separate operations that look correct individually. SELECT FOR UPDATE holds a pessimistic row lock for the entire check-and-process cycle. No concurrent request can read stale escrow state while that lock is held.
Why testing missed it: Sequential tests have no concurrent access. The gap exists but is never triggered. The bug only appears under adversarial concurrent load — which Holt’s team did not test against.
🔧 UNDER THE HOOD▼
THE ATTACK — STEP BY STEP
Race condition — two concurrent requests both pass a check and both succeed, where only one should. Classic coupon double-spend:
# Attack: send two simultaneous requests before the server marks the coupon used
# Using curl parallel:
curl -s -X POST https://victim.com/apply-coupon \
-d "coupon=SAVE50&user=attacker" &
curl -s -X POST https://victim.com/apply-coupon \
-d "coupon=SAVE50&user=attacker" &
wait
# Both requests hit the check simultaneously:
# Thread 1: SELECT used FROM coupons WHERE code='SAVE50' → used=false ✓
# Thread 2: SELECT used FROM coupons WHERE code='SAVE50' → used=false ✓
# Thread 1: UPDATE coupons SET used=true WHERE code='SAVE50'
# Thread 2: UPDATE coupons SET used=true WHERE code='SAVE50'
# Both applied discount — coupon used twice
TOCTOU (Time Of Check To Time Of Use) — the state changes between the check and the action. File permissions, account balances, inventory counts all susceptible.COMMON VARIATIONS
1. Limit bypass (overdraft) — withdraw more money than your balance by sending concurrent withdrawal requests:
3. Rate limit bypass — a rate limiter checks a counter in Redis. Concurrent requests all read the counter simultaneously (before any increment), all pass the check, all execute.
# 10 simultaneous requests to withdraw $100 from a $100 account:
for i in {1..10}; do
curl -X POST https://bank.com/withdraw -d "amount=100" -H "Cookie: session=TOKEN" &
done
2. Inventory race — buy the last item in stock multiple times. The stock check and the decrement are not atomic.3. Rate limit bypass — a rate limiter checks a counter in Redis. Concurrent requests all read the counter simultaneously (before any increment), all pass the check, all execute.
HOW TO DEFEND
Use database-level atomic operations — make the check and the update a single uninterruptible operation:
-- SQL: atomic compare-and-update (fails if already used):
UPDATE coupons
SET used = true, used_by = 'user123'
WHERE code = 'SAVE50' AND used = false;
-- Rows affected = 1 means success; 0 means already used (race lost)
-- PostgreSQL: SELECT FOR UPDATE locks the row:
BEGIN;
SELECT * FROM coupons WHERE code = 'SAVE50' FOR UPDATE;
-- Other transactions block here until this one commits
UPDATE coupons SET used = true WHERE code = 'SAVE50';
COMMIT;
// Node.js with idempotency key — each operation gets a unique ID,
// duplicate requests with the same key are rejected:
const result = await db.query(
'INSERT INTO operations (idempotency_key, user_id, action) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING *',
[idempotencyKey, userId, 'apply_coupon']
);
if (result.rowCount === 0) return res.status(409).json({ error: 'Already processed' });
PHASE 02
CONCURRENT SIMULATOR
SIMULATE
Vulnerable line identified. Now fire it — trigger the race window repeatedly until the auction engine starts choking on stale data.
💡 Tap SUBMIT BID rapidly. The timeline shows each request’s CHECK phase (gold) and PROCESS phase (green). When two overlap in the 200ms window, the race fires (red). Trigger 8 stall events to complete the phase.
MENDAX
ENCRYPTED
The bid engine accepts requests concurrently but validates escrow in two separate queries. The validation and the debit are not atomic. Two requests that both pass validation before either debits the escrow both proceed with stale balance data. One will fail processing.
That failure is a stall. Eight stalls and the auction timer resets.
That failure is a stall. Eight stalls and the auction timer resets.
▶ AUCTION // BID ENGINE WATCHDOG
03:00
0
BIDS SENT
0/8
STALLS
0
RACES
REQUEST TIMELINE — each row = one concurrent request
CHECK (200ms)
PROCESS (150ms)
RACE COLLISION
Auction running. No bids in flight. Tap SUBMIT BID to begin.
[system] Bid engine online. Escrow: $2,400,000. Race window: 200ms.
✓
AUCTION STALLED — 8 RACE CONDITIONS TRIGGERED
The auction timer has reset. Buyers receive “bid processing failed — please retry.” Every retry is logged with full buyer account metadata. The buyer list is forming.
Key insight: The platform logs failed bids with complete request metadata because the failure happens at the processing step — after identification has already succeeded. The race condition reaches the right code path to expose the data Shadow needs.
PHASE 03
BID LOG READER
EXTRACT
Eight stalls triggered — the auction is in chaos. The failure logs didn't wipe. Each crashed bid left a fingerprint. Time to read who's in the room.
💡 Failed bids are not wiped. Each log entry contains buyer account metadata. Expand each entry, read the org_type and tier fields, and classify the buyer. Eight entries. Eight buyers.
MENDAX
ENCRYPTED
The race-induced failures produced a partial buyer log. Tap each entry to expand it. Read the metadata fields. Classify each buyer: state actor, government-affiliated, private entity, or unknown. This is the first time the buyers have identifiers.
Classified: 0 / 8
► FAILED BID LOG // auction.vantage-sys.net
✓
PARTIAL BUYER LIST EXTRACTED
Eight buyers identified. Two sovereign states. Two government agencies. Three private intelligence firms. One anonymous escrow with no traceable origin.
MENDAX sends the partial list through a secure channel. Shadow sends Rousseau enough to confirm the auction is real.
MENDAX sends the partial list through a secure channel. Shadow sends Rousseau enough to confirm the auction is real.
PHASE 04
ATOMIC TRANSACTION BUILDER
DEFEND
Buyers identified. Now build the fix that would have closed the race window — four SQL primitives, placed correctly, make the gap disappear entirely.
💡 Place four SQL primitives in the correct blank slots. The correct placement makes the check-and-process cycle atomic — the race window ceases to exist. One wrong placement and the window reopens.
MENDAX
ENCRYPTED
Four slots in the fixed bid function. Tap a primitive from the pool below, then tap the slot where it belongs. Get all four correct and the database row is locked for the entire check-and-process cycle — no concurrent request can slip into the gap.
► SQL PRIMITIVES — TAP TO SELECT, THEN TAP A SLOT
✓
ATOMIC TRANSACTION — RACE WINDOW CLOSED
BEGIN + SELECT FOR UPDATE + COMMIT + ROLLBACK. The row is locked from the moment the balance is read until the transaction completes. No concurrent request can read stale state. The 200ms gap no longer exists — because the lock makes the gap irrelevant.
Why SELECT FOR UPDATE specifically: A regular SELECT acquires no lock. Other transactions can modify the row after the read. FOR UPDATE holds a pessimistic lock until COMMIT. That lock is the atomic guarantee.
⏳
OPERATION 26 — COMPLETE
RACE DAY — SUCCESS
AUCTION STALLED // BUYER LIST PARTIAL
+0
XP EARNED THIS OPERATION
Eight race conditions. Auction timer reset. Buyers retrying for twenty-six minutes.
The failed bid logs contain eight buyer identifiers: two sovereign states, two government agencies, three private intelligence firms, and one anonymous escrow account with no traceable origin.
Holt has called his legal team. The administrative dashboard is still running. Shadow’s hijacked session from Op 24 still has partial access. MENDAX has found something in the console’s JavaScript framework.
The failed bid logs contain eight buyer identifiers: two sovereign states, two government agencies, three private intelligence firms, and one anonymous escrow account with no traceable origin.
Holt has called his legal team. The administrative dashboard is still running. Shadow’s hijacked session from Op 24 still has partial access. MENDAX has found something in the console’s JavaScript framework.
MENDAX + ROUSSEAU — DARK CHANNEL // POST-RACE DAY
09:06
MENDAX
“You bought time. Not much. Twenty-six hours. Op 27.”
ROUSSEAU
Something is wrong with the auction. My source says their bids keep failing. How long can you hold this?
■
Shadow does not answer.
MENDAX
Tell her as long as necessary.
09:06 — MENDAX BRIEF // OP 27
MENDAX
The operator console runs a JavaScript framework with a known prototype pollution vulnerability. Elevate the session. We need administrator access to extract the full client list. Op 27.
DEBRIEF — REFLECTION QUESTION
Two concurrent requests both succeeded where only one should have. What database-level primitive — a single word — would have serialised access and made the race impossible?
SIGMA-9 ACADEMY
OPERATION 26 // RACE CONDITIONS // TOCTOU // ATOMIC TRANSACTIONS
◈ WHAT IS A RACE CONDITION?
A race condition occurs when system behaviour depends on the timing or ordering of events that cannot be guaranteed. Two concurrent operations read shared state, make decisions, and one acts on stale information.
TOCTOU (Time of Check to Time of Use): The state is checked at one moment and acted upon at a later moment. Between the check and the use, another process can change the state — invalidating the check’s result. The bug is in the gap, not in any individual line.
◈ VULNERABLE vs ATOMIC PATTERN
Vulnerable: SELECT balance → check → [200ms gap] → UPDATE. Any concurrent transaction can modify balance during the gap. The check’s result is stale by the time the update runs.
Fixed: BEGIN → SELECT FOR UPDATE → check → UPDATE → COMMIT. The FOR UPDATE acquires a pessimistic row lock. No other transaction can read or modify the row until COMMIT releases it. Check and act become atomic.
◈ WHY TESTING MISSED IT
Sequential testing has no concurrent access. The 200ms gap exists in the code but is never triggered when only one request runs at a time. The test passes. The bug ships. Concurrency testing — deliberately sending simultaneous requests — is required to find timing-dependent vulnerabilities. Holt’s team validated “normal operational parameters” only.
❓
FIELD QUESTIONS — RACE DAY
Can a race condition exist in a single-threaded system?
Yes — in async systems. When a function awaits a database query, the event loop runs other requests. If two requests both reach
await db.query(SELECT balance) before either reaches await db.execute(UPDATE), the race condition exists despite the single thread. Node.js async/await is a common source of this pattern.What is the difference between optimistic and pessimistic locking?
Pessimistic locking (SELECT FOR UPDATE) assumes conflicts will happen and acquires a lock immediately. Other transactions queue. High contention creates bottlenecks.
Optimistic locking assumes conflicts are rare. It reads without a lock but stores a version number. On write, it checks the version still matches. If another process changed the row, the version increments — the write fails and retries. No lock held; failed writes require retry logic.
Optimistic locking assumes conflicts are rare. It reads without a lock but stores a version number. On write, it checks the version still matches. If another process changed the row, the version increments — the write fails and retries. No lock held; failed writes require retry logic.
REAL-WORLD TOOLS — RACE CONDITIONS
WHAT PROFESSIONALS USE
⏱
Burp Repeater / Turbo Intruder
FREE TIER
Fires many requests in a tight window to test for time-of-check/time-of-use flaws under authorised testing.
turbo-intruder: race 30 parallel requests
📝
curl + xargs
FREE / OPEN SOURCE
Quick parallel-request harness to demonstrate a check-then-act window in a controlled environment.
seq 30 | xargs -P30 -I{} curl -s URL
🔒
DB locks / idempotency
FREE / OPEN SOURCE
The defence: atomic transactions, row locks and idempotency keys close the window entirely.
SELECT ... FOR UPDATE; / unique txn key
LEAVE MISSION?
Progress is saved. You can resume from the mission select screen at any time.
RESET MISSION?
This wipes all progress for this operation only. XP in other operations is unaffected.
ARE YOU SURE?
Final confirmation -- all progress for this operation will be permanently erased.