Skip to content

CWE-421: Race Condition During Access to Alternate Channel

Overview

Race conditions during signal handling occur when signal handlers access shared state without proper synchronization, enabling concurrent modification of data structures, TOCTOU vulnerabilities, and unpredictable behavior since signals can interrupt execution at any point.

Risk

High: Signal handler races cause data corruption (partially modified state), use of async-unsafe functions (malloc, printf in handlers), deadlocks (acquiring locks in handler), security bypasses, and unpredictable crashes. Hard to debug and reproduce.

Remediation Steps

Core principle: Race conditions can bypass security checks; make security decisions atomic and synchronized.

Locate the Signal Handler Race Condition

When reviewing security scan results:

  • Examine data_paths: Identify signal handlers that access shared state
  • Find async-unsafe functions: Look for malloc, printf, mutex operations in handlers
  • Trace shared data: Identify variables accessed by both handler and main code
  • Check for reentrancy issues: Find non-reentrant functions called from handlers
  • Review critical sections: Locate code that should block signals

Use Only Async-Signal-Safe Functions (Primary Defense)

Safe functions in signal handlers:

  • write() (NOT printf, fprintf, sprintf)
  • _exit() (NOT exit)
  • Signal modification functions (signal, sigaction, etc.)
  • Basic system calls (read, close, etc.)
  • sig_atomic_t variable assignments

UNSAFE functions in signal handlers:

  • Any stdio functions: printf, fprintf, sprintf, fwrite
  • Memory allocation: malloc, calloc, realloc, free
  • Locking primitives: pthread_mutex_lock, sem_wait
  • Non-reentrant functions: strtok, localtime, getenv

Safe handler example:

void handler(int sig) {
    const char msg[] = "Signal received\n";
    write(STDERR_FILENO, msg, sizeof(msg)-1);  // Safe
}

Why this works: Async-signal-safe functions are guaranteed to be reentrant and won't corrupt internal state if interrupted mid-execution.

Minimize Signal Handler Work (Set Flags Only)

volatile sig_atomic_t flag = 0;

void handler(int sig) {
    flag = 1;  // Only set flag - minimal work
}

int main() {
    signal(SIGINT, handler);
    while (1) {
        if (flag) {
            // Do all actual work in main thread context
            handle_signal();
            flag = 0;
        }
        // Normal processing
    }
}

Best practices:

  • Signal handlers should only set flags or record signal information
  • Perform all actual work in normal execution context
  • This avoids reentrancy issues and race conditions
  • Keeps handler code simple and verifiable

Use sig_atomic_t for All Shared Variables

// Correct: atomic type for signal communication
volatile sig_atomic_t received_signal = 0;
volatile sig_atomic_t signal_count = 0;

void handler(int sig) {
    received_signal = sig;  // Atomic write
    signal_count++;         // Atomic increment
}

int main() {
    // Atomic reads
    if (received_signal) {
        process_signal(received_signal);
        received_signal = 0;
    }
}

Implementation details:

  • sig_atomic_t guarantees atomic reads and writes
  • Always use volatile to prevent compiler optimizations
  • Limit to simple integer types (int, unsigned, etc.)
  • Don't use for complex data structures or pointers

Block Signals During Critical Sections

sigset_t set, oldset;

// Initialize signal set
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);

// Block signals before critical section
sigprocmask(SIG_BLOCK, &set, &oldset);

// Critical section - no signal interruption
modify_shared_data();
update_complex_structure();

// Restore original signal mask
sigprocmask(SIG_SETMASK, &oldset, NULL);

Additional blocking techniques:

// Block all signals temporarily
sigset_t all_signals;
sigfillset(&all_signals);
sigprocmask(SIG_BLOCK, &all_signals, &oldset);
// Critical section
sigprocmask(SIG_SETMASK, &oldset, NULL);

When to use:

  • Protecting multi-step updates to shared data
  • During cleanup or resource deallocation
  • When calling non-reentrant library functions
  • During file operations that must be atomic

Test Signal Handler Safety

Testing strategies:

  • Send signals during execution: kill -SIGNAL $PID
  • Use stress testing to send rapid signals
  • Test with ThreadSanitizer: -fsanitize=thread
  • Verify with static analysis for async-safety
  • Review all signal handlers for unsafe function calls

Verification approaches:

# Test signal handling under stress
while true; do kill -USR1 $PID; sleep 0.001; done

# Check for data races with ThreadSanitizer
gcc -fsanitize=thread -g program.c
./a.out

Common Vulnerable Patterns

Using malloc/free in Signal Handlers

// VULNERABLE - malloc in handler - NOT async-signal-safe!
void handler(int sig) {
    char *msg = malloc(100);  // Can corrupt heap
    sprintf(msg, "Signal %d", sig);  // NOT safe
    free(msg);  // Can deadlock
}

