Race Conditions in Privileged Programs
A race condition occurs when the behavior of a program depends on the timing of external events โ and an attacker can influence that timing to trigger unexpected behavior. In privileged programs, race conditions are especially dangerous because the window between a check and an action can be exploited to gain unauthorized access.
There are two related attack patterns: TOCTTOU (time-of-check, time-of-use) and signal-based stop/resume attacks. Both exploit the gap between when a program verified something and when it acts on that verification.
The classic pattern: a privileged program checks whether an operation is safe (e.g., verifies a file exists and is accessible), then later performs the operation based on that check. If an attacker can change the state between the check and the use, the verification is worthless.
| Time | Privileged Program | Attacker (different process) |
|---|---|---|
| T1 | stat(“/tmp/userfile”) โ OK, owned by user 1000 | Waiting… |
| T2 | Context switch / signal / pause… | rm /tmp/userfile; ln -s /etc/shadow /tmp/userfile |
| T3 | open(“/tmp/userfile”) โ opens /etc/shadow as ROOT! | Success โ /etc/shadow read by attacker |
The access() System Call โ A Classic TOCTTOU Trap
access() checks file accessibility using the real UID (not effective UID). Privileged programs often use it to check whether the real user (not root) can access a file before proceeding. But this creates a TOCTTOU window between access() and the subsequent open().
The fix: don’t use access() at all. Instead, temporarily drop privilege to the real UID, then call open() directly. This combines the check and the operation atomically from the kernel’s point of view.
Any user can send SIGTSTP or SIGSTOP to a set-UID program they started. This stops the process mid-execution. The attacker then modifies the environment and sends SIGCONT to resume the program.
The program resumes executing based on assumptions it made before being stopped โ which are now false. This is effectively a way to widen the TOCTTOU window to an arbitrarily long duration, giving the attacker plenty of time to manipulate the environment.
Defenses Against Signal-Based Attacks
Block SIGTSTP in critical sections. Note that SIGSTOP cannot be caught or ignored.
Use atomic operations: open the file (verify+open in one call), then use fstat() on the FD rather than stat() on the path.
After handling a signal, re-check all assumptions about the runtime environment before continuing.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <string.h>
/*
* WRONG approach: stat() then open() โ classic TOCTTOU
*
* Attacker can replace /tmp/userfile with a symlink to /etc/shadow
* BETWEEN the stat() call and the open() call.
*/
int unsafe_open(const char *path)
{
struct stat sb;
/* CHECK: verify file attributes */
if (stat(path, &sb) == -1) {
perror("stat");
return -1;
}
if (sb.st_uid != getuid()) {
fprintf(stderr, "File not owned by caller!\n");
return -1;
}
/* USE: window here โ attacker can swap the file! */
return open(path, O_RDWR);
}
/*
* CORRECT approach: open() first, then fstat() on the FD
*
* fstat() operates on the already-open file descriptor.
* Even if the pathname is changed after open(), fstat() still
* checks the file we actually opened โ no TOCTTOU window.
*/
int safe_open(const char *path)
{
int fd;
struct stat sb;
/* Step 1: Open the file โ creates an FD pinned to this inode */
fd = open(path, O_RDWR);
if (fd == -1) {
perror("open");
return -1;
}
/* Step 2: fstat() on the FD โ checks the OPENED file, not the path */
if (fstat(fd, &sb) == -1) {
perror("fstat");
close(fd);
return -1;
}
/* Step 3: Now validate โ we know this is the same file we opened */
if (sb.st_uid != getuid()) {
fprintf(stderr, "File not owned by caller!\n");
close(fd);
return -1;
}
/* Also check: it's a regular file (not a symlink, device, etc.) */
if (!S_ISREG(sb.st_mode)) {
fprintf(stderr, "Not a regular file!\n");
close(fd);
return -1;
}
printf("Safely opened: %s (inode %lu)\n", path, (unsigned long)sb.st_ino);
return fd;
}
int main(void)
{
int fd;
printf("UID: %d, EUID: %d\n", (int)getuid(), (int)geteuid());
fd = safe_open("/tmp/test_tocttou.txt");
if (fd != -1) {
printf("File opened safely, fd=%d\n", fd);
close(fd);
}
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
/*
* Block SIGTSTP (Ctrl+Z / stop signal) during a critical section
* where we cannot afford to be stopped mid-operation.
*
* SIGSTOP cannot be caught or blocked โ only SIGTSTP can be managed.
*/
void critical_section_start(sigset_t *old_mask)
{
sigset_t block_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGTSTP); /* Block Ctrl+Z */
sigaddset(&block_set, SIGINT); /* Optionally block Ctrl+C too */
if (sigprocmask(SIG_BLOCK, &block_set, old_mask) == -1) {
perror("sigprocmask block");
exit(EXIT_FAILURE);
}
printf("[Critical] Signals blocked. Cannot be interrupted.\n");
}
void critical_section_end(const sigset_t *old_mask)
{
/* Restore the original signal mask */
if (sigprocmask(SIG_SETMASK, old_mask, NULL) == -1) {
perror("sigprocmask restore");
exit(EXIT_FAILURE);
}
printf("[Critical] Signals unblocked.\n");
}
int main(void)
{
sigset_t saved_mask;
printf("Program started. EUID=%d\n", (int)geteuid());
/* --- Begin critical section --- */
critical_section_start(&saved_mask);
/*
* In this window:
* 1. Verify environment assumptions
* 2. Perform privileged operation atomically
*
* User cannot stop the program with Ctrl+Z during this block.
*/
printf("[Critical] Checking file ownership...\n");
/* ... stat/open operations here ... */
printf("[Critical] Performing privileged write...\n");
/* ... privileged operation here ... */
critical_section_end(&saved_mask);
/* --- End critical section --- */
printf("Back to normal operation. User can now Ctrl+Z.\n");
/* In a real daemon, you'd sleep or do other non-critical work here */
sleep(2);
return 0;
}
