之前在Linux内核中隐藏的兵法—无中生有一文中,我们探究了Linux中的进程创建,着重介绍了进程0和fork系统调用。本文我们将继续探究,另一个与进程创建相关的重要系统调用:execve。看看它又是葫芦里卖的什么药。
fork的子进程跟父进程跑着相同的程序,如果想要执行一个新的程序,那么就需要用到execve了。它在当前进程的上下文中加载并运行一个新的程序。
系统调用进入
为了使逻辑更加顺畅,我们还是选择从execve
系统调用入口开始看起。这部分其实在前一篇讲fork相关内容时已经涉及,不过因为还是存在一点差异,同时也为了方便读者阅读,在这里还是完整地介绍一遍。
我们从execve系统调用定义的地方开始看起
1 | _syscall3(int,execve,const char *,file,char **,argv,char **,envp) |
_syscall3
是个宏,内核定义了几个这样类似的宏,其中后面跟的那个数字表示参数的个数
1 |
这几个宏其实是内嵌了汇编的函数,只有一条int $0x80
指令,其中0x80是系统调用的中断号。对于_syscall3
有1个输出参数和4个输入参数。局部变量__res
作为输出参数绑定到eax寄存器上,用于接收返回值。输入参数__NR_execve
是系统调用的编号,每个系统调用都有一个独立的编号,同样绑定到寄存器eax上。其余3个输入参数a、b、c分别放到寄存器ebx、ecx、edx中。
int
指令执行之后,CPU去IDT中找到对应的中断描述符,因为系统调用是实现为系统门(特权级DPL为3的陷阱门),所以可以在用户态调用,门描述符中找到中断例程所在的段的选择符及段内偏移,跳转到那里。对于系统调用,就是跳转到下面system_call:
处。
此时进程已进入内核空间。system_call
开头这一段代码是所有系统调用通用的,它首先检查eax中的系统调用号是否超出范围,如正常就将段寄存器和通用寄存器压栈,然后重新设置ds、es为内核数据段,fs为用户数据段。最后通过系统调用号查表调用对应的处理函数。
1 | system_call: |
内核堆栈情况
我们来看看内核堆栈的情况。int
指令引发的CPU的中断过程会自动将用户态原堆栈段ss、堆栈指针esp、状态寄存器eflags、原用户代码段cs、原用户代码eip入栈(注意:这里是入栈到内核堆栈)。那么CPU为什么要将这些寄存器压栈?因为它得记得回去的路。就如同函数调用要把返回地址压栈一样,中断也需要将返回地址压栈。又因为内核代码段跟用户代码段是不同的,所以原用户态代码段寄存器也需要压栈,另外内核态和用户态特权级不同,是使用独立的堆栈,所以用户态的堆栈段寄存器ss和堆栈指针esp也要压栈。因此刚进入内核空间时,内核堆栈指针位于下图的esp0位置。
1 | HIGH +----------------------+ |
随后将段寄存器ds、es、fs和参数edx、ecx、ebx分别入栈。然后因为eax的值是__NR_execve
,所以会调用到sys_execve
。进入sys_execve
之后,内核堆栈指针便位于esp1处了,call
调用函数时会将当前的eip的值入栈。
1 | sys_execve: |
lea EIP(%esp), %eax
指令获取了当前esp指针+0x1C位置的地址,即上图中esp0所指的位置,然后将该指针压栈之后最终调用我们的核心函数do_execve()
。
do_execve核心函数
1 | int do_execve(unsigned long * eip,long tmp,char * filename, |
刚进入该函数时内核堆栈指针位于esp2处。这个函数有5个参数,其实都是前面压栈的这些值。其中最后3个参数是用户传进来的,tmp
对应esp1位置的值,没有用。eip
则是刚刚前面压栈的指针,指向esp0的位置。
那么这个eip
参数有什么用呢?看到最后你就知道了,它是本文的最关键的点。
do_execve()
函数前面的部分主要是在做一些准备工作,比如读取新程序的信息、准备参数和环境变量。因为篇幅原因,这里就略过了这些不太重要的部分,直接来看后面部分较为核心的代码。
1 | free_page_tables(get_base(current->ldt[1]),get_limit(0x0f)); |
这两行释放原先程序的代码段和数据段的所有内存页以及页表本身,于是它跟父进程便不再共享内存。
接下来重新设置LDT局部描述符表:
1 | p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE; |
可以看到程序代码段的长度值作为参数传给了change_ldt()
。
1 | static unsigned long change_ldt(unsigned long text_size,unsigned long * page) |
change_ldt()
干的事情并不复杂,先是设置新的代码段和数据段的基址与限长,然后更新fs寄存器。
1 | data_base += data_limit; |
最后将之前准备好的参数和环境变量字符串的那32个页的物理地址(即page
数组)关联到数据段中相应的线性地址(通过修改相应的页表项),也即数据段的末尾。返回值是代码段的限长。
p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
p在运算前指示了当前参数和环境变量字符串空间(即前面提到的32个页)中剩余的大小,所以p在运算后转换为在数据段中的偏移值。
线性地址情况
此时的线性地址情况如下:
1 | 0x0 0x4000000 |
p的右边的高地址是已经存放的参数和环境变量字符串,字符串是从低地址往高地址存放的。
接下来create_tables()
函数根据当前的堆栈指针p,以及参数个数argc和环境变量个数envc,在堆栈中创建环境变量和参数变量的指针表,返回新的堆栈指针值
1 | p = (unsigned long) create_tables((char *)p,argc,envc); |
此时的线性地址空间如下:
1 | 0x0 0x4000000 |
其中堆栈部分的详细情况如下:
1 | HIGH +--------------------+ |
从上往下(高地址到低地址)分别是每个环境变量字符串指针、每个参数字符串指针、环境变量字符串指针数组起始位置envp、参数字符串指针数组起始位置argv以及参数的个数argc。
接下来更新current各字段为新程序的信息,主要是几个指针及进程的有效用户id和组id。最后两行是做了这么一件事情,如果数据段末尾不是页对齐的,那么将剩余属于bss段的部分清零。
1 | current->brk = ex.a_bss + |
此时的线性地址空间如下:
1 | 0x0 endcode enddata brk 0x4000000 |
万事俱备,只欠东风。该做的铺垫都以就绪,准备好执行新程序了么,骚年!接下来就是见证奇迹的时刻了。
1 | eip[0] = ex.a_entry; /* eip, magic happens :-) */ |
eip
就是函数的第一个参数,ex.a_entry
和p
分别是程序的入口地址和当前的堆栈指针。结合前面的内核堆栈情况,就可以很清楚地知道,这两行其实是改了内核堆栈中的两个值:将esp0位置的值改成了新程序的入口地址,将esp0往上3步位置的值(即prev esp的值),改成了新程序的堆栈指针。接下来就是函数和系统调用返回的过程。
系统调用返回
返回的过程是一个堆栈清理的过程,首先do_execve
返回,接着sys_execve
返回,回到systemcall:
执行后续的代码:
1 | pushl %eax |
执行完具体的系统调用处理函数之后到这可能会发生调度行为。
1 | ret_from_sys_call: |
如果是从用户态进行的系统调用,还会对进程进行信号的处理,因为不是我们今天关注的重点,这里不再展开。
1 | popl %eax |
最后就是将之前压栈的所有寄存器出栈恢复,并iret
回到用户空间继续执行。因为此时堆栈中的prev eip
和prev esp
已经分别被我们修改成了新程序的入口地址和堆栈指针,所以返回用户空间之后就将开始执行新程序。而内核堆栈则又恢复到了最初干净的状态。
你可能会好奇,那为什么prev cs和prev ss不需要修改呢?因为前面do_execve()
并没有修改进程编号和pid,只是修改了局部表LDT中的内容,局部表本身的位置并没有变化,所以不需要更新cs和ss。
至此,execve的移花接木大法便也被我们搞清楚了。