Linux内核中隐藏的兵法—移花接木

之前在Linux内核中隐藏的兵法—无中生有一文中,我们探究了Linux中的进程创建,着重介绍了进程0和fork系统调用。本文我们将继续探究,另一个与进程创建相关的重要系统调用:execve。看看它又是葫芦里卖的什么药。

fork的子进程跟父进程跑着相同的程序,如果想要执行一个新的程序,那么就需要用到execve了。它在当前进程的上下文中加载并运行一个新的程序。

系统调用进入

为了使逻辑更加顺畅,我们还是选择从execve系统调用入口开始看起。这部分其实在前一篇讲fork相关内容时已经涉及,不过因为还是存在一点差异,同时也为了方便读者阅读,在这里还是完整地介绍一遍。

我们从execve系统调用定义的地方开始看起

1
_syscall3(int,execve,const char *,file,char **,argv,char **,envp)

_syscall3是个宏,内核定义了几个这样类似的宏,其中后面跟的那个数字表示参数的个数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define _syscall0(type,name) \
...
#define _syscall1(type,name,atype,a) \
...
#define _syscall2(type,name,atype,a,btype,b) \
...

#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
system_call:
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call
movl $0x10,%edx # set up ds,es to kernel space
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # fs points to local data space
mov %dx,%fs
call sys_call_table(,%eax,4)

内核堆栈情况

我们来看看内核堆栈的情况。int指令引发的CPU的中断过程会自动将用户态原堆栈段ss、堆栈指针esp、状态寄存器eflags、原用户代码段cs、原用户代码eip入栈(注意:这里是入栈到内核堆栈)。那么CPU为什么要将这些寄存器压栈?因为它得记得回去的路。就如同函数调用要把返回地址压栈一样,中断也需要将返回地址压栈。又因为内核代码段跟用户代码段是不同的,所以原用户态代码段寄存器也需要压栈,另外内核态和用户态特权级不同,是使用独立的堆栈,所以用户态的堆栈段寄存器ss和堆栈指针esp也要压栈。因此刚进入内核空间时,内核堆栈指针位于下图的esp0位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
HIGH  +----------------------+
| | prev ss |
+----------------------+
| prev esp |
+----------------------+
| eflags |
+----------------------+
| | prev cs |
+----------------------+
+--->| prev eip |<-- esp0
| +----------------------+
| | | ds |
| +----------------------+
| | | es |
| +----------------------+
| | | fs |
| +----------------------+
| | edx | envp
| +----------------------+
| | ecx | argv
| +----------------------+
| | ebx | filename
| +----------------------+
| | eip1 |<-- esp1
| +----------------------+
+----| esp1+0x1C | eip
+----------------------+
| eip2 |<-- esp2
LOW +----------------------+

随后将段寄存器ds、es、fs和参数edx、ecx、ebx分别入栈。然后因为eax的值是__NR_execve,所以会调用到sys_execve。进入sys_execve之后,内核堆栈指针便位于esp1处了,call调用函数时会将当前的eip的值入栈。

1
2
3
4
5
6
sys_execve:
lea EIP(%esp),%eax ; EIP = 0x1C
pushl %eax
call do_execve
addl $4,%esp
ret

lea EIP(%esp), %eax指令获取了当前esp指针+0x1C位置的地址,即上图中esp0所指的位置,然后将该指针压栈之后最终调用我们的核心函数do_execve()

do_execve核心函数

1
2
int do_execve(unsigned long * eip,long tmp,char * filename,
char ** argv, char ** envp)

刚进入该函数时内核堆栈指针位于esp2处。这个函数有5个参数,其实都是前面压栈的这些值。其中最后3个参数是用户传进来的,tmp对应esp1位置的值,没有用。eip则是刚刚前面压栈的指针,指向esp0的位置。

那么这个eip参数有什么用呢?看到最后你就知道了,它是本文的最关键的点。

do_execve()函数前面的部分主要是在做一些准备工作,比如读取新程序的信息、准备参数和环境变量。因为篇幅原因,这里就略过了这些不太重要的部分,直接来看后面部分较为核心的代码。

1
2
free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
free_page_tables(get_base(current->ldt[2]),get_limit(0x17));

这两行释放原先程序的代码段和数据段的所有内存页以及页表本身,于是它跟父进程便不再共享内存。

接下来重新设置LDT局部描述符表:

1
p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;

可以看到程序代码段的长度值作为参数传给了change_ldt()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static unsigned long change_ldt(unsigned long text_size,unsigned long * page)
{
unsigned long code_limit,data_limit,code_base,data_base;
int i;

code_limit = text_size+PAGE_SIZE -1;
code_limit &= 0xFFFFF000;
data_limit = 0x4000000;
code_base = get_base(current->ldt[1]);
data_base = code_base;
set_base(current->ldt[1],code_base);
set_limit(current->ldt[1],code_limit);
set_base(current->ldt[2],data_base);
set_limit(current->ldt[2],data_limit);
/* make sure fs points to the NEW data segment */
__asm__("pushl $0x17\n\tpop %%fs"::);

change_ldt()干的事情并不复杂,先是设置新的代码段和数据段的基址与限长,然后更新fs寄存器。

1
2
3
4
5
6
7
8
    data_base += data_limit;
for (i=MAX_ARG_PAGES-1 ; i>=0 ; i--) {
data_base -= PAGE_SIZE;
if (page[i])
put_page(page[i],data_base);
}
return data_limit;
}

