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 withELOOPrather than succeeding. This is the direct test thatO_NOFOLLOWis actually in the flags you shipped, not just in the version you reviewed. Assert the errno rather than just the failure:O_CREAT | O_EXCLrefuses a symlink on its own, so the same test against the create path passes withO_NOFOLLOWmissing. - Point the path at a FIFO with no writer and assert
read_user_filereturns-1from theS_ISREGbranch. WithO_NONBLOCKtheopen()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_EXCLproducesEEXISTand 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.