4. 栈的管理

154 阅读6分钟

函数与栈帧

当我们在调用函数时,CPU 会在栈空间里开辟一小块区域,函数的局部变量都在这块区域里存活。当函数调用结束时,局部变量就会被回收。

这一小块区域很像一个框子,所以大家就命名它为 stack frame。frame 本意是框子,在翻译的时候被译为帧,现在它的中文名字就是栈帧了。

栈帧本质上是一个函数的活动记录。当某个函数正在执行时,它的活动记录就会存在,当函数执行结束时,活动记录也被销毁。 不过,一个函数执行时可以调用其他函数,这时它的栈帧还是存在的。例如,A 函数调用 B 函数,A 的栈帧不会被销毁,而是会在 A 栈帧的下方,再去创建 B 函数的栈帧。只有当 B 函数执行完了,B 的栈帧也被销毁了,CPU 才会回到 A 的栈帧里继续执行。

从指令的角度理解栈

通过一个例子来考察一下:

int fac(int n) { 
    return n == 1 ? 1 : n * fac(n-1); 
}

我们使用gcc进行编译,再使用objdump进行反编译,观察编译后的机器码

gcc -o fac fac.c
objdump -d fac

在输出中找到fac函数的:

00401410 <_fac>:
  401410:       55                      push   %ebp
  401411:       89 e5                   mov    %esp,%ebp
  401413:       83 ec 18                sub    $0x18,%esp
  401416:       83 7d 08 01             cmpl   $0x1,0x8(%ebp)
  40141a:       74 14                   je     401430 <_fac+0x20> // 第五行
  40141c:       8b 45 08                mov    0x8(%ebp),%eax
  40141f:       83 e8 01                sub    $0x1,%eax
  401422:       89 04 24                mov    %eax,(%esp)
  401425:       e8 e6 ff ff ff          call   401410 <_fac>
  40142a:       0f af 45 08             imul   0x8(%ebp),%eax // 第十行
  40142e:       eb 05                   jmp    401435 <_fac+0x25>
  401430:       b8 01 00 00 00          mov    $0x1,%eax
  401435:       c9                      leave
  401436:       c3                      ret

第 1 行将调用者栈基址指针压入栈中,第 2 行把当前函数的栈指针寄存器的值保存到栈基址寄存器,这两行的作用是把当前函数的栈帧创建在调用者的栈帧之下。保存调用者的栈基址是为了在 return 时可以恢复这个寄存器。

第 3 行的作用是把栈向下增长 0x18,为了给局部变量预留空间,运行 fac 函数要是消耗栈空间的。

试想一下,如果不加 n==1 的判断,函数会一直递归调用回不来,这样栈上就会出现很多 fac 的帧栈,造成栈空间耗尽,出现 StackOverflow。这里的原理是,os在栈空间的尾部设置一个禁止读写的页,一旦栈增长到尾部,os就可以通过中断探知程序在访问栈末端。

第 4 行把变量 n(EBP寄存器所指向的地址再加上偏移量0x8,八个字节) 和 常量0x1作比较,在第5行中如果比较结果相同,则跳转到0x401430,将0x1送到寄存器eax中然后返回,即n==1时返回1。

如果不相同则不会跳转,继续执行 第 6 行。6、7、8这三行的作用,就是把 n-1 送到 esp 寄存器中,即以 n-1 为参数调用 fac 函数。这时调用的返回值在 eax 中,第 10 行会把返回值与变量 n 相乘, 结果仍然存储在 eax 中。然后程序就可以跳转到 0x401435 处结束这次调用。

我们再重点讨论 callq 指令。

执行 callq 指令时,CPU 会把 rip 寄存器中的内容,也就是 call 的下一条指令的地址放到栈上(在这个例子中就是 0x40142a),然后跳转到目标函数处执行。执行完成后会执行 ret 指令,这个指令会从栈上找到刚才存的那条指令,然后继续恢复执行。

栈溢出

我们已经从机器指令的角度,加深了对栈和栈帧的理解。现在举一个通过缓冲区溢出来破坏栈的例子。

#include<stdio.h>
#include<stdlib.h>

#define BUFFER_LEN 24

void bad(){
    printf("Haha, I am a hacked/\n");
}

void copy(char *dst, char *src, int n){
    int i;
    for (i = 0; i < n; i++){
        dst[i] = src[i];
    }
}

void test(char *t, int n){
    char s[16];
    copy(s, t, n);
}

int main(){
    char t[BUFFER_LEN] = {'w', 'o', 'l', 'd', 'a', 'b','a', 'b','a', 'b','a', 'b','a', 'b','a', 'b'};
    int n = BUFFER_LEN - 8;
    int i = 0;
    for (; i < 8; i ++){
        t[n + i] = (char)((((long)(&bad)) >> (i * 8)) &0xff);
    }
    test(t, BUFFER_LEN);
    printf("hello\n");
}

用gcc编译后运行

gcc -O1 -o bad bad.c -g -fno-stack-protector

image.png

虽然 main 函数里并没有调用 bad 函数,但它却执行了。

调用 test 函数时,会把返回地址(rip 寄存器中的值),放到栈上,就进入 test 的栈帧,CPU 开始执行 test 函数。 执行时先在自己的栈帧里创建数组 s,长度是 16。此时,栈上的布局是这样的:

image.png

返回地址是变量 s 的地址 + 16 的地方,这就是攻击的目标。只要在这把原来的地址替换为函数 bad 的入口地址,就可以改变程序的执行顺序,实现了一次缓冲区溢出。

s 的长度是 16,理论上只能修改以 s 的地址开始、长度为 16 的数据。但现在通过 copy 函数操作了大于 16 的数据,从而破坏了栈上的关键数据。也就是说我们针对函数调用的返回地址发起了一次攻击。所以,test 函数的实现是不安全的。

有两种常见的手段可以对这一类攻击进行防御。

一. 入参检查,使用 strncpy 来代替 strcpy。

strcpy 不对参数长度做限制,而 strncpy 则会做检查。比如上述例子中,如果我们对参数 n 做检查,要求它的值必须大于 0 且小于缓冲区长度,就可以阻击缓冲区溢出攻击了。

二. 使用 gcc 自带的栈保护机制, -fstack-protector 选项。

当 -fstack-protector 启用时,当其检测到缓冲区溢出时会立即终止正在执行的程序并提示。这种机制是通过在函数中的易被受到攻击的目标上下文添加保护变量来完成的。这些保护变量在进入函数时进行初始化,当函数退出时进行检测,如果某些变量检测失败,那么会打印出错误提示信息并且终止当前的进程。

5.8 版本开始,gcc 中的这个选项是默认打开的。如果在编译时不加 -fno-stack-protector,gcc 就会给可执行程序增加栈保护的功能。这样运行结果就会出现 Segment Fault,导致进程崩溃。在日常开发中,这个选项虽然使得栈的安全大大加强了,但它也有巨大的性能损耗。当然这个选项也不是万能的,攻击者依然能通过精心构造数据来达成它的目标。所以在写代码时,你还是应该对缓冲区安全多加注意。