最后将之前准备好的参数和环境变量字符串的那32个页的物理地址(即page数组)关联到数据段中相应的线性地址(通过修改相应的页表项),也即数据段的末尾。返回值是代码段的限长。

p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;

p在运算前指示了当前参数和环境变量字符串空间(即前面提到的32个页)中剩余的大小,所以p在运算后转换为在数据段中的偏移值。

线性地址情况

此时的线性地址情况如下:

1
2
3
4
5
0x0                                         0x4000000
| | MAX_ARG_PAGES * PAGE_SIZE |
^ 参数和环境变量字符串
|
p

p的右边的高地址是已经存放的参数和环境变量字符串,字符串是从低地址往高地址存放的。

接下来create_tables()函数根据当前的堆栈指针p,以及参数个数argc和环境变量个数envc,在堆栈中创建环境变量和参数变量的指针表,返回新的堆栈指针值

1
p = (unsigned long) create_tables((char *)p,argc,envc);

此时的线性地址空间如下:

1
2
3
4
5
0x0                                         0x4000000
| ... | MAX_ARG_PAGES * PAGE_SIZE |
^ table ^ 参数和环境变量字符串
| |
p old p

其中堆栈部分的详细情况如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
HIGH  +--------------------+
| NULL |<-- old p
-|--------------------|
/ | |
envc|--------------------|
\ | |<---+
-|--------------------| |
| NULL | |
-|--------------------| |
/ | | |
argc|--------------------| |
\ | |<-+ |
-|--------------------| | |
| envp |--+-+
|--------------------| |
| argv |--+
|--------------------|
| argc |<-- p
|--------------------|
LOW +--------------------+

从上往下(高地址到低地址)分别是每个环境变量字符串指针、每个参数字符串指针、环境变量字符串指针数组起始位置envp、参数字符串指针数组起始位置argv以及参数的个数argc。

接下来更新current各字段为新程序的信息,主要是几个指针及进程的有效用户id和组id。最后两行是做了这么一件事情,如果数据段末尾不是页对齐的,那么将剩余属于bss段的部分清零。

1
2
3
4
5
6
7
8
9
current->brk = ex.a_bss +
(current->end_data = ex.a_data +
(current->end_code = ex.a_text));
current->start_stack = p & 0xfffff000;
current->euid = e_uid;
current->egid = e_gid;
i = ex.a_text+ex.a_data;
while (i&0xfff)
put_fs_byte(0,(char *) (i++));

此时的线性地址空间如下:

1
2
3
4
5
0x0 endcode enddata  brk                              0x4000000
| code | data | bss | .. | MAX_ARG_PAGES * PAGE_SIZE |
^ table ^ 参数和环境变量字符串
| |
p old p

万事俱备,只欠东风。该做的铺垫都以就绪,准备好执行新程序了么,骚年!接下来就是见证奇迹的时刻了。

1
2
3
eip[0] = ex.a_entry;        /* eip, magic happens :-) */
eip[3] = p; /* stack pointer */
return 0;

eip就是函数的第一个参数,ex.a_entryp分别是程序的入口地址和当前的堆栈指针。结合前面的内核堆栈情况,就可以很清楚地知道,这两行其实是改了内核堆栈中的两个值:将esp0位置的值改成了新程序的入口地址,将esp0往上3步位置的值(即prev esp的值),改成了新程序的堆栈指针。接下来就是函数和系统调用返回的过程。

系统调用返回

返回的过程是一个堆栈清理的过程,首先do_execve返回,接着sys_execve返回,回到systemcall:执行后续的代码:

1
2
3
4
5
6
pushl %eax
movl current,%eax
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule

执行完具体的系统调用处理函数之后到这可能会发生调度行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ret_from_sys_call:
movl current,%eax # task[0] cannot have signals
cmpl task,%eax
je 3f
cmpw $0x0f,CS(%esp) # was old code segment supervisor ?
jne 3f
cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ?
jne 3f
movl signal(%eax),%ebx
movl blocked(%eax),%ecx
notl %ecx
andl %ebx,%ecx
bsfl %ecx,%ecx
je 3f
btrl %ecx,%ebx
movl %ebx,signal(%eax)
incl %ecx
pushl %ecx
call do_signal

如果是从用户态进行的系统调用,还会对进程进行信号的处理,因为不是我们今天关注的重点,这里不再展开。

1
2
3
4
5
6
7
8
9
    popl %eax
3: popl %eax
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret

最后就是将之前压栈的所有寄存器出栈恢复,并iret回到用户空间继续执行。因为此时堆栈中的prev eipprev esp已经分别被我们修改成了新程序的入口地址和堆栈指针,所以返回用户空间之后就将开始执行新程序。而内核堆栈则又恢复到了最初干净的状态。

你可能会好奇,那为什么prev cs和prev ss不需要修改呢?因为前面do_execve()并没有修改进程编号和pid,只是修改了局部表LDT中的内容,局部表本身的位置并没有变化,所以不需要更新cs和ss。

至此,execve的移花接木大法便也被我们搞清楚了。

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道