Skip to content

CWE-367: Time-of-check Time-of-use Race Condition - Python

Overview

Python's global interpreter lock protects the interpreter's own data structures - it does not make your read-modify-write sequence atomic. A thread switch can occur between any two bytecodes, so if self.balance >= amount: followed by self.balance -= amount is two operations with a gap, exactly as it would be in C.

The more consequential detail is deployment shape. Most Python web applications run several worker processes (Gunicorn, uWSGI, Celery), so a threading.Lock protects one worker and nothing else. A fix that works in a single-process test and fails in production is the characteristic failure for this CWE in Python.

Common Vulnerable Patterns

Check-then-act on shared mutable state

# VULNERABLE - two threads can both pass the check before either deducts
class BankAccount:
    def __init__(self, account_id, initial_balance):
        self.account_id = account_id
        self.balance = initial_balance

    def withdraw(self, amount):
        if self.balance < amount:
            raise InsufficientFundsError(f"Balance {self.balance} < {amount}")

        # Any I/O, any allocation, or simply the interpreter's switch interval
        # can hand control to another thread here
        self.balance -= amount
        return self.balance

# Balance 100, two concurrent withdrawals of 100:
# both read 100, both pass the check, both subtract - final balance -100

Why this is vulnerable: self.balance -= amount is a load, a subtract and a store. The check reads a value that any other thread may invalidate before the store lands. sys.setswitchinterval() governs how often a switch is offered, not whether one can happen at an inconvenient point - shortening the code between check and use narrows the window without closing it.

Existence check before file creation

import os

# VULNERABLE - the path is resolved twice
def save_upload(path, data):
    if os.path.exists(path):
        raise FileExistsError(path)

    # Another process creates the path, or points it at a symlink, here
    with open(path, 'wb') as fh:
        fh.write(data)

Why this is vulnerable: os.path.exists() answers about the path at that instant, and open() resolves it again. open() follows symbolic links, so in a shared directory this writes wherever the attacker aimed the name.

Secure Patterns

Make the database enforce the invariant

from sqlalchemy import text

# SECURE - the check is part of the write, evaluated under a row lock
def withdraw(session, account_id, amount):
    updated = session.execute(
        text("""
            UPDATE accounts
               SET balance = balance - :amount
             WHERE id = :account_id
               AND balance >= :amount
        """),
        {"amount": amount, "account_id": account_id},
    ).rowcount

    if updated == 0:
        raise InsufficientFundsError("Insufficient balance")

    session.commit()

Why this works: The condition and the update are one statement, so the database evaluates balance >= :amount while holding the row lock it needs for the write. There is no interval for a second withdrawal to slip into, and the guarantee holds across threads, worker processes and application instances - which a Python-level lock cannot do. rowcount reports the outcome at write time rather than at read time, but it does not say which part of the predicate failed: zero means no row matched id = :account_id AND balance >= :amount, so the balance was too low, the account does not exist, or it belongs to someone else. Where those need different handling, re-read the row after the failed update - nothing was written, so there is nothing to roll back.

Where the new value cannot be expressed as an arithmetic update, use SELECT ... FOR UPDATE inside the transaction and keep the read and write between the same BEGIN and COMMIT.

Lock the critical section when the state really is in-process

import threading

class RateLimiter:
    def __init__(self, limit):
        self._lock = threading.Lock()
        self._limit = limit
        self._count = 0

    # SECURE for a single process - check and update inside one lock
    def try_acquire(self):
        with self._lock:
            if self._count >= self._limit:
                return False
            self._count += 1
            return True

Why this works: The lock makes the check and the increment indivisible with respect to other threads in this interpreter, so no two callers can both observe _count below the limit and both increment past it. with self._lock also releases on exception, which a manual acquire()/release() pair drops as soon as anything in the body raises.

This is correct only while the state it guards is genuinely per-process - an in-memory cache, a connection pool, a counter that does not need to be globally accurate. For anything shared across workers, the lock is decoration and the guarantee has to come from the database, or from Redis if you need it outside a transaction.

Let the operating system do check-and-create

import os
import tempfile

# SECURE - existence check and creation in one syscall
def save_upload(path, data):
    flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0)
    fd = os.open(path, flags, 0o600)
    with os.fdopen(fd, 'wb') as fh:
        fh.write(data)


# SECURE - unpredictable name, created exclusively with 0600
def save_temp(data, directory='/var/app/spool'):
    fd, path = tempfile.mkstemp(dir=directory)
    with os.fdopen(fd, 'wb') as fh:
        fh.write(data)
    return path

Why this works: O_CREAT | O_EXCL moves the existence check into the kernel, where it happens under the same lock as the creation - a pre-existing file raises FileExistsError rather than being overwritten. O_NOFOLLOW refuses a symlinked final component (it is absent on Windows, hence the getattr). tempfile.mkstemp combines an unpredictable name with the same exclusive creation and 0600 permissions; tempfile.mktemp returns a name only and is deprecated precisely because using it is this weakness.

Considerations

  • Which process boundary the state actually crosses. This is the decision the fix turns on. In-process state takes a threading.Lock; state shared by workers takes a database constraint or transaction; state shared by services takes a distributed lock or an idempotency key. Choosing the first for the second case is the common wrong answer, and it tests green under a single worker.
  • Whether an attacker can drive the interleaving. A race that needs two requests within microseconds and cannot be retried is a different severity from an endpoint an attacker can hammer in parallel. Retryability is usually what makes it exploitable rather than theoretical.
  • asyncio has the same problem with clearer seams. A coroutine can only be interrupted at await, which makes the windows easy to find - any check followed by an await before its use is one. Use asyncio.Lock for in-process coordination; threading.Lock does not protect a coroutine and will block the event loop if it contends.
  • Database-level fixes need the right isolation, and the default is not the same everywhere. PostgreSQL defaults to READ COMMITTED and MySQL's InnoDB to REPEATABLE READ; neither prevents two transactions from reading the same value and both writing it back. The conditional UPDATE above works because the condition is evaluated at write time under the row lock - reading first and updating later needs FOR UPDATE or SERIALIZABLE.

Testing

The scanner sees a lock or a conditional update and stops there. Whether the interleaving is closed is only observable by forcing it.

  • Run the operation from many threads at once against a balance or quota that admits exactly one success. Assert exactly one succeeds and the invariant never breaks. Release the threads from a threading.Barrier so they start together; sequential calls in a loop will pass against broken code.
  • Run the same test with more than one worker process (gunicorn -w 4, or multiprocessing). This is the run that distinguishes a real fix from a threading.Lock, and it is the one most likely to be skipped.
  • Assert the failure mode is the intended one: InsufficientFundsError and an unchanged balance, not a negative balance or a partially applied update.
  • For the file paths, pre-create the target and assert FileExistsError; point the path at a symlink and assert OSError rather than a write to the target.
  • Assert legitimate concurrent use still works - two withdrawals that both fit within the balance should both succeed. A fix that serialises everything into a single lock can pass the safety tests and destroy throughput.

Additional Resources