写在前面
经历了ISCC和二十余篇报告的终极折磨,我终于……还是没解放
课程设计、期末考试、武术表演、CISCN……甚至还是AWD
我™真想给那屎忽的来一发超解口牙!我要把那可恶排班,可恶出题人,锁在地上吃我登龙口牙!!!杀!杀!杀!!!
害,算了,让我们收拾收拾心情,看看接下来的内容:
进程控制
这把的CISCN初赛,有一道pwn用上了一个恶心玩意,那就是fork。简单来讲,这玩意用来克隆一个与父进程在代码和堆栈等层面完全一致,也就是上回提到的0-3G的用户层部分完全一致,甚至连哪个变量的值是什么都一样,但在pid(每个进程的运行时唯一编号)、内核映射等方面不大一样的子进程(具体可以看享受生活 的文章)。那道题目中,程序在while时通过fork创建子进程回传,再配合PIE地址随机化把没有做过多少pwn的我整的晕头转向。
当然,fork可不单单是出题人用来恶心人的道具,在错误处理、进程控制等方面,fork都有重要应用。实际上,你的linux shell也在用fork。当你打算打开一个程序,shell通过fork创建一个新的子进程。而这些就是我们接下来要讲的内容:
fork的作用机理
以程序员角度,一个进程有三种状态,运行(Running,即在自己的逻辑控制流上,正在或等待执行的状态)、暂停(Stopped,进程被挂起,暂时不会被调度或执行)和终止(Terminated,进程不再被执行)。其中,暂停在进程收到 SIGSTOP / SIGSTEP / SIGTTIN / SIGTTOU 信号时发生,而终止则在收到对应信号、主程序返回或者调用exit函数时发生。
fork的定义如下:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
返回值在linux中定义为int,当子进程成功创建,fork在子进程中返回0,在父进程中返回子进程的PID。若子进程创建不成功返回-1。故正常运转的fork调用一次时会返回两次,一次子进程,一次父进程。比如如下代码:
pid = fork();
if (pid == 0){
printf("child detected\n");
exit();
}
printf("pid: %d\n", pid);
exit();
在子进程中,fork的返回值为0,故会输出child detected
;在父进程中,fork的返回值为子进程的pid,故会输出pid: 2
之类的东西。两个进程独立并发运行,且先后顺序不定,取决于你的操作系统。
如上,子进程相对于父进程,除了用户层分配的三个G是相同的之外,地址空间在构成上也是完全相同的,但两个空间相互独立。并且,它们调用的文件都是相同的。
以及,多次调用fork而不清理进程的话,会使得进程指数级增长,2fork出4进程,3fork出8进程,etc。为了你的电脑,请不要偷懒一次性多开fork,而没写回收的函数,要不然……
为什么,以及如何回收进程呢
在Linux中正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。子进程的结束和父进程的运行是一个异步过程,也就是说,父进程永远无法预知到子进程会在什么时候结束。当一个进程完成工作终止后,它的父进程需要调用
wait()
或waitpid()
系统调用来获取子进程的终止状态。——摘自JunChow520的文章
子进程的结束时间相对于父进程是不定的,正常来讲,子进程在父进程结束之前结束,并获得子进程状态,而倘若父进程先于子进程结束,那么这个子进程就成为了没父亲的孤儿进程,系统会安排init进程称为它的养父进程,其pid为1,是所有进程的祖先,亦是所有孤儿进程的“托儿所”。由于计算机系统并不像某些dfzf一样拖泥带水,这些孤儿进程一般都会得到妥善回收,所以这种情况是不需要我们处理的。
但僵死进程就不那么好办了。通常来讲,我们在父进程中会使用wait或者waitpid来回收,或者在父进程因为某些问题挂掉的时候让init领养,可一旦你在父进程中忘记回收,而你的子进程恰好在父进程运行的逻辑控制流中终止了,那它就成为了停止,但依旧在内核空间存在的僵死进程,即子进程残留资源PCB存放于内核中。
这些残留资源会一直存在于内核,并且占用相应的内存号,要是没有其它进程用wait或者waitpid发现它们,可能它们就一直呆在那里,直到你内存爆炸或者系统再也无法生成新的进程号,然后你的电脑就在寄掉的边缘徘徊。你也不能kill它们,因为它们已经是终止的进程了,就整一个处于死了也没死的状态,故又名僵尸进程,让计算机避之不及。
但好就好在,我们还有wait和waitpid可以利用。
wait函数可以阻塞当前进程,等待子进程退出,之后进行子进程的回收并获取子进程终止状态及原因。子进程的终止状态由wait.h
库的几个宏定义:
WIFEXITED(status) :全称wait if exited,表示判断是否是正常退出的,若非0则表示进程正常退出
WEXITSTATUS(status) :全称wait exit status,表示退出状态,当子进程正常退出,即WIFEXITED(status)为真则获取进程退出的状态,即exit的参数。
WIFSIGNALED(status) :全称wait if signaled,若非0则表示进程异常终止
WTERMSIG(status):全称wait term signal,若WIFSIGNALED为真则获得使进程终止的那个信号的编号。
WIFSTOPPED(status):全称wait if stopped进程处于暂停状态,若非0则表示进程处于暂停状态
WSTOPSIG(status):为真则使用宏WSTOPSIG(status)以获取使进程暂停的那个信号的编号。
WIFCONTINUED(status):为真则表示进程暂停后已经继续运行
waitpid则更加灵活且具有指向性,可以指定pid进程清理且不带有进程阻塞。若pid小于-1表示回收指定进程组中的任意子进程;若pid等于0表示回收和当前调用waitpid一个组的所有子进程;若pid等于-1表示回收任意子进程,作用相当于wait函数;若pid大于0表示回收指定进程ID的子进程。
Linux中系统调用的错误都存储于 errno中,errno由操作系统维护,存储就近发生的错误,即下一次的错误码会覆盖掉上一次的错误。当调用进程没有子进程,则waitpid返回-1,并且设置errno为ECHILD。若waitpid被一个信号中断,则返回-1,并设置errno为EINTR。详细的errno状态列表可以参考Jimmy_Nie的文章,这里不再赘述。
加载并运行程序
fork可以克隆一个进程,但我们怎么在进程运行的过程中在打开一个新的程序呢?这就要用到execve了。execve函数在当前进程的上下文中加载并运行一个新程序,但是莫得返回值。execve的定义如下:
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
filename就是你要运行的程序名,argv表示执行这个程序需要的参数列表,envp表示环境变量列表。
关于argv,最直观的,你使用linux或者windows的shell执行某些程序或指令的时候,你在后面加上的一系列诸如-o xxx
或者--update
之类的东西就是这个程序运行需要的参数。argv[0]
按照惯例是你的可执行文件或程序的名字,比方说我们需要在kali上修复并更新系统上的软件,那么我们一般会用(假设你已经是root权限了):
apt-get update --fix-missing
此时,argv内容如下,argc表参数个数(严格意义来讲是argv中非空指针的数量),argv以NULL结尾:
argv[0] = "apt-get"
argv[1] = "update"
argv[argc-1] = "--fix-missing"
argv[argc] = NULL
envp和argv一样都是尾部为NULL的指针数组,但从0开始包含当前的环境变量信息,如文件路径pwd,用户名,系统版本等。环境变量及其打印可以参考deniece1的文章。
加载
当我们使用shell运行程序的时候,以linux shell为例,我们在输入执行命令后,execve首先会调用一个驻留在存储器中的加载器(loader),这个加载器会先读取可执行文件的头,校验它的正确性,接着将其中的代码和数据从磁盘复制到内存中,接着通过跳转到程序的第一条指令或者入口点来运行该程序(也就是把PC定位到第一条指令的地址),这个过程叫做加载。
从细节上,上一节其实对地址空间的描述还不够细致,事实上,地址空间从代码段开始的0x400000,还有读/写段、运行时堆、共享库的内存映射区域和用户栈(并且数据与代码间为了.data段的前后对齐而留有空隙)。用户栈从最大合法用户地址,也就是上一回提到的那个第三个G的尾巴开始,向较小地址反向拓展。
接下来可以参考第一回的内容,在程序header的引导下,加载器调用mmap进行映射,具体而言是一种“按需分页”的方法,不会一下子将所有的代码段以及数据段的数据加载到内存,而是通过缺页中断(当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断)来实现懒加载,而这些被分页的程序段就叫做chunk。
加载器接下来会让PC指向程序的start
,start
又指向系统启动函数__libc_start_main
,该函数定义在系统库libc.so
中,以初始化执行环境并调用main函数进行处理,一定情况下需要返回信息给内核。__libc_start_main
的严格定义如下:
int __libc_start_main(int *(main) (int, char * *, char * *), int argc, char * * ubp_av, void (*init) (void), void (*fini) (void), void (*rtld_fini) (void), void (* stack_end));
运行
接下来,启动代码设置栈,并将控制权和变量传递给用户层的,也就是程序的主函数main。堆栈的结构自栈底到栈顶依次是:环境变量、命令行、envp、argv、libc_start_main的栈帧、main未来的栈帧。
main的定义比较简单:
int main(int argc,char **argv, char **envp);
接下来,程序按照既定顺序执行代码。当程序的主函数结束,__libc_start_main
将控制传递给exit,结束进程。
不过从理论上,加载器本身作为一个程序,也需要被shell通过execve调用,按这么说,execve调用加载器,加载器需要execve启动,然后要加载器加载加载器,接下来就是加载器加载加载加载器的加载器……
好了,不开玩笑了。事实上,因为是execve调用的加载器,所以你单独使用execve还是不会发生上述的套娃,就算在进程中使用execve调用加载器,也只会发生加载器加载加载器的情况,点到为止。
fork + execve
然而,光使用execve还不够,因为execve是在当前进程的上下文中打开相应的程序,而非新创建一个进程:
system是在单独的进程中执行命令,完了还会回到你的程序中。而exec函数是直接在你的进程中执行新的程序,新的程序会把你的程序覆盖,除非调用出错,否则你再也回不到exec后面的代码,就是说你的程序就变成了exec调用的那个程序了。
——摘自挨踢的小胖的文章
因此,我们往往需要利用fork+execve的形式来创建一个全新的进程。一个简单的自定义进程创建大概长这样:
pid = fork();
if (pid < 0) exit(-1);
else if (pid == 0)execve([your progress], [argv], NULL);
else{
wait();
exit(0);
}
到此为止,我们终于把程序的运行机理,进程的创建机理给深入分析了一番。至于接下来的内容嘛……还是那句话: