Linux fork()、exec() Hook Risk、Design-Principle In Multi-Threadeed Program
目录
1. Linux exec指令执行监控Hook方案 2. 在"Multi-Threadeed Program"环境中调用fork存在的风险 3. Fork When Multi-Threadeed Program的安全设计原则 4. Fork When Multi-Threadeed Program Deaklock Demo Code
1. Linux exec指令执行监控Hook方案
1. 基于LD_PRELOAD技术的glibc API劫持Hook技术 1) 优点: 位于Ring3应用层,基于Linux原生提供的LD_PRELOAD共享库符号加载调试技术,具有很好的兼容性和稳定性 2) 缺点: LD_PRELOAD技术的核心是SO加载劫持注入,只要在安装了Hook模块之后启动的程序才能被Hook捕获到,系统先于Hook模块启动的常驻进程无法被捕获到,这对于在持续运行的业务服务器上部署入侵检测系统是非常不利的 2. 基于ptrace()进程注入调试技术+内核模块提供进程启动事件的通知机制 1) 优点: 能灵活控制需要Hook的进程,可以实现一套Ring3的Whitelist机制,对不需要Hook的进程予以放行 2) 缺点: 进程创建的事件通知和Hook模块的处理逻辑之间无法实现串行,当发生瞬发进程执行的时候,有可能发生进程创建事件通知到ptrace注入模块的时候,进程已经exit退出了 3. 基于Kernel Inline Hook对指定系统调用进行审计监控 4. 基于0x80中断劫持system_call->sys_call_table进行系统调用Hook 5. 基于Linux内核机制kprobe机制(kprobes, jprobe和kretprobe)进行系统调用Hook 6. LSM(linux security module)钩子技术(linux原生机制)
Relevant Link:
http://www.cnblogs.com/LittleHann/p/3854977.html
2. 在"Multi-Threadeed Program"环境中调用fork存在的风险
0x1: Fork()的实现原理
关于fork()系统调用的实现原理和内核源码分析,请参阅另一篇文章
http://www.cnblogs.com/LittleHann/p/3853854.html //搜索:4. sys_execve()函数
在整个过程中,我们关注以下几个重点(完整的详细过程请参阅另一篇文章)
1. 子进程复制了父进程的内存页 1) 数据段: 静态变量、字符串等数据 2) 代码段: 程序代码 3) 堆栈区: 保存调用时可能正处于"中间状态"的变量运算结果 2. 子进程复制了父进程的文件描述符数组 1) 通过current->struct files_struct *files可以寻址、并操作当前进程打开的文件 3. 在多线程执行的情况下调用fork()函数,仅会将发起调用的线程复制到子进程中 1) 如果父进程包含多个线程,父进程(主线程)在调用fork的时候,其他线程均在子进程中"立即停止并消失",并且不会为这些线程调用清理函数以及针对线程局部存储变量的析构函数
0x2: 共享区、锁(Critical sections, mutexes)中存在的风险
虽然只将发起fork()调用的线程复制到子进程中,但全局变量的状态以及所有的pthreads对象(如互斥量、条件变量等)都会在子进程中得以保留,这就造成一个危险的问题
关于fork when multi-thread risk问题,我们通过一个code case来逐步学习
#include <pthread.h> void* doit() { static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock(&mutex); struct timespec ts = {10, 0}; nanosleep(&ts, 0); // 10秒sleep pthread_mutex_unlock(&mutex); return; } int main(void) { pthread_t t; //启动子进程,并在子进程中试图锁定一个全局锁 pthread_create(&t, 0, doit, 0); if (fork() == 0) { //子进程 doit(); return 0; } // 等待子线程结束 pthread_join(t, 0); } /* gcc -Wall -o fork fork.c -lpthread ./fork */
分析一下可能产生死锁的原因
以下是说明死锁的理由:
1. 子进程只拷贝了调用fork的主线程(进程)的 2. 在内存区域里,静态变量mutex的内存会被拷贝到子进程里。而且,父进程里即使存在多个线程,但它们也不会被继承到子进程里 3. 父进程产生的线程里的doit()先执行. 1) doit执行的时候会给互斥体变量mutex加锁. 2) mutex变量的内容会原样拷贝到fork出来的子进程中(在此之前,mutex变量的内容已经被父进程产生的线程改写成锁定状态) 4. 父进程fork出的子进程再次调用doit的时候,在锁定互斥体mutex的时候会发现它已经被加锁,所以就只能一直等待,直到拥有该互斥体的进程释放它(但是能够解锁这个mutex的线程并没有通过fork复制到子进程中,sleep 10s模拟了这个情况). 5. 父进程的线程的doit执行完成之前会把自己的mutex释放,但是因为copy on write的关系(父子进程在这个互锁的期间有很高概率对内存进行了写草走),所以这个时候父进程的mutex和子进程里的mutex已经是两份内存。所以即使父进程释放了mutex锁也不会对子进程里的mutex造成什么影响,子进程就处于无限等待状态中
0x3: 文件操作中存在的风险
由于子进程会将父进程的大多数数据拷贝一份,这样在文件操作中就意味着子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于dup()函数调用,因此父、子进程中对应的文件描述符均指向相同的打开的文件句柄,而且打开的文件句柄包含着当前文件的偏移量以及文件状态标志,所以在父子进程中处理文件时很有可能发生对同一个文件的同时操作,导致文件内容出现混乱或者别的问题
0x4: 数据一致性运算中存在的风险
全局变量的状态也可能处于不一致的状态,因为对其更新的操作只做到了一半对应的线程就消失了,或者父子进程同时对同一个数据进行了操作,通常情况下这和锁风险是同时发生的
0x5: 子进程中引用指针引发的错误
因为并未执行清理函数和针对线程局部存储数据的析构函数,所以多线程情况下可能会导致子进程的内存泄露。另外,子进程中的线程可能无法访问(父进程中)由其他线程所创建的线程局部存储变量,因为子进程没有任何相应的引用指针
Relevant Link:
http://www.linuxprogrammingblog.com/threads-and-fork-think-twice-before-using-them http://mail-index.netbsd.org/tech-userlevel/2014/06/23/msg008609.html https://sourceware.org/bugzilla/show_bug.cgi?id=4737 https://bugzilla.redhat.com/show_bug.cgi?id=241665
3. Fork When Multi-Threadeed Program的安全设计原则
0x1: 在调用fork()系统调用之后需要避免使用的函数
在多线程里因为fork而引起问题的函数,我们把它叫做"fork-unsafe函数"。反之,不能引起问题的函数叫做"fork-safe函数",典型的"fork-unsafe函数"例如
1. malloc: malloc函数就是一个维持自身固有mutex的典型例子,malloc()在访问全局状态时会加锁,因此通常情况下它是fork-unsafe的。依赖于malloc函数的函数有很多,例如 1) printf() 2) dlsym() 它们也是变成fork-unsafe 2. stdio functions like printf() - this is required by the standard. 1) 因为其他线程可能恰好持有stdout/stderr的锁 3. syslog() 4. 任何可能分配或释放内存的函数,包括 1) new 2) map::insert() 3) snprintf() 5. 任何pthreads函数 1) 不能用pthread_cond_signal()去通知父进程,只能通过读写pipe(2)来同步 6. 除了man 7 signal中明确列出的"signal安全"函数"之外"的任何函数
0x2: 在fork之后调用"异步信号安全函数"
fork()函数被调用之后,子进程就相当于处于signal handler之中,此时就不能调用线程安全的函数(用锁机制实现安全的函数),因为线程安全函数中的锁操作很可能会导致死锁(deadlock),除非函数是可重入的,而只能调用异步信号安全(async-signal-safe)的函数,调用异步信号安全函数是规格标准
对于那些必须执行fork(),而其后又无exec()紧随其后的程序来说(例如入侵检测Hook程序),pthreads API提供了一种机制:利用函数pthread_atfork()来创建fork()处理函数 pthread_atfork()声明如下
/* Upon successful completion, pthread_atfork() shall return a value of zero; otherwise, an error number shall be returned to indicate the error. 1. prepare: 新子进程产生之前被调用 2. parent: 新子进程产生之后在父进程被调用 3. child: 新子进程产生之后,在子进程被调用 一些典型的应用场景 1. when in the prepare handler mutexes are locked, in the parent handler unlocked and in the child handler reinitialized 2. 在子进程产生之后,父进程关闭不需要操作的文件句柄 */ int pthread_atfork (void (*prepare) (void), void (*parent) (void), void (*child) (void));
该函数的作用就是往进程中注册三个函数,以便在不同的阶段调用,有了这三个参数,我们就可以在对应的函数中加入对应的处理功能。同时需要注意的是
1. 每次调用pthread_atfork()函数会将prepare添加到一个函数列表中,创建子进程之前会(按与注册次序相反的顺序)自动执行该函数列表中函数 2. parent与child也会被添加到一个函数列表中,在fork()返回前,分别在父子进程中自动执行(按注册的顺序)
0x3: 在多线程应用中不使用fork
在程序中fork()与多线程的协作性很差,这是POSIX系列操作系统的历史包袱。因为长期以来程序都是单线程的,fork()运转正常。当20世纪90年代初期引入线程之后,fork()的适用范围就大为缩小了
在多线程应用中用pthread_create来代替fork,这是一个比较好的安全实践
0x4: 在fork之后立刻调用exec
子进程在创建后,是写时复制的,也就是子进程刚创建时,与父进程一样的副本,当exce后,那么老的地址空间被丢弃,而被新的exec的命令的内存的印像覆盖了进程的内存空间(一切和进程相关的资源都会被重置),所以锁的状态无关紧要了。
请注意这里使用的"马上"这个词.即使exec前仅仅只是调用一回printf("I’m child process")
Relevant Link:
http://blog.csdn.net/swgsunhj/article/details/8871758 http://blog.csdn.net/cywosp/article/details/27316803 http://blog.chinaunix.net/uid-26885237-id-3210394.html
4. Fork When Multi-Threadeed Program Deaklock Demo Code
如果在并发多线程(多进程)的场景下采用LD_PRELOAD技术对glibc的API调用进行Hook,造成的直接结果如下
1. 系统中可能存在这中代码 while(1000 times) { if(pid = fork() = 0) { //do some calculate execve(new programe); } } 2. 使用LD_PRELOAD技术对execve进行Hook之后,相当于产生了"fork when multi-thread programe"的场景,Hook模块的代码延长了fork和execve之间的代码执行时间 1) Hook模块中基本都有对内存的写入操作,也就是说在这种情况下,几乎100%会发生copy on write事件,进程创建的内存复制开销大大增加了 2) 子进程调用Hooked execve函数中,调用了dlsym,dlsym中包含malloc的调用,这有可能导致deadlock 3) hook延迟了子进程fork和execve之间的代码执行时间,同时有更大的可能性出现父子进程共同操作同一个fs、锁等资源
0x1: Code Example
test.c
#include <stdio.h> int main(int argc, char* argv[]) { printf("hello world\n"); return 0; } //gcc test.c -o test
fork.c
#include <pthread.h> #include <stdio.h> #include <unistd.h> #include <malloc.h> void* doit() { static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock(&mutex); struct timespec ts = {10, 0}; nanosleep(&ts, 0); // 10秒sleep pthread_mutex_unlock(&mutex); } int main(int argc, char *argv[]) { int times; pthread_t t; char *newargv[] = { NULL, "hello", "world", NULL }; char *newenviron[] = { NULL }; int* p; if (argc != 2) { fprintf(stderr, "Usage: %s <file-to-exec>\n", argv[0]); return 0; } newargv[0] = argv[1]; //启动子进程,并在子进程中锁定一个全局锁 //pthread_create(&t, 0, doit, 0); //父进程调用malloc p = (int*)malloc(sizeof(int)*128); if(NULL == (int*)p) { perror("error..."); return 0; } for (times = 0; times < 50000; times++) { //fork a duplicate process pid_t child_pid = fork(); //child if (child_pid == 0) { //doit(); execve(argv[1], newargv, newenviron); } } free(p); p = NULL; return 0; } /* gcc -Wall -o fork fork.c -lpthread ./fork test */
Copyright (c) 2014 LittleHann All rights reserved