函数与栈帧

当调用一个函数的时候,CPU 会在栈空间(这当然是线性空间的一部分)里开辟一小块区域,这个函数的局部变量的生命周期属于该区域。当函数调用结束的时候,这一小块区域内的局部变量就会被“回收”(并没有被真正地清空局部变量)。
这一小块区域被称为栈帧,栈帧本质上是一个函数的活动记录。当某个函数正在执行时,它的活动记录就会存在,当函数执行结束时,活动记录也被“回收”。
由于函数调用层数过深或者局部变量所占栈空间过大等问题导致 StackOverflow 运行时错误的原理是,操作系统会在栈空间的尾部设置一个禁止读写的页,一旦栈增长到尾部,操作系统就可以通过中断探知程序在访问栈末端。

从指令角度理解栈

  1. int fac(int n) {
  2. return n == 1 ? 1 : n * fac(n - 1);
  3. }
  4. int main() {
  5. fac(2);
  6. return 0;
  7. }
  1. $ gcc -o fac fac.c
  2. $ objdump -d fac
  1. 0000000000001129 <fac>:
  2. 1129: f3 0f 1e fa endbr64
  3. 112d: 55 push %rbp // 保存当前栈基址到栈顶
  4. 112e: 48 89 e5 mov %rsp,%rbp // 保存栈指针 rsp 到栈基址寄存器
  5. 1131: 48 83 ec 10 sub $0x10,%rsp // 为局部变量分配栈帧空间,此时当前的栈帧已经生成
  6. 1135: 89 7d fc mov %edi,-0x4(%rbp) // 为局部变量 n 赋值
  7. 1138: 83 7d fc 01 cmpl $0x1,-0x4(%rbp) // 比较 n 是否等于 1
  8. 113c: 74 13 je 1151 <fac+0x28> // 若不等于则跳转到 27 行
  9. 113e: 8b 45 fc mov -0x4(%rbp),%eax
  10. 1141: 83 e8 01 sub $0x1,%eax
  11. 1144: 89 c7 mov %eax,%edi
  12. 1146: e8 de ff ff ff callq 1129 <fac> // 以 n - 1 为参数调用 fac
  13. 114b: 0f af 45 fc imul -0x4(%rbp),%eax // 上一行函数的返回值保存在 eax 中
  14. 114f: eb 05 jmp 1156 <fac+0x2d> // 跳转到 28 行
  15. 1151: b8 01 00 00 00 mov $0x1,%eax
  16. 1156: c9 leaveq
  17. 1157: c3 retq

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

栈溢出

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #define BUFFER_LEN 24
  4. void bad() {
  5. printf("Haha, I am hacked.\n");
  6. exit(1);
  7. }
  8. void copy(char* dst, char* src, int n) {
  9. int i;
  10. for (i = 0; i < n; i++) {
  11. dst[i] = src[i];
  12. }
  13. }
  14. void test(char* t, int n) {
  15. char s[16];
  16. copy(s, t, n);
  17. }
  18. int main() {
  19. char t[BUFFER_LEN] = {
  20. 'w', 'o', 'l', 'd',
  21. 'a', 'b', 'a', 'b', 'a', 'b',
  22. 'a', 'b', 'a', 'b', 'a', 'b',
  23. };
  24. int n = BUFFER_LEN - 8;
  25. int i = 0;
  26. for (; i < 8; i++) {
  27. t[n+i] = (char)((((long)(&bad)) >> (i*8)) & 0xff);
  28. } // 将数组的最后八个字节修改为 bad 函数的入口地址
  29. test(t, BUFFER_LEN);
  30. printf("hello\n");
  31. }
  1. $ gcc -O1 -o bad bad.c -g -fno-stack-protector # -O1 参数使得 rbp 不保存在栈上
  2. $ ./bad
  3. Haha, I am hacked.

main 函数中并没有调用 bad,但它却执行了。如果不开启 -O1,那么就会触发 general protection,因为 bad 函数的地址将修改栈上保存的旧的 rbp 值,因此在函数返回时,导致栈帧失衡;如果开启栈保护功能,无论采用何种优化都会正常输出 hello,因为修改 rip 或 rbp 的行为会被拒绝。

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

返回地址保存在变量 s 的地址 + 16 的地方,代码的第 24 至 33 行所做的事情就是在这个地方把原来的地址替换为函数 bad 的入口地址(),就可以改变程序的执行顺序,实现了一次缓冲区溢出

这类缓冲区溢出问题,就是指通过一定的手段,来达成修改不属于本函数栈帧的变量的目的,而这种手段多是通过往字符串变量或者数组中写入错误的值而造成的

解决方案

  1. 对入参进行检查,尽量使用 strncpy 来代替 strcpy。因为 strcpy 不对参数长度做限制,而 strncpy 则会做检查。比如上述例子中,如果对参数 n 做检查,要求它的值必须大于 0 且小于缓冲区长度,就可以阻击缓冲区溢出攻击了;
  2. 可以使用 gcc 自带的栈保护机制,就是 -fstack-protector 选项(默认打开,编译时添加 -fno-stack-protector 选项可关闭栈保护的功能,关闭后可一定程度上提升程序性能)

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

指针和引用

  1. #include <stdio.h>
  2. void swap(int* a, int* b) {
  3. int t = *a;
  4. *a = *b;
  5. *b = t;
  6. }
  7. void main() {
  8. int a = 2;
  9. int b = 3;
  10. swap(&a, &b);
  11. printf("a is %d, b is %d\n", a, b);
  12. }
  1. #include <stdio.h>
  2. void swap(int& a, int& b) {
  3. int t = a;
  4. a = b;
  5. b = t;
  6. }
  7. void main() {
  8. int a = 2;
  9. int b = 3;
  10. swap(a, b);
  11. printf("a is %d, b is %d\n", a, b);
  12. }

上例中的引用类型会通过 lea 汇编指令将变量 a、b 在栈上的地址传递给 swap 函数(从汇编代码层面来看,这两个 swap 函数对应的汇编代码一模一样)