Why is this vulnerable: Memory allocation functions (malloc, calloc, free) maintain internal data structures protected by locks. If a signal interrupts the main thread during a malloc call and the handler tries to allocate memory, it attempts to acquire the same lock, causing deadlock. Even without deadlock, heap metadata can be corrupted if the signal interrupts mid-allocation - the allocator's internal state becomes inconsistent, leading to crashes or memory corruption. sprintf is also unsafe because it internally allocates memory and uses non-reentrant buffering. This creates unpredictable crashes that only occur when signals arrive at precise moments, making the bug extremely difficult to reproduce and debug.

Modifying Complex Shared Data Structures

// VULNERABLE - race condition with main thread
struct Data {
    int field1;
    int field2;
    char *message;
} shared_data;

void handler(int sig) {
    shared_data.field1 = 1;  // RACE: main thread might be reading
    shared_data.field2 = 2;  // Main thread sees inconsistent state
    shared_data.message = "Updated";  // Pointer update not atomic
}

int main() {
    // Main thread accesses shared_data without synchronization
    if (shared_data.field1 == shared_data.field2) {
        process(shared_data.message);  // Might see half-updated state
    }
}

Why is this vulnerable: Signals can interrupt execution at any instruction, creating a race condition where the main thread reads the structure while the handler is mid-update. If the handler sets field1 = 1 then the signal returns to main code before setting field2, the main thread sees an inconsistent state (field1=1, field2=0) violating invariants. Multi-word values like pointers may not be atomically updated on all architectures - on 32-bit systems, a 64-bit pointer update might be interrupted halfway through. The handler might also interrupt the main thread while it's updating the same structure, causing lost updates where both threads write to the same fields and one overwrites the other's changes. This leads to data corruption and security bypasses where checks see inconsistent states.

Acquiring Locks in Signal Handlers

// VULNERABLE - can deadlock
pthread_mutex_t lock;

void handler(int sig) {
    pthread_mutex_lock(&lock);  // DEADLOCK if main thread holds lock
    shared_counter++;
    pthread_mutex_unlock(&lock);
}

int main() {
    pthread_mutex_lock(&lock);
    shared_counter++;  // Signal arrives here - handler blocks on same lock
    pthread_mutex_unlock(&lock);  // Never reached - deadlock
}

Why is this vulnerable: If the signal interrupts the main thread while it holds a lock, and the handler tries to acquire the same lock, the handler blocks waiting for the main thread to release it - but the main thread is suspended waiting for the handler to return. This is an unrecoverable deadlock. Even with different locks, using mutexes in signal handlers creates deadlock potential because pthread_mutex_lock is not async-signal-safe - it may call malloc internally or manipulate non-reentrant data structures. Semaphores (sem_wait) have the same problem. The only safe synchronization in signal handlers is blocking signals themselves (sigprocmask) or using atomic types (sig_atomic_t).

Using stdio Functions in Signal Handlers

// VULNERABLE - stdio functions not async-signal-safe
void handler(int sig) {
    printf("Received signal %d\n", sig);  // Can corrupt stdio buffers
    fprintf(stderr, "Error occurred\n");  // NOT safe
    fflush(stdout);  // NOT safe
}

Why is this vulnerable: All stdio functions (printf, fprintf, fwrite, puts) maintain internal buffers and use locking for thread safety. If a signal interrupts during printf, the stdio buffer is in an inconsistent state. When the handler calls printf again, it corrupts these buffers, leading to garbled output, crashes, or infinite loops. The stdio library is not reentrant - calling it from a signal handler while it's already executing violates assumptions about its state. On some systems, stdio uses malloc internally, compounding the deadlock risk. Even fflush is unsafe because it manipulates buffer state. The only safe output in signal handlers is write(), which is a raw system call with no internal state.

Non-Atomic Flag Variables

// VULNERABLE - not sig_atomic_t and not volatile
int signal_received = 0;  // Compiler might optimize away checks

void handler(int sig) {
    signal_received = 1;  // Write might not be atomic
}

int main() {
    while (!signal_received) {  // Compiler might cache value
        do_work();
    }
}

Why is this vulnerable: Regular int variables don't guarantee atomic access on all architectures. On some platforms, reading/writing multi-byte integers involves multiple instructions, so a signal might interrupt mid-access, causing torn reads (seeing partially updated values). Without volatile, the compiler assumes signal_received doesn't change during the loop and optimizes the check away entirely, creating an infinite loop that never sees the signal. Even if atomicity is preserved, without volatile the compiler might cache the value in a register and never re-read from memory. Using sig_atomic_t guarantees atomic access, and volatile prevents optimization, ensuring the main thread sees updates from the signal handler.

Secure Patterns

Minimal Signal Handlers (Flag-Only Pattern)

// SECURE - only set atomic flag
volatile sig_atomic_t flag = 0;

void handler(int sig) {
    flag = 1;  // Minimal work - atomic write only
}

int main() {
    signal(SIGINT, handler);
    while (1) {
        if (flag) {
            // All actual work in main thread context
            handle_signal();
            flag = 0;
        }
        do_normal_work();
    }
}

