The TOCTOU Trap: When Your Safety Check Lies to You

2026-05-09

This C function is supposed to safely write user-supplied data to a file, but only if the path points to a regular file owned by the current user. It looks defensive — it checks the file's type and ownership before writing. Spot the security hole.

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

// Write `data` to `path`, but only if it's a regular file we own.
int safe_write(const char *path, const char *data) {
    struct stat st;
    if (stat(path, &st) != 0)
        return -1;

    if (!S_ISREG(st.st_mode)) {
        fprintf(stderr, "refusing: not a regular file\n");
        return -1;
    }
    if (st.st_uid != getuid()) {
        fprintf(stderr, "refusing: not owned by caller\n");
        return -1;
    }

    FILE *f = fopen(path, "w");        // open AFTER the check
    if (!f) return -1;
    fputs(data, f);
    fclose(f);
    return 0;
}

The Bug

This is a textbook TOCTOU — Time-of-Check to Time-of-Use — race condition, and it's a privilege-escalation vector when the program runs setuid or as a service.

Between stat(path, &st) and fopen(path, "w"), the path is resolved twice. An attacker with write access to the containing directory can race the two calls:

  1. Create /tmp/foo as a normal file owned by the victim user.
  2. Call safe_write("/tmp/foo", evil). The stat() succeeds — regular file, correct uid.
  3. Before fopen() runs, the attacker does rm /tmp/foo && ln -s /etc/passwd /tmp/foo.
  4. fopen() follows the symlink and merrily clobbers /etc/passwd with attacker-controlled bytes.

The window is small but absolutely exploitable — attackers use filesystem-event tricks (inotify, sched_yield storms, even slow disks) to widen it to milliseconds. The CERT secure coding catalog has filed this exact pattern under FIO01-C for two decades.

The deeper lesson: a path is a name, not an object. Every syscall that takes a const char * path re-resolves it from scratch, so any check on a path is meaningless by the time you act on it. The kernel offers no atomicity between two pathname-based calls.

The Fix

Open the file first, then check the resulting file descriptor with fstat(). The fd is bound to a specific inode — once you hold it, swapping the path can't redirect your write. Add O_NOFOLLOW to refuse symlinks, and O_CLOEXEC for hygiene.

#include <fcntl.h>

int safe_write(const char *path, const char *data) {
    int fd = open(path, O_WRONLY | O_NOFOLLOW | O_CLOEXEC);
    if (fd < 0) return -1;

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

    if (!S_ISREG(st.st_mode) || st.st_uid != getuid()) {
        close(fd);
        return -1;
    }

    FILE *f = fdopen(fd, "w");         // wrap the SAME fd
    if (!f) { close(fd); return -1; }
    fputs(data, f);
    fclose(f);                         // closes fd too
    return 0;
}

For new files, use openat() with a directory fd plus O_CREAT | O_EXCL | O_NOFOLLOW — that pattern is atomic against the racing-symlink attack entirely.

Key Takeaway: A path is a name that the kernel re-resolves every time you use it; only a file descriptor refers to a specific object — so check the fd, not the path.

All newsletters