CWE-364: Signal Handler Race Condition - C
Overview
POSIX signal handlers run asynchronously and can interrupt normal execution - or another handler - at essentially any instruction. C provides no automatic protection against this: a handler that touches shared state or calls a non-reentrant function can corrupt data, deadlock, or double-free memory depending on exactly when the signal arrives.
Primary Defence: Restrict signal handlers to the small set of functions POSIX guarantees are async-signal-safe (write(), _exit(), sig_atomic_t assignments, and a handful of others), and do the real work in the main program after the handler sets a flag.
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 the heap
sprintf(msg, "Signal %d", sig); // NOT safe
free(msg); // can deadlock
}
Why this is 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, a signal that interrupts mid-allocation leaves the allocator's internal state inconsistent, corrupting the heap metadata. sprintf is not on POSIX's async-signal-safe list either, for a reason invisible at the call site: it writes into a caller-supplied buffer and so looks self-contained, but glibc's implementation reads locale state and allocates a working buffer for some conversions - so a handler that only formats a message can still re-enter the allocator without naming malloc anywhere. The resulting crashes only happen when signals arrive at precise moments, which is what makes the bug hard to reproduce.
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
}
Why this is 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 violating invariants. The individual assignments are no safer than the sequence. What a handler may touch is a short list: an object declared volatile sig_atomic_t, or - since C11 - a lock-free atomic object. Nothing here is either. Anything wider than the machine's word is written in more than one store, so a 64-bit counter or a double on a 32-bit target can be half-updated when the handler returns, and a struct assignment is a copy loop with no atomicity at all. Whether a given pointer store is one instruction is a property of the target, not something the source shows - which is why the rule is stated as a list of permitted types rather than as "a handler touches small things".
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);
}
Why this is vulnerable: If the signal interrupts the main thread while it holds 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. pthread_mutex_lock is not async-signal-safe independently of this scenario - it may call malloc internally or manipulate non-reentrant data structures. The only safe synchronization in signal handlers is blocking signals themselves - sigprocmask(), or pthread_sigmask() in a threaded program such as this one - or using atomic types (sig_atomic_t).
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 the value
do_work();
}
}
Why this is vulnerable: Regular int variables don't guarantee atomic access on all architectures, so a signal might interrupt mid-write. Without volatile, the compiler assumes signal_received doesn't change during the loop and can optimize the check away entirely, creating an infinite loop that never observes the signal.
One Handler Registered for Several Signals
// VULNERABLE - the same handler runs for two signals and can free the same pointer twice
static char *log_buffer;
void cleanup_handler(int sig) {
free(log_buffer); // VULNERABLE - a second entry frees an already-freed pointer
log_buffer = NULL; // not reached if SIGTERM arrives between these two lines
_exit(0);
}
int main(void) {
log_buffer = malloc(4096);
signal(SIGHUP, cleanup_handler);
signal(SIGTERM, cleanup_handler); // both dispositions point at the same code
run_server();
}
Why this is vulnerable: SIGHUP arrives, the handler calls free(log_buffer), and SIGTERM arrives before log_buffer = NULL runs. The second entry passes the same pointer to free() again - a double free (CWE-415), and a use-after-free (CWE-416) if the allocator has already handed that chunk to someone else. Reordering so the pointer is cleared before the free() does not fix it either: reading the pointer and clearing it are still two steps, so a signal landing between them leaves both entries holding the same non-NULL copy.
How the handler was registered decides how reachable that window is, and signal() is the worst case. The man page is direct about it: "The only portable use of signal() is to set a signal's disposition to SIG_DFL or SIG_IGN", because the semantics of establishing a handler with it vary by system and POSIX permits the variation. The two historical behaviours break differently and neither is safe. Under the original System V semantics the disposition is reset to SIG_DFL on delivery and nothing is blocked while the handler runs - so a second SIGHUP does not re-enter the handler, it takes the default action and terminates the process mid-cleanup. Handlers written for those systems re-arm themselves with signal(SIGHUP, cleanup_handler) on entry to avoid that, which reopens the door the other way: once the disposition is restored and nothing is blocking the signal, the next SIGHUP does re-enter. Under BSD semantics the delivered signal is blocked for the duration, and the same-signal case is closed. sigaction() gives the BSD behaviour everywhere - but it blocks only the signal being delivered, so a sibling signal sharing the handler still re-enters unless it is named in sa_mask. MITRE tracks this specific shape as CWE-831, a child of CWE-364.
Secure Patterns
Minimal Signal Handlers (Flag-Only Pattern)
// SECURE - only set an atomic flag
#include <signal.h>
volatile sig_atomic_t flag = 0;
static void handler(int sig) {
(void)sig;
flag = 1; // minimal work - atomic write only
}
int main(void) {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGINT); // block every signal this handler serves,
sigaddset(&sa.sa_mask, SIGTERM); // so neither can re-enter it
sa.sa_flags = SA_RESTART;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
while (1) {
if (flag) {
handle_signal(); // all real work happens here, in normal context
flag = 0;
}
do_normal_work();
}
}
Why this works: The handler does one thing - set a flag - so there is no multi-step update for a signal to interrupt. volatile sig_atomic_t guarantees the main loop never reads a half-written value: sig_atomic_t is a type C promises can be written without a signal splitting the access, and volatile stops the compiler caching the flag in a register and optimizing the loop's check away. All complex processing happens in the main thread context, where malloc, stdio, and locks all work correctly and async-signal-safety restrictions don't apply.
sigaction() rather than signal() is doing work here, not just following convention. It gives one defined behaviour on every POSIX system instead of the two signal() is allowed to choose between, and sa_mask names the signals blocked while the handler runs - which is what answers the shared-handler re-entrancy above, since the delivered signal is blocked automatically but its siblings are not. SA_RESTART restarts an interrupted read() or write() rather than failing it with EINTR; it does not cover select() or poll(), which the next pattern has to handle explicitly.
Note the guarantee's limits before reusing this between threads: volatile sig_atomic_t says nothing about ordering or visibility across CPUs, so it is the right type for a handler talking to the thread it interrupted and the wrong one for two threads talking to each other.
Where the flag has to serve both - a handler setting it and a second thread reading it - the type that covers both cases is a lock-free atomic. C11 permits a handler to access an atomic object when atomic_is_lock_free is true for it, and ATOMIC_INT_LOCK_FREE == 2 says so at compile time for atomic_int on the usual targets; the lock-free part is the condition, because an atomic the implementation backs with a mutex puts the handler right back into the deadlock this page opens with. sig_atomic_t stays the simpler and more portable choice for the plain handler-to-main-loop flag, which is what this example is - CERT SIG31-C names both.
Using Only Async-Signal-Safe Functions
// SECURE - write() is async-signal-safe
#include <errno.h>
#include <unistd.h>
static void handler(int sig) {
int saved_errno = errno; // write() can set errno; the interrupted code owns it
const char msg[] = "Signal received\n";
ssize_t n = write(STDERR_FILENO, msg, sizeof(msg) - 1); // safe system call
(void)n;
errno = saved_errno;
_exit(1); // use _exit, not exit
}
Why this works: write() is a raw system call with no buffering, locks, or internal state - POSIX guarantees it's async-signal-safe. _exit() terminates immediately without running cleanup handlers or flushing stdio buffers (which would be unsafe), while exit() performs cleanup that might call non-reentrant code.
The errno save and restore is the part that gets left out, and it is a requirement rather than a nicety: signal-safety(7) grants that reading and writing errno is safe "provided that the signal handler saves errno on entry and restores its value before returning". Without it a handler that runs between a failed call and its if (errno == ...) replaces the value that code was about to read - a corruption with no crash and no bad pointer to find it by. It applies to any handler calling anything that can fail, which is every handler that calls a system call at all. This one exits, so it could skip the restore; handlers that return cannot, and writing it the same way in both is how it stops being forgotten.
Blocking Signals During Critical Sections
// SECURE - block signals to protect a critical section
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
sigprocmask(SIG_BLOCK, &set, &oldset);
modify_shared_data();
update_complex_structure();
sigprocmask(SIG_SETMASK, &oldset, NULL);
Why this works: sigprocmask() prevents the specified signals from being delivered during the critical section, creating an execution window where multi-step operations complete without interruption. Restoring oldset rather than unblocking outright means this nests correctly inside a caller that had already blocked something. 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.
Two limits decide whether this is the right tool. A standard signal that arrives while blocked is delivered once when the mask is restored, however many times it was sent - signal(7): "if multiple instances of a standard signal are generated while that signal is blocked, then only one instance of the signal is marked as pending" - so a critical section that blocks SIGCHLD across three exiting children gets one SIGCHLD afterwards, and the handler has to reap in a loop rather than assume one signal per child. Only the real-time signals (SIGRTMIN to SIGRTMAX) queue. And sigprocmask() is unspecified in a multithreaded process: use pthread_sigmask() there, which has the same signature and defined behaviour, and remember that a mask is per-thread - blocking a signal in one thread leaves any other thread eligible to receive it.
Self-Pipe Trick for Event Loop Integration
// SECURE - integrate signals with select/poll
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/select.h>
#include <unistd.h>
static int signal_pipe[2];
static void handler(int sig) {
int saved_errno = errno;
unsigned char byte = (unsigned char)sig;
ssize_t n = write(signal_pipe[1], &byte, 1); // async-safe, and cannot block
(void)n; // a full pipe drops the byte; see below
errno = saved_errno;
}
static void set_nonblocking(int fd) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}
int main(void) {
if (pipe(signal_pipe) == -1) return 1;
set_nonblocking(signal_pipe[0]);
set_nonblocking(signal_pipe[1]); // the write end matters most - see below
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGUSR1, &sa, NULL);
fd_set readfds;
while (1) {
FD_ZERO(&readfds);
FD_SET(signal_pipe[0], &readfds);
if (select(signal_pipe[0] + 1, &readfds, NULL, NULL, NULL) == -1) {
if (errno == EINTR) continue; // SA_RESTART does not cover select()
break;
}
if (FD_ISSET(signal_pipe[0], &readfds)) {
unsigned char byte;
while (read(signal_pipe[0], &byte, 1) == 1) { // drain; EAGAIN ends the loop
handle_signal(byte); // processed in normal context
}
}
}
}
Why this works: The self-pipe trick converts asynchronous signals into file-descriptor events handled by select/poll/epoll. The handler only calls write() (async-signal-safe) and returns immediately; the main event loop processes the signal once the pipe becomes readable, in a context where every function is safe to call. This pattern is used by production systems like Nginx and Redis because it's both safe and integrates cleanly with existing event-driven code.
Three details are what make it safe rather than merely tidy, and all three are omissions rather than mistakes, so they survive review:
- The write end must be non-blocking. A pipe holds a finite amount of unread data. If signals arrive faster than the loop drains them - which an attacker who can send them controls - a blocking
write()inside the handler waits for a reader that cannot run until the handler returns, and the process hangs. Non-blocking turns that into a dropped byte, which is the right trade: the loop only needs to learn that a signal arrived, and it drains everything pending in one pass. errnois saved and restored, for the reason given in the previous pattern -write()can set it, and the interrupted code may be about to read it.select()must handleEINTRitself.SA_RESTARTrestarts an interruptedread()orwrite(), andselect()andpoll()are on the list of calls it never restarts. Without the check the loop falls through toFD_ISSETon a descriptor set the failedselect()left undefined.
Testing
- Send signals during execution (
kill -SIGNAL $PID) and under stress (rapid repeated signals) to expose timing-dependent bugs. Send two different signals that share a handler, back to back, which is the case a single-signal loop never reaches. - Build with ThreadSanitizer (
-fsanitize=thread) for the async-signal-safety report, not for race detection. TSan'sreport_signal_unsafe(on by default) reports "violations of async signal-safety (e.g.malloc()call from a signal handler)", which is exactly the defect on this page. Its data-race detection will not find it: the handler runs on the thread it interrupted, so TSan sees one thread and has no second one to report a race against. - Review every registered handler for calls to non-async-signal-safe functions - static analysis tools can flag most of them. Check the transitive calls too: the handler that calls your own
log_error()is unsafe if that function eventually reachesprintf. - Assert the flag-only handler actually works with optimization on, at the level the release build uses. A missing
volatileis the case where an unoptimized build and an optimized one legitimately differ - at-O0the loop reloads the flag each time and the program appears correct - so a test built without optimization says nothing about the shipped binary.