2001年5月,Michal Zalewski在他的论文《Delivering Signals for Fun and Profit》中展示了一个令人不安的例子:一个看似完全正确的信号处理函数,仅仅因为在错误的时间点被调用,就能让整个进程陷入不可恢复的死锁状态。没有内存泄漏,没有竞态条件,没有缓冲区溢出——程序只是停在那里,永远不再响应。
问题的根源在于一个被广泛低估的概念:async-signal-safe(异步信号安全)。这个名字听起来像是某种性能优化的术语,但它实际上关乎程序能否正常运行。如果你曾经在信号处理函数中调用过printf、malloc、或者任何看起来"无害"的标准库函数,你可能已经在生产环境中埋下了一颗定时炸弹。
一个看似无害的死锁
考虑这个简化但完全真实的场景:
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *logMessage;
void handler(int sigNum) {
printf("Received signal %d: %s\n", sigNum, logMessage);
free(logMessage);
exit(0);
}
int main(int argc, char* argv[]) {
logMessage = strdup(argv[1]);
signal(SIGHUP, handler);
signal(SIGTERM, handler);
pause();
}
这段代码为SIGHUP和SIGTERM注册了同一个处理函数。当信号到达时,函数打印日志、释放内存、然后退出。看起来毫无问题。
但如果在main函数的某个时刻,程序正在执行malloc或free,而此时信号到达,处理函数又调用了printf(内部会调用malloc)或free——程序将永远卡住。
CWE-364(Signal Handler Race Condition)正是描述这类问题的安全漏洞分类。MITRE的官方描述写道:“攻击者可能利用信号处理函数中的竞态条件,导致程序状态被破坏,可能引发拒绝服务甚至代码执行。”
为什么信号处理函数如此特殊?
要理解这个问题的本质,必须先理解信号的处理机制。
当内核向进程发送信号时,它会在进程从内核态返回用户态的瞬间检查是否有待处理的信号。如果有,内核会:
- 保存当前的执行上下文(寄存器、栈指针等)到用户栈
- 构造信号处理函数的栈帧
- 修改返回地址,使进程返回用户态后直接跳转到信号处理函数
这意味着信号处理函数不是通过普通的函数调用进入的,而是通过内核强制修改执行流实现的。更关键的是,信号可以在任何用户态指令执行完毕后到达——包括正在修改全局数据结构的指令。
这就是"异步"的真正含义:你无法预测信号会在哪个精确时刻到达。
malloc的内部世界:锁与Arena
malloc不是线程安全的吗?为什么在信号处理函数中调用会导致死锁?
答案在于"线程安全"和"异步信号安全"是两个完全不同的概念。
glibc的malloc实现(ptmalloc)使用arena结构来管理内存。每个arena有自己的mutex锁,线程在进行分配或释放操作时必须先获取这个锁。这种设计确保了多线程环境下的安全性:不同线程如果访问不同的arena,可以并行执行;如果访问同一个arena,锁会串行化操作。
但信号处理函数打乱了这个模型。
假设主程序正在执行malloc,已经获取了arena的锁,正在修改链表指针。此时信号到达,内核将执行流切换到信号处理函数。如果处理函数内部调用printf,而printf需要分配缓冲区,调用malloc——此时,malloc尝试获取同一个arena的锁,但锁已经被"自己"持有。
在正常的多线程场景下,这是可以处理的:线程A持有锁,线程B等待。但在信号处理的场景下,持有锁的代码被"暂停"了,而执行信号处理函数的代码(注意:是同一个线程)尝试再次获取同一个锁。这是典型的自死锁场景。
glibc的wiki明确写道:“所有其他操作都需要线程获取arena的锁。对这个mutex的竞争是创建多个arena的原因。”
stdio缓冲区:另一种灾难
即使不涉及malloc,printf家族函数仍然有另一个陷阱:stdio缓冲区。
每个FILE结构体内部维护着一个缓冲区和相关状态变量:当前位置指针、剩余字节数、缓冲区状态等。当程序调用printf时:
- 函数检查缓冲区是否有足够空间
- 如果有,直接写入缓冲区
- 如果没有,先刷新缓冲区,再写入
问题在于,这个过程不是原子的。如果信号在缓冲区状态被部分更新时到达,信号处理函数中再次调用printf会看到不一致的内部状态——缓冲区指针可能指向错误的位置,计数器可能与实际内容不匹配。
Linux man page的signal-safety(7)页面直接指出了这个问题:“当对文件执行缓冲I/O时,stdio函数必须维护一个静态分配的数据缓冲区以及相关的计数器和索引……假设主程序正在调用stdio函数(如printf)的过程中,缓冲区和相关变量已被部分更新。此时,如果程序被信号处理函数中断,而处理函数也调用了printf,第二次printf调用将操作不一致的数据,产生不可预测的结果。”
可重入性 vs 异步信号安全
这两个概念经常被混淆,但它们有微妙但重要的区别。
可重入(Reentrant):一个函数可以在其执行过程中被中断,然后被再次调用(包括由同一中断处理程序调用),而不会产生错误。关键在于:可重入函数不依赖任何静态或全局状态,不修改自己的代码,不调用任何不可重入函数。
异步信号安全(Async-Signal-Safe):一个函数可以安全地从信号处理函数中调用。POSIX定义了两类:
- 天生可重入的函数(如
write、_exit) - 虽然不是可重入的,但在信号处理上下文中表现"原子"的函数
Stack Overflow上的一个高赞回答精确区分了这两者:“可重入函数必须只操作局部数据……线程安全只要求代码执行可以被任意交错。”
信号处理函数的限制比多线程代码更严格,因为信号处理函数会停止当前代码的执行,而不是与其并发。这意味着如果一个锁被持有,持有它的代码被暂停了,信号处理函数无法等待锁释放——因为释放锁的代码无法运行。
POSIX的安全函数列表
POSIX.1-2017标准明确定义了异步信号安全函数列表。这份列表出奇地短,主要包括:
系统调用包装器:_exit、read、write、close、fork、exec系列、wait、kill等
信号操作函数:signal、sigaction、sigprocmask、sigpending、sigsuspend等
简单内存/字符串操作:memcpy、memmove、memset、strlen、strcpy、strcat等
文件描述符操作:open、creat、dup、dup2、pipe、fcntl等
值得注意的是不在列表中的函数:
| 函数 | 不安全的原因 |
|---|---|
printf/fprintf/sprintf |
使用stdio缓冲区,内部可能调用malloc |
malloc/free/realloc |
使用arena锁,修改全局数据结构 |
strerror |
可能使用静态缓冲区 |
strtok |
维护内部状态 |
fopen/fclose |
使用stdio和malloc |
popen/system |
使用fork和shell |
syslog |
内部使用锁 |
exit |
调用atexit处理函数,刷新stdio缓冲区 |
Michael Kerrisk维护的Linux man-pages项目在signal-safety(7)中给出了完整列表,约140个函数。相比C标准库的上千个函数,这是极其有限的子集。
sig_atomic_t:唯一的安全共享变量
如果信号处理函数只能调用那么有限的函数,那它到底能做什么?
POSIX给出的答案是:设置一个flag,然后返回。
#include <signal.h>
#include <stdatomic.h>
volatile sig_atomic_t signal_received = 0;
void handler(int sig) {
signal_received = 1;
}
int main(void) {
signal(SIGINT, handler);
while (!signal_received) {
// 主循环工作
}
// 处理信号
}
sig_atomic_t是C标准定义的一种整数类型,保证对其的读写操作是原子的——即使被信号中断,读到的值要么是写入前的值,要么是写入后的值,绝不会是中间状态。volatile关键字告诉编译器不要优化对这个变量的访问,因为它的值可能在意想不到的时刻被修改。
但这里有一个限制:sig_atomic_t的大小是实现定义的,在某些平台上可能只有8位或16位,不足以存储复杂的信号信息。C++11提供了std::atomic作为更现代的替代,但严格来说,C++标准对信号处理中的原子操作保证比C标准更复杂。
pselect与竞态条件
信号处理的另一个经典问题是竞态条件。考虑这个场景:
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, NULL);
// 信号被阻塞...
// 解除阻塞并等待
sigprocmask(SIG_UNBLOCK, &mask, NULL); // <-- 信号可能在这里到达!
select(nfds, &readfds, NULL, NULL, NULL); // 永远阻塞,错过了信号
问题在于,解除信号阻塞和进入select调用之间有一个时间窗口。如果信号在这个窗口内到达,处理函数会执行,但select尚未开始,不会返回EINTR。
这正是pselect被设计出来的原因。LWN.net的一篇文章详细解释了这个系统调用的价值:"pselect的主要区别在于它有一个信号掩码参数……内核执行的一系列步骤等价于原子性地执行以下系统调用:"
sigprocmask(SIG_SETMASK, &sigmask, &sigsaved);
select(nfds, &readfds, &writefds, &exceptfds, timeout);
sigprocmask(SIG_SETMASK, &sigsaved, NULL);
关键在于"原子性":信号只会在select真正开始等待后才被解除阻塞,消除了竞态窗口。
self-pipe技巧:事件循环的正确姿势
1990年左右,D. J. Bernstein发明了一个被称为"self-pipe trick"的技术,至今仍是处理信号的最佳实践之一。Richard Stevens在1992年的《Advanced Programming in the UNIX Environment》中提到了这个问题:“你无法安全地将select()或poll()与SIGCHLD(或其他信号)混合使用。”
self-pipe的核心思想是:
- 创建一个pipe
- 在信号处理函数中,向pipe的写端写入一个字节
- 在主事件循环中,将pipe的读端加入
select/poll的监控集合
int pipe_fd[2];
pipe(pipe_fd);
void handler(int sig) {
char c = sig;
write(pipe_fd[1], &c, 1); // write是async-signal-safe的
}
// 主循环
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(pipe_fd[0], &readfds);
while (1) {
select(pipe_fd[0] + 1, &readfds, NULL, NULL, NULL);
if (FD_ISSET(pipe_fd[0], &readfds)) {
char c;
read(pipe_fd[0], &c, 1);
// 现在可以安全地处理信号
}
}
为什么write是安全的?因为它是直接的系统调用,不依赖任何用户态缓冲区或锁。内核保证对pipe的写入是原子操作(对于小于PIPE_BUF大小的数据,通常是4096字节)。
Bernstein在他的文档中写道:“这个方法很有效:pipe是一个文件描述符,写入文件描述符是async-signal-safe的。”
signalfd:Linux特有的解决方案
Linux 2.6.22引入了signalfd系统调用,提供了一种更直接的机制:将信号转换为文件描述符上的可读事件。
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
int fd = signalfd(-1, &mask, SFD_NONBLOCK);
// 现在可以从fd读取信号信息
但这个API并非没有争议。一篇题为《signalfd is useless》的博客文章详细分析了它的问题:
- 信号合并:如果同一个信号在处理前多次到达,它们可能被合并,导致信息丢失
- 继承问题:信号掩码会被子进程继承,可能导致子进程行为异常
- 不改变信号传递机制:使用
signalfd仍需要阻塞信号,否则信号仍会按传统方式传递
文章的结论是:“唯一能干净处理信号的方法是避免POSIX API,而不是在其中工作。”
多线程环境中的信号
多线程程序中的信号处理更加复杂。POSIX规定:
- 信号处理函数是进程范围的:所有线程共享同一组信号处理设置
- 信号掩码是线程级别的:每个线程可以独立设置自己的信号掩码
- 信号传递是任意的:对于异步信号,内核可以选择任何一个没有阻塞该信号的线程来传递
这意味着,如果一个信号被多个线程注册了处理函数,且都没有阻塞该信号,信号会在哪一个线程中触发是不可预测的。
现代的最佳实践是:
- 创建一个专门的信号处理线程
- 在其他所有线程中阻塞所有异步信号
- 使用
sigwait或signalfd在专用线程中同步等待信号
// 主线程中阻塞所有信号
sigset_t set;
sigfillset(&set);
pthread_sigmask(SIG_BLOCK, &set, NULL);
// 创建信号处理线程
pthread_create(&thread, NULL, signal_thread, NULL);
void* signal_thread(void* arg) {
sigset_t set;
sigfillset(&set);
int sig;
while (1) {
sigwait(&set, &sig); // 同步等待
// 安全地处理信号
}
}
历史包袱:从不可靠信号到可靠信号
Unix信号机制有着悠久的历史,而这个历史本身就包含了设计缺陷的痕迹。
早期(Version 7 Unix及之前)的信号被称为"不可靠信号":信号处理函数执行一次后会自动重置为默认行为。如果程序想在后续信号中继续使用自定义处理函数,必须在处理函数内部重新注册自己。但这带来了一个窗口期:信号到达时,处理函数已执行但尚未重新注册,此时再收到信号会触发默认行为(通常是终止进程)。
BSD 4.x引入了"可靠信号",核心改进包括:
- 信号处理函数保持注册状态
- 引入信号掩码机制
- 支持
SA_RESTART标志自动重启被中断的系统调用
System V和BSD的差异最终在POSIX中得到统一,sigaction系统调用成为了标准接口。但历史的痕迹仍然存在——signal函数的行为在不同系统上可能不同,在某些系统上它可能使用不可靠信号语义。
Doug McIlroy——Unix管道的发明者——曾经这样评价信号:"signal()首先是为了支持SIGKILL;它并未声称为异步IPC提供可靠基础。sigaction()的复杂性证明了40年后异步性仍未被驯服。"
安全漏洞的现实案例
信号处理函数的安全问题并非纸上谈兵。CVE数据库中有多起与此相关的漏洞。
CWE-479(Signal Handler Use of a Non-reentrant Function)的描述中指出:“如果一个信号处理函数调用了不可重入函数,而信号中断了该函数的执行,程序状态可能被破坏。”
一个经典的攻击场景是:攻击者找到程序中信号处理函数调用free的地方,通过精确控制信号到达的时机,使free操作中断正在进行的另一个free操作,导致堆元数据被破坏,进而实现任意代码执行。
Python解释器也曾受此困扰。Python bug tracker的issue 24283记录了一个问题:“如果正在进行的I/O调用被信号中断,信号处理函数再次调用I/O栈,这种情况会被检测到并报告错误。“Python 3.5之后开始限制信号处理函数中可执行的操作。
实践建议
综合以上分析,以下是处理信号的实用建议:
第一原则:极简主义
信号处理函数应该尽可能短小。最安全的做法是只设置一个volatile sig_atomic_t标志,然后立即返回。所有复杂的逻辑都在主程序中处理。
第二原则:只使用async-signal-safe函数
如果你必须在信号处理函数中做更多事情,严格检查每个调用的函数是否在POSIX安全列表中。记住:printf、malloc、free、strerror、syslog都不安全。
第三原则:使用self-pipe或专用线程
对于事件驱动程序,使用self-pipe技巧将信号转换为文件描述符事件。对于多线程程序,创建专用信号处理线程,其他线程阻塞所有信号。
第四原则:避免信号处理函数中的资源操作
永远不要在信号处理函数中分配或释放内存、获取或释放锁、打开或关闭文件。这些操作依赖的内部状态可能被中断时破坏。
第五原则:理解信号合并
标准Unix信号可能被合并。如果同一个信号多次到达而尚未被处理,你可能只会收到一次通知。对于需要精确计数的场景(如统计SIGCHLD的子进程退出数),这是一个严重的限制。
结语:设计缺陷的遗产
Unix信号机制诞生于1970年代,那时的计算环境与今天截然不同。单处理器系统、没有线程、程序结构相对简单——在这样的环境下,信号作为"软件中断"是一个合理的设计。
但半个世纪后,信号机制的局限性变得明显:无法可靠地传递复杂数据、与多线程模型不兼容、严格限制了处理函数能执行的操作、容易引入难以调试的死锁和数据竞争。CWE数据库中专门为此设立了四个弱点条目,这在Unix API中是罕见的。
然而,信号机制不会消失。它深深嵌入在Unix/Linux的进程模型中,是Ctrl-C终止程序、kill命令发送信号、容器编排系统优雅关闭进程的基础设施。我们只能理解它的限制,并遵循最佳实践来规避陷阱。
回到开头的问题:为什么printf在信号处理函数中可能导致死锁?因为printf使用静态分配的stdio缓冲区,可能调用malloc获取更多内存,而malloc内部持有锁。如果信号打断了正在持有这个锁的代码,信号处理函数中的printf将永远等待一个永远不会释放的锁。
这不是printf的bug,也不是malloc的设计缺陷。这是信号异步本质与同步原语之间的根本冲突——一个在Unix诞生之初就已埋下的设计张力,至今仍在影响着我们写的每一行信号处理代码。
参考资料
-
Zalewski, M. (2001). Delivering Signals for Fun and Profit: Understanding, exploiting and preventing signal-handling related vulnerabilities. http://lcamtuf.coredump.cx/signals.txt
-
signal-safety(7) — Linux manual page. Michael Kerrisk. https://man7.org/linux/man-pages/man7/signal-safety.7.html
-
CWE-364: Signal Handler Race Condition. MITRE. https://cwe.mitre.org/data/definitions/364.html
-
MallocInternals. glibc Wiki. https://sourceware.org/glibc/wiki/MallocInternals
-
Bernstein, D. J. The self-pipe trick. https://cr.yp.to/docs/selfpipe.html
-
“The new pselect() system call”. LWN.net. https://lwn.net/Articles/176911/
-
“signalfd is useless”. https://ldpreload.com/blog/signalfd-is-useless
-
IEEE Std 1003.1-2017 (POSIX.1-2017). The Open Group Base Specifications Issue 7.
-
Stevens, W. R. (1992). Advanced Programming in the UNIX Environment. Addison-Wesley.
-
“Beyond Ctrl-C: The dark corners of Unix signal handling”. https://sunshowers.io/posts/beyond-ctrl-c-signals/
-
Python issue 24283: Print not safe in signal handlers. https://bugs.python.org/issue24283