422.43 Race conditions
Prevent security vulnerabilities caused by multiple processes accessing shared resources in unpredictable or unsafe ways.
422.43 Race Conditions
Prevent security vulnerabilities caused by multiple processes accessing shared resources in unpredictable or unsafe ways.
Overview
A race condition occurs when the behaviour of software depends on the timing or order of operations by multiple processes or threads. If two actions happen at the same time and access the same resource, unexpected results can occur, especially if the software does not correctly control access.
Race conditions are particularly dangerous in secure software because they can lead to unauthorised access, corrupted data, inconsistent system behaviour, and privilege escalation. They are especially common in web applications like Flask apps when multiple users access the same data simultaneously.
Learning Targets
In this topic, students learn to:
Explain how race conditions arise in software systems
Identify where shared state or concurrent access may cause vulnerabilities
Apply strategies to prevent race conditions during design and implementation
Understand the consequences of insecure timing in concurrent systems
What is a Race Condition?
A race condition happens when:
Two or more operations access shared data or resources
The operations are not properly ordered or synchronised
The outcome depends on the sequence or timing of execution
Simple Example
# Pseudocode showing a race condition
balance = 100
# User A and User B both try to withdraw $60 at the same time:
# Process 1 (User A): # Process 2 (User B):
check_balance() # sees 100 check_balance() # sees 100
if balance >= 60: if balance >= 60:
balance = balance - 60 balance = balance - 60
# Result: balance = 40 (should be -20, one withdrawal should have failed)
Race Conditions in Flask Applications
Vulnerable Example: Account Balance
from flask import Flask, request, session
import sqlite3
import time
app = Flask(__name__)
@app.route('/withdraw', methods=['POST'])
def withdraw_money():
if 'user_id' not in session:
return "Not logged in"
user_id = session['user_id']
amount = float(request.form['amount'])
# VULNERABLE: Race condition in balance check
conn = sqlite3.connect('bank.db')
cursor = conn.cursor()
# Step 1: Check current balance
cursor.execute("SELECT balance FROM accounts WHERE user_id = ?", (user_id,))
current_balance = cursor.fetchone()[0]
# Step 2: Check if sufficient funds (DANGEROUS GAP HERE)
if current_balance >= amount:
time.sleep(0.1) # Simulates processing delay
# Step 3: Update balance
new_balance = current_balance - amount
cursor.execute("UPDATE accounts SET balance = ? WHERE user_id = ?",
(new_balance, user_id))
conn.commit()
conn.close()
return f"Withdrawal successful. New balance: ${new_balance}"
conn.close()
return "Insufficient funds"
# Problem: If two withdrawal requests happen simultaneously:
# 1. Both check balance (e.g., $100)
# 2. Both see sufficient funds
# 3. Both proceed to withdraw (e.g., $60 each)
# 4. Result: $100 - $60 - $60 = -$20 (should have rejected one)
Secure Solution: Database Transactions
@app.route('/withdraw_secure', methods=['POST'])
def withdraw_money_secure():
if 'user_id' not in session:
return "Not logged in"
user_id = session['user_id']
amount = float(request.form['amount'])
# SECURE: Use database transaction to prevent race condition
conn = sqlite3.connect('bank.db')
cursor = conn.cursor()
try:
# Begin transaction (locks the affected rows)
cursor.execute("BEGIN IMMEDIATE")
# Check and update in single atomic operation
cursor.execute(
"UPDATE accounts SET balance = balance - ? WHERE user_id = ? AND balance >= ?",
(amount, user_id, amount)
)
# Check if update actually happened
if cursor.rowcount == 0:
# No rows affected = insufficient funds
conn.rollback()
return "Insufficient funds"
# Get new balance
cursor.execute("SELECT balance FROM accounts WHERE user_id = ?", (user_id,))
new_balance = cursor.fetchone()[0]
conn.commit()
return f"Withdrawal successful. New balance: ${new_balance}"
except Exception as e:
conn.rollback()
return "Transaction failed"
finally:
conn.close()
# This prevents race conditions because:
# 1. Database locks the row during the transaction
# 2. Check and update happen atomically (all-or-nothing)
# 3. Second request waits until first transaction completes
File-Based Race Conditions
Vulnerable File Operations
import os
@app.route('/upload', methods=['POST'])
def upload_file():
filename = request.form['filename']
content = request.form['content']
# VULNERABLE: Race condition between check and create
if not os.path.exists(filename):
time.sleep(0.1) # Simulates processing delay
# Another process could create the file here!
with open(filename, 'w') as f:
f.write(content)
return "File created"
return "File already exists"
# Problem: Two users could both check for same filename,
# both see it doesn't exist, then both try to create it
Secure File Operations
@app.route('/upload_secure', methods=['POST'])
def upload_file_secure():
filename = request.form['filename']
content = request.form['content']
# SECURE: Atomic file creation
try:
# Open with 'x' mode - fails if file exists
with open(filename, 'x') as f:
f.write(content)
return "File created"
except FileExistsError:
return "File already exists"
# This is atomic - the check and create happen together
# No gap where race condition can occur
Session-Based Race Conditions
Vulnerable Session Counter
# Global counter (shared resource)
page_views = 0
@app.route('/count')
def count_views():
global page_views
# VULNERABLE: Race condition on shared variable
current_views = page_views # Read current value
time.sleep(0.001) # Tiny delay (simulates processing)
page_views = current_views + 1 # Write new value
return f"Page views: {page_views}"
# Problem: Multiple simultaneous requests can:
# 1. Read same value (e.g., 100)
# 2. Both increment it (100 + 1 = 101)
# 3. Both write 101 (should be 102)
Secure Session Counter
import threading
# Thread lock for synchronisation
view_lock = threading.Lock()
page_views = 0
@app.route('/count_secure')
def count_views_secure():
global page_views
# SECURE: Use lock to prevent race condition
with view_lock:
page_views += 1 # Atomic increment
current_views = page_views
return f"Page views: {current_views}"
# The lock ensures only one thread can modify page_views at a time
# Other requests wait until the lock is released
Code Interpretation Examples
# Example 1: Identify the race condition
votes = 0
@app.route('/vote')
def cast_vote():
global votes
current_votes = votes # Read
current_votes += 1 # Modify
votes = current_votes # Write
return f"Total votes: {votes}"
# Problem: Multiple voters at same time could read same value,
# increment it, and both write the same result
# Example 2: Database race condition
@app.route('/like_post/<int:post_id>')
def like_post(post_id):
# Get current like count
cursor.execute("SELECT likes FROM posts WHERE id = ?", (post_id,))
current_likes = cursor.fetchone()[0]
# Increment and update
new_likes = current_likes + 1
cursor.execute("UPDATE posts SET likes = ? WHERE id = ?",
(new_likes, post_id))
return f"Post has {new_likes} likes"
# Problem: Two users liking simultaneously could result in lost likes
# Example 3: Secure version
@app.route('/like_post_secure/<int:post_id>')
def like_post_secure(post_id):
# Atomic increment - no race condition possible
cursor.execute("UPDATE posts SET likes = likes + 1 WHERE id = ?", (post_id,))
# Get updated count
cursor.execute("SELECT likes FROM posts WHERE id = ?", (post_id,))
new_likes = cursor.fetchone()[0]
return f"Post has {new_likes} likes"
Prevention Strategies
1. Use Atomic Operations
# GOOD: Single atomic operation
cursor.execute("UPDATE accounts SET balance = balance - ? WHERE user_id = ?",
(amount, user_id))
# BAD: Separate read and write
balance = get_balance(user_id)
update_balance(user_id, balance - amount)
2. Use Database Transactions
# GOOD: Transaction ensures consistency
cursor.execute("BEGIN")
# Multiple related operations
cursor.execute("COMMIT")
# BAD: Multiple separate operations
operation1()
operation2() # Could fail, leaving inconsistent state
3. Use Thread Locks (When Necessary)
import threading
lock = threading.Lock()
def thread_safe_operation():
with lock:
# Only one thread can execute this at a time
shared_resource += 1
Real-World Examples in Student Projects
# Example: Blog post comment counter
@app.route('/add_comment/<int:post_id>', methods=['POST'])
def add_comment(post_id):
comment_text = request.form['comment']
# Add comment
cursor.execute("INSERT INTO comments (post_id, text) VALUES (?, ?)",
(post_id, comment_text))
# SECURE: Atomic increment of comment count
cursor.execute("UPDATE posts SET comment_count = comment_count + 1 WHERE id = ?",
(post_id,))
conn.commit()
return "Comment added"
# Example: User registration with unique usernames
@app.route('/register', methods=['POST'])
def register():
username = request.form['username']
try:
# SECURE: Database constraint prevents duplicate usernames
cursor.execute("INSERT INTO users (username) VALUES (?)", (username,))
conn.commit()
return "Registration successful"
except sqlite3.IntegrityError:
return "Username already taken"
Summary
Race conditions occur when multiple operations access shared resources without proper synchronisation
Common in web apps where multiple users interact with the same data simultaneously
Database transactions provide atomic operations that prevent most race conditions
File operations should use atomic methods when possible
Thread locks can protect shared variables in memory
Prevention is easier than detection - design systems to avoid race conditions from the start
Race conditions can be subtle and hard to reproduce, but understanding the basic patterns helps you write more reliable Flask applications that handle concurrent users safely.
Last updated
Was this helpful?