CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition
Overview
TOCTOU race conditions occur when security checks are separated from resource use, creating a window where an attacker can change state between check and use (file replaced via symlink, permissions changed, balance modified), bypassing security controls.
Risk
High: TOCTOU enables privilege escalation (check permissions, then use after they're changed), symlink attacks (check file, attacker replaces with symlink to sensitive file), double-spending (check balance, deduct after second transaction), and authentication bypass.
Remediation Steps
Core principle: Avoid TOCTOU by combining check+use atomically (locking, file descriptors, transactions) or re-check at time of use.
Locate the TOCTOU race condition in your code
- Review the flaw details to identify the specific file, line number, and code pattern with separated check and use
- Identify the TOCTOU pattern: check permission/existence/balance, then use resource after the check
- Determine the race window: time between check and use where attacker can change state
- Trace the vulnerable operations: permission checks, file operations, balance checks, authentication checks
Make check-and-use atomic (Primary Defense)
- Use file descriptors instead of paths: Open file and use
fstat(), don't usestat()thenopen()with path - Hold locks from check through use: Acquire lock before check, hold it through use, then release
- Use atomic operations (compare-and-swap): Use CAS for check-and-update in single atomic operation
- Combine check and use in single operation: Use APIs that do both atomically (e.g.,
open()withO_CREAT|O_EXCL)
File system TOCTOU prevention
- Wrong pattern:
if (file.exists()) { fd = open(file); }- attacker can replace file between check and open - Right pattern:
fd = open(file, O_CREAT|O_EXCL);- atomic check and create - Use O_NOFOLLOW: Prevents following symlinks during open
- Use file descriptors: Once opened, use
fstat(fd)notstat(path)to avoid TOCTOU
Use exclusive locks
- Acquire lock before check: Lock before checking permission/balance/existence
- Hold lock through use: Don't release lock between check and use
- Use pessimistic locking for critical operations: Lock early, hold through entire transaction
- Database transactions for financial operations: Use
BEGIN TRANSACTION...COMMITwith row-level locks
Avoid path-based operations
- Open file, then use fstat() not stat(): After opening, use file descriptor for all checks
- Pass file descriptors not paths: Pass
fdbetween functions, not file paths - Use O_NOFOLLOW to prevent symlink following: Prevents symlink attack during open
- Verify after opening, not before: Check permissions/ownership after opening file, using file descriptor
Test the TOCTOU fix
- Verify check-and-use is atomic (single operation or locked)
- Test with concurrent modification: Try to change state between check and use (should be impossible)
- Test symlink attacks: Try to replace file with symlink between operations (should fail with O_NOFOLLOW)
- Test double-spending: Try to deduct balance twice concurrently (should fail with proper locking)
- Re-scan with security scanner to confirm the issue is resolved
Common Vulnerable Patterns
File Existence Check Before Open (Classic TOCTOU)
// VULNERABLE - check and use separated
int read_user_file(const char *filename) {
// TIME-OF-CHECK: Check if file exists and is readable
if (access(filename, R_OK) != 0) {
fprintf(stderr, "Cannot access file\n");
return -1;
}
// RACE WINDOW: Attacker can replace file with symlink here
sleep(0); // Simulates delay, but race exists even without sleep
// TIME-OF-USE: Open and read the file
int fd = open(filename, O_RDONLY);
if (fd < 0) {
perror("open");
return -1;
}
// Read file contents...
close(fd);
return 0;
}
// Attack scenario:
// 1. Attacker creates /tmp/userfile (passes access() check)
// 2. Between access() and open(), attacker replaces with: ln -sf /etc/shadow /tmp/userfile
// 3. open() follows symlink, application reads /etc/shadow with elevated privileges
Why is this vulnerable: The access() call checks permissions on the file at time-of-check, but the file path is used again in open() at time-of-use. Between these two operations, an attacker can replace the file with a symlink to a sensitive file (like /etc/shadow or /etc/passwd). Even though the application checked that the original file was readable, the open() call follows the symlink and opens the attacker-controlled target. This is especially dangerous in setuid programs or applications running with elevated privileges - the access() check uses the real UID, but open() uses the effective UID, so the application might open files it shouldn't have access to. The race window can be microseconds, but automated attack tools can win the race repeatedly by creating and replacing files in tight loops.
Permission Check Before Privileged Operation
// VULNERABLE - permission check separated from use
@RestController
public class AdminController {
@PostMapping("/admin/delete-user")
public ResponseEntity<?> deleteUser(@RequestParam String userId, HttpSession session) {
User currentUser = (User) session.getAttribute("user");
// TIME-OF-CHECK: Check if user is admin
if (!currentUser.isAdmin()) {
return ResponseEntity.status(403).body("Access denied");
}
// RACE WINDOW: Attacker can revoke admin privileges here
// or modify session in another concurrent request
// TIME-OF-USE: Execute privileged operation
userService.deleteUser(userId);
return ResponseEntity.ok("User deleted");
}
}
// Attack scenario:
// 1. Attacker logs in as admin, gets session
// 2. Attacker sends delete request, passes isAdmin() check
// 3. Before deleteUser() executes, attacker triggers account demotion in parallel request
// 4. deleteUser() executes with revoked privileges but check already passed
Why is this vulnerable: The admin check (isAdmin()) is performed on the user object at time-of-check, but the privileged operation (deleteUser()) executes later. In concurrent environments (web applications, multi-threaded services), another request can modify the user's privileges between the check and the operation. An attacker can exploit this by sending two simultaneous requests: one that triggers privilege revocation and another that performs the admin action. If timing is right, the admin action passes the check before revocation occurs but executes after, bypassing authorization. This violates the principle of least privilege - the operation should re-verify permissions at the moment of execution, not rely on stale checks. Session attributes, database-backed permissions, and cached authorization data are all vulnerable if not re-validated atomically with the operation.
Balance Check Before Deduction (Double-Spending)
# VULNERABLE - check and update separated
class BankAccount:
def __init__(self, account_id, initial_balance):
self.account_id = account_id
self.balance = initial_balance
def withdraw(self, amount):
# TIME-OF-CHECK: Check if sufficient balance
if self.balance < amount:
raise InsufficientFundsError(f"Balance {self.balance} < {amount}")
# RACE WINDOW: Another thread can withdraw here
# Simulating processing delay
time.sleep(0.01)
# TIME-OF-USE: Deduct balance
self.balance -= amount
return self.balance
# Attack scenario (with threading):
# Initial balance: $100
# Thread 1: withdraw($100) - passes check (balance=100 >= 100)
# Thread 2: withdraw($100) - passes check (balance=100 >= 100)
# Thread 1: executes balance -= 100 (balance = 0)
# Thread 2: executes balance -= 100 (balance = -100)
# Result: Overdraft! Attacker withdrew $200 from $100 account
Why is this vulnerable: The balance check (self.balance < amount) reads the balance at time-of-check, but the deduction (self.balance -= amount) happens later without locking. In multi-threaded or distributed systems, two concurrent withdrawal requests can both pass the balance check before either deducts funds. Both see balance=100, both determine they can withdraw $100, then both subtract $100, resulting in a negative balance (double-spending). This is a critical vulnerability in financial systems - attackers can drain accounts by sending parallel withdrawal requests that exploit the race window. The check and update must be atomic (single locked operation) or protected by database transactions with proper isolation levels (SERIALIZABLE or SELECT FOR UPDATE). Without atomicity, the invariant "balance never goes negative" is violated.
File Metadata Check Before Open (Symlink Attack)
// VULNERABLE - stat before open allows symlink race
int write_log(const char *logfile, const char *message) {
struct stat st;
// TIME-OF-CHECK: Check file ownership and permissions
if (stat(logfile, &st) == 0) {
// Verify file is owned by current user
if (st.st_uid != getuid()) {
fprintf(stderr, "Log file not owned by user\n");
return -1;
}
// Verify file is not world-writable
if (st.st_mode & S_IWOTH) {
fprintf(stderr, "Log file is world-writable\n");
return -1;
}
}
// RACE WINDOW: Attacker replaces logfile with symlink to /etc/passwd
// TIME-OF-USE: Open file for writing
int fd = open(logfile, O_WRONLY | O_APPEND);
if (fd < 0) {
perror("open");
return -1;
}
write(fd, message, strlen(message));
close(fd);
return 0;
}
// Attack scenario:
// 1. Attacker creates /tmp/app.log owned by victim user
// 2. stat() checks pass (correct owner, safe permissions)
// 3. Attacker replaces file: rm /tmp/app.log; ln -s /etc/passwd /tmp/app.log
// 4. open() follows symlink, writes log message into /etc/passwd
Why is this vulnerable: The stat() call checks metadata (ownership, permissions) on the file at the given path, but then open() uses the same path again. Between these calls, an attacker can replace the file with a symlink to a sensitive system file. The metadata checks pass on the original file, but open() follows the symlink and writes to the attacker's target. This is particularly dangerous in setuid programs, daemons running as root, or applications writing to predictable paths in /tmp. The attacker exploits the fact that filesystem operations use paths (strings) not file objects - the same path can resolve to different files at different times. The fix is to open the file first with O_NOFOLLOW (fails if symlink), then use fstat() on the file descriptor to check metadata on the opened file, not the path.
Database Record Check Before Update (Race Condition)
-- VULNERABLE - check and update in separate transactions
-- Transaction 1 (checking):
BEGIN;
SELECT quantity FROM inventory WHERE product_id = 123;
-- Returns quantity = 10
COMMIT;
-- RACE WINDOW: Another transaction can sell items here
-- Transaction 2 (updating):
BEGIN;
UPDATE inventory SET quantity = quantity - 5 WHERE product_id = 123;
-- Assumes quantity was still 10, but might be 0 now
COMMIT;
-- Attack scenario:
-- Initial inventory: 10 units
-- Request 1: SELECT quantity (sees 10), plans to sell 10
-- Request 2: UPDATE inventory SET quantity = 0 (sells all 10)
-- Request 1: UPDATE inventory SET quantity = quantity - 10 (now -10!)
-- Result: Overselling, negative inventory
Why is this vulnerable: Reading the inventory quantity in one transaction and updating it in another (or without transactions) creates a TOCTOU race. The SELECT reads quantity at time-of-check, but another transaction can modify the quantity before the UPDATE executes. Both transactions read the same initial value, both think they can proceed, both update the inventory, causing overselling or double allocation. This violates inventory constraints and can lead to financial losses (selling items you don't have, double-booking resources). The fix is to use database transactions with proper locking: SELECT FOR UPDATE (pessimistic lock) or optimistic locking with version numbers. SELECT FOR UPDATE locks the row during the read, preventing other transactions from modifying it until commit, making check-and-update atomic.
Authentication Token Validation (Time-of-Check vs Time-of-Use)
// VULNERABLE - validate token, then use cached user object
const express = require('express');
const app = express();
app.post('/api/transfer', async (req, res) => {
const token = req.headers.authorization;
// TIME-OF-CHECK: Validate token and get user
const user = await validateToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
// RACE WINDOW: Token could be revoked or user permissions changed
await processTransfer(req.body); // Async delay
// TIME-OF-USE: Execute transfer using cached user object
const result = await bankService.transfer({
from: user.accountId,
to: req.body.toAccount,
amount: req.body.amount
});
res.json(result);
});
// Attack scenario:
// 1. User creates transfer request with valid token
// 2. Token validated, user object cached
// 3. Admin revokes token or freezes account during async processTransfer()
// 4. Transfer executes with revoked/frozen account - validation bypassed
Why is this vulnerable: The authentication token is validated at time-of-check (validateToken()), but the actual operation (bankService.transfer()) executes later using a cached user object. In asynchronous systems, the delay between validation and use can be significant (milliseconds to seconds with I/O operations). During this window, the token can be revoked, the user account can be frozen, or permissions can be changed. The application continues using stale authentication state, executing privileged operations after authorization has been revoked. This is especially problematic in microservices where token validation and operation execution happen in different services or processes. The fix is to pass the token to the operation service and re-validate just before execution, or use short-lived tokens with transaction-level validation rather than request-level validation.
Secure Patterns
File Descriptor with fstat() (Eliminates Path-Based TOCTOU)
// SECURE - Use file descriptor, not path-based operations
int read_user_file(const char *filename) {
struct stat st;
// Open file first with O_NOFOLLOW (prevents symlink following)
int fd = open(filename, O_RDONLY | O_NOFOLLOW);
if (fd < 0) {
perror("open");
return -1;
}
// Check metadata using file descriptor (not path)
// This checks the actual opened file, no TOCTOU race
if (fstat(fd, &st) != 0) {
perror("fstat");
close(fd);
return -1;
}
// Verify ownership and permissions on the opened file
if (st.st_uid != getuid()) {
fprintf(stderr, "File not owned by current user\n");
close(fd);
return -1;
}
if (st.st_mode & S_IWOTH) {
fprintf(stderr, "File is world-writable\n");
close(fd);
return -1;
}
// Safe to read - we've verified the actual opened file
char buffer[1024];
ssize_t bytes = read(fd, buffer, sizeof(buffer) - 1);
close(fd);
return 0;
}
Why this works:
- File descriptor eliminates path ambiguity:
open()returns a file descriptor pointing to the specific inode opened, not a path that can change - fstat() operates on opened file: Checks metadata of the actual file descriptor, not the filesystem path - no race condition possible
- O_NOFOLLOW prevents symlink attacks: If filename is a symlink,
open()fails immediately with ELOOP, preventing attacker from redirecting to sensitive files - Single atomic operation:
open()withO_NOFOLLOWis atomic - either succeeds with correct file or fails, no race window between check and use - No TOCTOU window: Once file descriptor is obtained, all subsequent operations (read, fstat, fchmod) work on that exact file descriptor, not the path
Atomic File Creation with O_CREAT|O_EXCL
// SECURE - Atomic file creation without TOCTOU
int create_secure_tempfile(const char *filename) {
// O_CREAT|O_EXCL atomically checks existence and creates
// Fails if file already exists - no race possible
int fd = open(filename, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
if (fd < 0) {
if (errno == EEXIST) {
fprintf(stderr, "File already exists\n");
} else {
perror("open");
}
return -1;
}
// File is created exclusively - no one else can have it
write(fd, "secure data", 11);
close(fd);
return 0;
}
// Alternative: Use mkstemp() for truly unique temp files
int create_unique_tempfile(void) {
char template[] = "/tmp/myapp-XXXXXX";
// mkstemp() atomically creates file with unique name
int fd = mkstemp(template);
if (fd < 0) {
perror("mkstemp");
return -1;
}
// File created with 0600 permissions, exclusive access
write(fd, "secure data", 11);
close(fd);
return 0;
}
Why this works:
- O_EXCL makes creation atomic:
open()withO_CREAT|O_EXCLchecks existence and creates in single atomic kernel operation - no race window - Fails safely if file exists: If attacker creates file first,
open()fails with EEXIST instead of opening attacker's file - O_NOFOLLOW prevents symlink tricks: Attacker can't pre-create symlink to redirect creation to different location
- mkstemp() provides unpredictable names: Attacker can't predict filename to pre-create malicious file or symlink
- Secure permissions from creation: File created with restrictive permissions (0600) from the start, no window with weak permissions
Database Transactions with SELECT FOR UPDATE
-- SECURE - Pessimistic locking with SELECT FOR UPDATE
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Lock the row for update - other transactions must wait
SELECT quantity
FROM inventory
WHERE product_id = 123
FOR UPDATE;
-- Row is locked, check quantity
-- If quantity < 10, rollback
IF quantity < 10 THEN
ROLLBACK;
RAISE EXCEPTION 'Insufficient inventory';
END IF;
-- Update the locked row - atomic check-and-update
UPDATE inventory
SET quantity = quantity - 10
WHERE product_id = 123;
COMMIT;
Why this works:
- SELECT FOR UPDATE acquires row lock: Locks the row in shared mode during SELECT, upgraded to exclusive lock during UPDATE - no other transaction can modify it
- Lock held through entire transaction: Lock persists from SELECT through UPDATE until COMMIT, preventing concurrent modifications
- SERIALIZABLE isolation level: Ensures transaction sees consistent snapshot of data - no phantom reads or dirty writes
- Atomic check-and-update: Check (quantity >= 10) and update (quantity -= 10) happen under same lock, eliminating race condition
- Deadlock detection: Database automatically detects and resolves deadlocks if two transactions wait for each other's locks
Optimistic Locking with Version Numbers
// SECURE - Optimistic locking for concurrent updates
@Entity
public class BankAccount {
@Id
private Long id;
private BigDecimal balance;
@Version // JPA automatic optimistic locking
private Long version;
// Getters/setters
}
@Service
public class BankService {
@Transactional
public void withdraw(Long accountId, BigDecimal amount) {
// Load account with version number
BankAccount account = accountRepository.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException());
// Check balance
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
// Update balance
account.setBalance(account.getBalance().subtract(amount));
// Save with version check
// UPDATE bank_account SET balance = ?, version = version + 1
// WHERE id = ? AND version = ?
// Fails with OptimisticLockException if version changed
accountRepository.save(account);
}
}
Why this works:
- Version column tracks modifications: Each update increments version number, creating modification history
- UPDATE checks version: Query includes
WHERE version = ?- fails if another transaction modified row (changed version) - OptimisticLockException on conflict: If version changed between read and update, exception is thrown and transaction rolls back
- Retry logic handles conflicts: Application can catch exception and retry transaction with fresh version
- No locking during read: Unlike
SELECT FOR UPDATE, optimistic locking doesn't hold locks during read phase, better concurrency for read-heavy workloads - Works across distributed systems: Version checking works even with multiple application servers - database enforces consistency
Thread Synchronization with Locks
# SECURE - Lock protects check-and-update
import threading
class BankAccount:
def __init__(self, account_id, initial_balance):
self.account_id = account_id
self.balance = initial_balance
self._lock = threading.Lock()
def withdraw(self, amount):
# Acquire lock before check-and-update
with self._lock: # Lock held for entire block
# TIME-OF-CHECK: Check balance while holding lock
if self.balance < amount:
raise InsufficientFundsError(
f"Balance {self.balance} < {amount}"
)
# Simulate processing (but lock still held)
time.sleep(0.01)
# TIME-OF-USE: Deduct while still holding lock
self.balance -= amount
return self.balance
# Lock automatically released when exiting 'with' block
# Alternative: Use atomic operations where possible
import threading
class AtomicCounter:
def __init__(self, initial=0):
self._value = initial
self._lock = threading.Lock()
def decrement(self, amount):
# Atomic compare-and-swap pattern
with self._lock:
if self._value < amount:
return False
self._value -= amount
return True
Why this works:
- Lock makes check-update atomic: Entire check-and-update sequence executes under single lock - no other thread can interfere
- Mutual exclusion prevents race: Only one thread can hold lock at a time - concurrent withdrawals are serialized
- Context manager ensures release:
with self._lock:automatically releases lock even if exception occurs, preventing deadlock - Lock held through entire critical section: From balance check through deduction, lock is held - no race window
- Works for multi-threaded applications: Protects shared state across threads in single process (use distributed locks for multi-process)
Authorization Re-validation at Time-of-Use
// SECURE - Re-validate permissions immediately before operation
const express = require('express');
const app = express();
app.post('/api/transfer', async (req, res) => {
const token = req.headers.authorization;
// Initial validation (can be cached for request routing)
const initialUser = await validateToken(token);
if (!initialUser) {
return res.status(401).json({ error: 'Invalid token' });
}
// Perform request processing
await processTransfer(req.body);
// CRITICAL: Re-validate permissions at time-of-use
// This checks current state, not cached state
const currentUser = await validateTokenFresh(token); // No cache
if (!currentUser || currentUser.accountFrozen) {
return res.status(403).json({
error: 'Account status changed during request'
});
}
// Execute transfer with fresh permission check
const result = await bankService.transfer({
from: currentUser.accountId,
to: req.body.toAccount,
amount: req.body.amount,
// Pass token for service-level re-validation
authToken: token
});
res.json(result);
});
// Service layer also validates
class BankService {
async transfer({ from, to, amount, authToken }) {
// Final permission check at operation execution
const user = await this.validatePermissions(authToken);
if (!user.canTransfer || user.accountFrozen) {
throw new AuthorizationError('Permission denied');
}
// Execute with current permissions verified
return this.executeTransfer(from, to, amount);
}
}
Why this works:
- Fresh validation at time-of-use: Re-validates token/permissions immediately before executing privileged operation, not relying on stale checks
- No cache for critical checks: Uses
validateTokenFresh()that bypasses cache, queries current state from authoritative source (database, auth service) - Service-level re-validation: Both API layer and service layer validate - defense in depth prevents bypassing validation
- Detects state changes: Catches account freezing, permission revocation, token blacklisting that occurred between initial validation and operation
- Works for async operations: Even with long async delays (I/O, external API calls), final validation ensures current authorization state
- Microservices-safe: Each service validates independently, doesn't trust validation from calling service
Atomic Compare-and-Swap Operations
// SECURE - Use atomic operations for lock-free check-and-update
package main
import (
"errors"
"sync/atomic"
)
type BankAccount struct {
accountID string
balance int64 // int64 for atomic operations
}
func (a *BankAccount) Withdraw(amount int64) error {
for {
// Read current balance atomically
currentBalance := atomic.LoadInt64(&a.balance)
// Check if sufficient funds
if currentBalance < amount {
return errors.New("insufficient funds")
}
// Calculate new balance
newBalance := currentBalance - amount
// Atomic compare-and-swap
// Only updates if balance hasn't changed since we read it
if atomic.CompareAndSwapInt64(&a.balance, currentBalance, newBalance) {
// Success - balance was currentBalance, now newBalance
return nil
}
// CAS failed - another goroutine modified balance
// Loop and retry with new value
}
}
// Alternative: Use mutex-based atomic operations
import "sync"
type SafeAccount struct {
mu sync.Mutex
balance int64
}
func (a *SafeAccount) Withdraw(amount int64) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return errors.New("insufficient funds")
}
a.balance -= amount
return nil
}
Why this works:
- Compare-and-swap is atomic: CPU-level instruction that checks value and updates in single atomic operation - no race condition possible
- Retry loop handles conflicts: If CAS fails (value changed), loop retries with new value - lock-free but still consistent
- No locks needed: Atomic operations don't require mutex locks, better performance for high-concurrency scenarios
- ABA problem handled by retry: Even if value goes A→B→A, CAS detects change and retries, ensuring consistency
- Works across CPU cores: Atomic operations use memory barriers and cache coherency protocols to work correctly on multi-core systems
- Mutex provides stronger guarantees: For complex check-update logic, mutex-based approach is clearer and prevents partial updates
Security Checklist
- Use file descriptors, not paths: Open file once, use descriptor for all operations - eliminates path-based TOCTOU
- Use O_NOFOLLOW flag: Prevents symlink following during file open - critical for setuid programs and
/tmpfiles - Use O_CREAT|O_EXCL for atomic creation: Atomically checks existence and creates file - no race window
- Database transactions with proper isolation: Use
BEGIN TRANSACTIONwithSERIALIZABLEorSELECT FOR UPDATEfor critical operations - Hold locks from check through use: Don't release lock between permission check and privileged operation
- Re-validate permissions at time-of-use: Don't trust cached authorization - re-check immediately before executing operation
- Use atomic operations: Prefer
CompareAndSwap, atomic integers over separate check-and-update - Avoid access() system call: Use
open()directly with proper flags, check errors -access()creates TOCTOU by design - Use fstat() not stat(): After opening file, use
fstat(fd)to check metadata, notstat(path) - Implement retry logic for conflicts: When using optimistic locking or CAS, handle conflicts by retrying transaction
- Use unique unpredictable filenames:
mkstemp(), UUIDs, cryptographic random - prevents filename prediction attacks - Avoid fixed paths in /tmp: If must use
/tmp, usemkdtemp()to create unique directory, then files inside it - Test with concurrent operations: Run tests with multiple threads/processes attempting same operations simultaneously
- Set restrictive permissions from creation: Use mode parameter in
open()to create files with 0600, not 0666 then chmod - Use advisory locks for coordination:
flock(),fcntl()withF_SETLKfor coordinating access to shared resources