DEV Community

Cover image for Understanding TOCTOU: The Race Condition Hiding in Your Code
Victory Lucky
Victory Lucky

Posted on

Understanding TOCTOU: The Race Condition Hiding in Your Code

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:

  1. Time of Check: Your code verifies a condition (file exists, user has permission, resource is available)
  2. Time Gap: A brief window where other processes can execute
  3. Time of Use: Your code acts based on the earlier check
  4. 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');
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

In this example, a single HttpClient instance is shared across all requests. Under concurrent requests (which is normal in Next.js):

  1. Request A calls authenticatedHttp() and sets its token
  2. Request B calls authenticatedHttp() and overwrites the token
  3. 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;
};
Enter fullscreen mode Exit fullscreen mode

Or even better, use immutable configuration:

// ✅ BEST: Immutable configuration
export const authenticatedHttp = async (...) => {
  const accessToken = await getAccessToken();
  return new HttpClient({
    token: accessToken,
    baseUrl: baseUrl || ""
  });
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Testing for TOCTOU Bugs

TOCTOU bugs are notoriously hard to test because they depend on timing:

  1. Stress testing: Run concurrent operations to expose race conditions
  2. Chaos engineering: Introduce random delays to widen the time gap
  3. Static analysis: Use tools to detect check/use patterns
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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)