Time-of-Check to Time-of-Use (TOCTOU) is one of those sneaky vulnerabilities that can slip past even experienced developers. It's a race condition that occurs when there's a time gap between checking a condition and acting on it, and in that gap, everything can change.
What is TOCTOU?
TOCTOU vulnerabilities arise from a simple but critical assumption: that the state you just checked will remain the same when you use it. Unfortunately, in concurrent systems, this assumption is often wrong.
The pattern looks like this:
- Time of Check: Your code verifies a condition (file exists, user has permission, resource is available)
- Time Gap: A brief window where other processes can execute
- Time of Use: Your code acts based on the earlier check
- Problem: The condition has changed, but your code doesn't know it
Example 1: File System Race Conditions
The classic TOCTOU example involves file operations:
import { existsSync, readFileSync } from 'fs';
// ❌ VULNERABLE: TOCTOU race condition
function readUserFile(filename: string): string {
// Time of Check
if (existsSync(filename)) {
// Time gap - another process could:
// - Delete the file
// - Replace it with a symlink to /etc/passwd
// - Change its permissions
// Time of Use
const data = readFileSync(filename, 'utf-8');
return data;
}
throw new Error('File not found');
}
Between the check and the read, an attacker (or even just another legitimate process) could manipulate the file system. This could lead to:
- Reading sensitive files via symlink attacks
- Privilege escalation in setuid programs
- Data corruption or application crashes
The Fix: Use Atomic Operations
import { readFileSync } from 'fs';
// ✅ BETTER: Handle errors, don't separate check from use
function readUserFile(filename: string): string {
try {
// Check and use happen atomically in the OS
return readFileSync(filename, 'utf-8');
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error('File not found');
}
throw error;
}
}
The key insight: Let the operating system handle the check and use atomically. Don't try to do it yourself.
Example 2: Shared Singleton State in HTTP Handlers
A more subtle TOCTOU bug appears in web applications with shared state. Consider this Next.js authentication helper:
// ❌ VULNERABLE: Shared HttpClient singleton
const http = new HttpClient();
export const authenticatedHttp = async (...) => {
const accessToken = await getAccessToken();
http.setToken(accessToken); // mutates shared instance!
http.setUrl = baseUrl || ""; // mutates shared instance!
return http;
};
In this example, a single HttpClient instance is shared across all requests. Under concurrent requests (which is normal in Next.js):
-
Request A calls
authenticatedHttp()and sets its token -
Request B calls
authenticatedHttp()and overwrites the token - Request A uses the client, but now has Request B's token
This is a classic TOCTOU bug:
- Time of Check: Request A sets its authentication token
- Time of Use: Request A makes HTTP calls with the client
- Problem: Between setting and using, Request B modified the shared state
The Real-World Impact
This vulnerability can cause:
- User A accidentally authenticating as User B
- Sensitive data leaking to the wrong user
- Authorization bypasses
- Difficult-to-reproduce bugs that only appear under load
The Fix: Avoid Shared Mutable State
// ✅ BETTER: Create a new instance per request
export const authenticatedHttp = async (...) => {
const accessToken = await getAccessToken();
const http = new HttpClient(); // new instance!
http.setToken(accessToken);
http.setUrl = baseUrl || "";
return http;
};
Or even better, use immutable configuration:
// ✅ BEST: Immutable configuration
export const authenticatedHttp = async (...) => {
const accessToken = await getAccessToken();
return new HttpClient({
token: accessToken,
baseUrl: baseUrl || ""
});
};
Common TOCTOU Scenarios
TOCTOU bugs appear in many contexts:
File System Operations
- Checking file existence then opening it
- Verifying permissions then accessing the file
- Checking disk space then writing data
Authentication & Authorization
- Validating a token then using it
- Checking permissions then performing an action
- Verifying session state then accessing resources
Resource Management
- Checking availability then allocating
- Validating constraints then modifying state
- Testing conditions then acting on them
Shared State in Web Applications
- Singleton services with mutable state
- Global configuration objects
- Shared database connections with session state
How to Prevent TOCTOU Vulnerabilities
1. Use Atomic Operations
Whenever possible, combine check and use into a single atomic operation:
// ❌ BAD: Separate check and use
if (canAccessResource(user, resource)) {
accessResource(resource);
}
// ✅ GOOD: Single operation that checks and acts
accessResourceIfAuthorized(user, resource);
2. Avoid Shared Mutable State
Create new instances instead of reusing mutable ones:
// ❌ BAD: Shared mutable client
const sharedClient = new ApiClient();
sharedClient.setAuth(token);
// ✅ GOOD: New instance per request
const client = new ApiClient({ auth: token });
3. Use Proper Locking
When shared state is unavoidable, use locks or mutexes:
import { Mutex } from 'async-mutex';
const mutex = new Mutex();
const sharedResource = { value: 0 };
async function safeUpdate() {
const release = await mutex.acquire();
try {
// Critical section - check and use are protected
if (sharedResource.value < 100) {
sharedResource.value++;
}
} finally {
release();
}
}
4. Design for Concurrency
Think about concurrent access from the start:
- Use immutable data structures
- Prefer functional programming patterns
- Minimize shared state
- Use message passing instead of shared memory
5. Handle Errors Gracefully
Don't assume checks guarantee success:
// ✅ GOOD: Handle the race condition gracefully
try {
const data = await readFile(filename);
return data;
} catch (error) {
if (error.code === 'ENOENT') {
// File disappeared between existence check and read
logger.warn('File vanished during read', { filename });
return null;
}
throw error;
}
Testing for TOCTOU Bugs
TOCTOU bugs are notoriously hard to test because they depend on timing:
- Stress testing: Run concurrent operations to expose race conditions
- Chaos engineering: Introduce random delays to widen the time gap
- Static analysis: Use tools to detect check/use patterns
- Code review: Look for shared mutable state and separate check/use operations
// Test helper to expose race conditions
async function concurrentTest(operation: () => Promise<void>, count: number) {
const promises = Array(count).fill(null).map(() => operation());
await Promise.all(promises);
}
// Use it to find bugs
await concurrentTest(async () => {
await authenticatedHttp(); // Does this cause issues?
}, 100);
Conclusion
TOCTOU vulnerabilities remind us that in concurrent systems, the state we checked a moment ago might already be obsolete. The solution isn't to check faster—it's to design systems that don't rely on assumptions about state persistence across time gaps.
Key takeaways:
- Combine check and use into atomic operations whenever possible
- Avoid shared mutable state in concurrent contexts
- Use proper synchronization when sharing is unavoidable
- Design with concurrency in mind from the start
- Handle race conditions gracefully when they occur
Remember: Between the time you check and the time you use, the world can change. Code accordingly.
Top comments (0)