Why this works: The handler does minimal work - only setting a flag - which eliminates most race condition risks. The flag is volatile sig_atomic_t, guaranteeing atomic writes from the handler and atomic reads from the main loop, with volatile preventing compiler optimization. All complex processing happens in the main thread context where normal synchronization primitives work correctly and async-signal-safety restrictions don't apply. This pattern is simple, verifiable, and eliminates the need for signal-safe alternatives to library functions. The main thread can safely call malloc, use stdio, acquire locks, and perform any operations needed to handle the signal. By deferring work to normal execution context, the code remains maintainable and bug-free.

Using Only Async-Signal-Safe Functions

// SECURE - write() is async-signal-safe
void handler(int sig) {
    const char msg[] = "Signal received\n";
    write(STDERR_FILENO, msg, sizeof(msg)-1);  // Safe system call
    _exit(1);  // Use _exit, not exit
}

Why this works: write() is a raw system call that operates directly on file descriptors without buffering, locks, or internal state - it's guaranteed to be async-signal-safe by POSIX. The message is a string literal stored in static memory, not dynamically allocated. _exit() terminates immediately without calling cleanup handlers or flushing stdio buffers (which would be unsafe), while exit() performs cleanup that might call non-reentrant code. By restricting to the small set of async-signal-safe functions (system calls like write, read, close, and signal manipulation functions), the handler avoids all reentrancy issues. This approach is more restrictive but provides guaranteed correctness.

Blocking Signals During Critical Sections

// SECURE - block signals to protect critical section
sigset_t set, oldset;

// Initialize signal set
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);

// Block signals before critical section
sigprocmask(SIG_BLOCK, &set, &oldset);

// Critical section - guaranteed no signal interruption
modify_shared_data();
update_complex_structure();

// Restore original signal mask
sigprocmask(SIG_SETMASK, &oldset, NULL);

Why this works: sigprocmask() prevents specified signals from being delivered during the critical section, creating an atomic execution window where multi-step operations can complete without interruption. Signals that arrive while blocked are queued and delivered after sigprocmask restores the original mask. This provides stronger guarantees than locks because signals cannot interrupt even to check lock availability. The pattern is exception-safe - even if the critical section crashes, the kernel automatically restores the signal mask when the process terminates. Blocking signals is the only reliable way to protect complex operations from signal interruption since locks and atomic operations don't work in signal handlers.

Using sig_atomic_t for Shared Variables

// SECURE - correct atomic type with volatile
volatile sig_atomic_t received_signal = 0;
volatile sig_atomic_t signal_count = 0;

void handler(int sig) {
    received_signal = sig;  // Guaranteed atomic write
    signal_count++;         // Atomic increment
}

int main() {
    signal(SIGUSR1, handler);

    while (1) {
        // Atomic read - no tearing
        if (received_signal) {
            process_signal(received_signal);
            received_signal = 0;  // Atomic write
        }

        // Safe to read at any time
        if (signal_count > 10) {
            handle_too_many_signals();
        }
    }
}

Why this works: sig_atomic_t is specifically designed for signal handler communication, guaranteeing that reads and writes are atomic (won't be interrupted mid-operation) on all platforms. The type is typically int but sized appropriately for the architecture to ensure atomic access. volatile prevents the compiler from optimizing away repeated reads - without it, the compiler might cache the value in a register and never see updates from the signal handler. Together, volatile sig_atomic_t provides lock-free, safe communication between signal handlers and normal code. The limitation to simple integer types is acceptable because the flag-only pattern only needs to signal conditions, not transfer complex data. This is the only safe way to share variables between signal handlers and normal code without blocking signals.

Self-Pipe Trick for Integration with Event Loops

// SECURE - integrate signals with select/poll
int signal_pipe[2];

void handler(int sig) {
    char byte = sig;
    write(signal_pipe[1], &byte, 1);  // Write to pipe (async-safe)
}

int main() {
    pipe(signal_pipe);
    signal(SIGUSR1, handler);

    fd_set readfds;
    while (1) {
        FD_ZERO(&readfds);
        FD_SET(signal_pipe[0], &readfds);

        select(signal_pipe[0] + 1, &readfds, NULL, NULL, NULL);

        if (FD_ISSET(signal_pipe[0], &readfds)) {
            char byte;
            read(signal_pipe[0], &byte, 1);
            handle_signal(byte);  // Process in normal context
        }
    }
}

Why this works: The self-pipe trick converts asynchronous signals into file descriptor events that can be handled by select/poll/epoll. The handler only calls write() (async-signal-safe) to send a byte through a pipe, then returns immediately. The main event loop detects the pipe is readable and processes the signal in normal execution context where all functions are safe. This integrates signals into existing event-driven architectures without special signal handling code. The pipe write is non-blocking (pipe buffer is typically 64KB), atomic for single-byte writes, and never fails unless the pipe is full (indicating thousands of pending signals). This pattern is used by many production systems (Nginx, Redis) because it's both safe and efficient.

Security Checklist

  • All signal handlers use only async-signal-safe functions
  • Shared variables are volatile sig_atomic_t
  • Critical sections block signals appropriately
  • No malloc/free in signal handlers
  • No printf/stdio functions in signal handlers
  • No mutex locks acquired in signal handlers

Additional Resources