Skip to content

CWE-367: Time-of-check Time-of-use Race Condition - C

Overview

Filesystem calls in C take a path, and a path is a name that the kernel resolves again on every call. Two calls naming the same path are two separate lookups, and between them another process can replace the file, or any directory on the way to it, with something else - most usefully a symbolic link.

This is why access() then open(), or stat() then open(), is a defect regardless of how little code sits between them. The race window is not the sleep in a demonstration; it is the interval between two syscalls, and an attacker retrying in a tight loop only has to win it once.

Common Vulnerable Patterns

access() before open()

// VULNERABLE - the path is resolved twice, and can name a different file each time
int read_user_file(const char *filename) {
    if (access(filename, R_OK) != 0) {
        fprintf(stderr, "Cannot access file\n");
        return -1;
    }

    // Attacker: rm /tmp/userfile; ln -s /etc/shadow /tmp/userfile
    int fd = open(filename, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    close(fd);
    return 0;
}

Why this is vulnerable: access() answers a question about the file the path named at that moment, and open() asks the kernel to resolve the path again. In a setuid program the two calls do not even ask the same question: access() tests the real UID while open() uses the effective UID, so the check can pass for the invoking user while the open succeeds with the elevated privilege. This is the reason access() has essentially no correct use as a pre-flight permission check.

stat() before open()

// VULNERABLE - metadata verified on a path, then a different file is opened
int write_log(const char *logfile, const char *message) {
    struct stat st;

    if (stat(logfile, &st) == 0) {
        if (st.st_uid != getuid()) {
            fprintf(stderr, "Log file not owned by user\n");
            return -1;
        }
        if (st.st_mode & S_IWOTH) {
            fprintf(stderr, "Log file is world-writable\n");
            return -1;
        }
    }

    // Attacker: rm /tmp/app.log; ln -s /etc/passwd /tmp/app.log
    int fd = open(logfile, O_WRONLY | O_APPEND);
    if (fd < 0) return -1;

    write(fd, message, strlen(message));
    close(fd);
    return 0;
}

Why this is vulnerable: The ownership and permission checks are correct, and they are performed on the wrong thing - a path, rather than the file that ends up open. A daemon writing to a predictable path under /tmp is the standard target, because the attacker controls the directory and can swap the entry between the two calls.

Secure Patterns

Open first, then check the descriptor

#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>

// SECURE - one path resolution; every later check refers to that exact file
int read_user_file(const char *filename) {
    // O_NONBLOCK so a FIFO or device node cannot make open() itself hang
    int fd = open(filename, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    struct stat st;
    if (fstat(fd, &st) != 0) {
        perror("fstat");
        close(fd);
        return -1;
    }

    if (!S_ISREG(st.st_mode)) {
        fprintf(stderr, "Not a regular file\n");
        close(fd);
        return -1;
    }
    if (st.st_uid != getuid() || (st.st_mode & S_IWOTH)) {
        fprintf(stderr, "File failed ownership or permission check\n");
        close(fd);
        return -1;
    }

    char buffer[1024];
    ssize_t bytes = read(fd, buffer, sizeof(buffer) - 1);
    if (bytes < 0) {
        perror("read");
        close(fd);
        return -1;
    }

    close(fd);
    return 0;
}

Why this works: A file descriptor refers to an open file description, not to a name. Once open() has returned, no amount of renaming, unlinking or symlinking changes what that descriptor points at, so fstat() reports on the file that will actually be read. O_NOFOLLOW makes the open fail with ELOOP if the final component is a symlink, which closes the swap that both vulnerable examples rely on.

O_NONBLOCK and the S_ISREG test are one control, and the order is the point. Opening a FIFO for reading blocks until a writer appears, so an attacker who substitutes a FIFO hangs the process inside open() - before any check runs, and a S_ISREG test placed after the open never gets to reject it. O_NONBLOCK makes the open return immediately, and fstat() then reports what was actually opened, so the type check can refuse it. On Linux the flag has no effect on subsequent reads of a regular file, so it costs the legitimate path nothing; where a descriptor is later handed to code that expects blocking semantics, clear the flag with fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) & ~O_NONBLOCK) once the type check has passed.

O_NOFOLLOW only constrains the last component. If the attacker controls a parent directory, they can still redirect the lookup higher up the path. Where that is in scope, open the directory once and work relative to it with openat(), or on Linux use openat2() with RESOLVE_BENEATH to make the kernel refuse any resolution that escapes the starting directory.

Let the kernel do check-and-create in one operation

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

// SECURE - existence check and creation are a single atomic step
int create_secure_file(const char *filename) {
    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;
    }

    write(fd, "secure data", 11);
    close(fd);
    return 0;
}

// SECURE - unpredictable name, created exclusively, 0600 from the start
int create_temp_file(void) {
    char template[] = "/tmp/myapp-XXXXXX";

    int fd = mkstemp(template);
    if (fd < 0) {
        perror("mkstemp");
        return -1;
    }

    write(fd, "secure data", 11);
    close(fd);
    unlink(template);
    return 0;
}

Why this works: O_CREAT | O_EXCL pushes the check into the kernel, where "does it exist" and "create it" happen under the same lock - there is no interval for anything to occur in. If an attacker pre-created the path, the call fails with EEXIST rather than opening their file. mkstemp() adds an unpredictable name, which removes the attacker's ability to pre-create the entry in the first place, and creates with 0600 so there is never a moment where the file exists with weaker permissions. Never use tmpnam(), tempnam() or mktemp(): they return a name, and the gap between generating it and opening it is exactly this weakness.

Considerations

  • Whether an attacker can reach the directory. The race needs write access to a directory on the path. A file under a root-owned directory with no attacker-writable component is not exploitable in the same way, and recording that reasoning is a legitimate outcome. A path under /tmp, a user home, or any world-writable spool directory is the opposite.
  • Whether the process is privileged. The severity comes from the privilege difference between the process and the attacker. A setuid binary or a root-running daemon turns this into arbitrary file read or write; the same code in an unprivileged process that only touches its own files is often not worth restructuring.
  • Retry semantics after EEXIST. Failing closed is correct, but a service that retries with the same predictable name simply re-enters the race. Either regenerate the name or treat the failure as terminal.

Testing

A scanner can see that access() is gone; it cannot see whether a race remains. The tests below assert it directly.

  • Run the operation in a loop while a second process alternates the path between a legitimate file and a symlink to a sentinel target. Assert the sentinel is never read or written after thousands of iterations. A single-shot test proves nothing about a race.
  • Point the read path at a symlink and assert open() fails with ELOOP rather than succeeding. This is the direct test that O_NOFOLLOW is actually in the flags you shipped, not just in the version you reviewed. Assert the errno rather than just the failure: O_CREAT | O_EXCL refuses a symlink on its own, so the same test against the create path passes with O_NOFOLLOW missing.
  • Point the path at a FIFO with no writer and assert read_user_file returns -1 from the S_ISREG branch. With O_NONBLOCK the open() itself succeeds, so asserting on the open would pass either way. Give the test a timeout: a hang here has reproduced the denial of service rather than returning an inconclusive result, so treat expiry as a failure.
  • Pre-create the target and assert O_EXCL produces EEXIST and no write occurs, then confirm the normal path still creates and writes correctly.
  • Run the tests with the filesystem under contention rather than on an idle machine. ThreadSanitizer is not the tool here: it instruments memory access within a process, and every race on this page is between processes over a directory entry, which it cannot observe. Iteration count and load are what make the window reachable.

Additional Resources