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.
asynciohas the same problem with clearer seams. A coroutine can only be interrupted atawait, which makes the windows easy to find - any check followed by anawaitbefore its use is one. Useasyncio.Lockfor in-process coordination;threading.Lockdoes 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 COMMITTEDand MySQL's InnoDB toREPEATABLE READ; neither prevents two transactions from reading the same value and both writing it back. The conditionalUPDATEabove works because the condition is evaluated at write time under the row lock - reading first and updating later needsFOR UPDATEorSERIALIZABLE.
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.Barrierso 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, ormultiprocessing). This is the run that distinguishes a real fix from athreading.Lock, and it is the one most likely to be skipped. - Assert the failure mode is the intended one:
InsufficientFundsErrorand 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 assertOSErrorrather 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.