3. 缓冲区溢出原理:栈缓冲区溢出、经典攻击与防御
大家好,我是你们这堂课的老朋友。今天咱们聊一个系统安全里绕不开的话题——缓冲区溢出。说实话,这玩意儿是很多安全漏洞的“老祖宗”。我早年刚入行时,第一次亲手复现一个栈溢出漏洞,那种感觉就像打开了潘多拉魔盒。你想想看,一个看似无害的输入,居然能让程序乖乖交出控制权。
咱们这一节,重点就放在栈缓冲区溢出上。我会把原理、攻击手法、还有防御机制,掰开了揉碎了讲清楚。
3.1 栈缓冲区溢出原理
先问个问题:为什么栈上容易出事?
因为栈的设计太“信任”程序员了。它把局部变量和函数返回地址放在一起,中间就隔了一层薄薄的“墙”。这堵墙,就是缓冲区的大小。
看个最经典的例子:
void vulnerable_function(char *input) {
char buffer[64]; // 局部缓冲区,在栈上
strcpy(buffer, input); // 危险!没有长度检查
// ... 正常逻辑
}
这里 strcpy 不会管你 buffer 只有 64 字节。你传个 100 字节的字符串进来,它照单全收。多出来的 36 字节,就会覆盖栈上相邻的数据。
栈的布局大概是这样的(从高地址到低地址):
高地址
+------------------+
| 函数返回地址 | ← 攻击者最想改的地方
+------------------+
| 保存的 EBP/RBP | ← 基址指针
+------------------+
| 局部变量(buffer)| ← 从这里开始写
+------------------+
低地址
嗯,这里要注意:buffer 在低地址,返回地址在高地址。你往 buffer 里写数据,数据是从低地址向高地址增长的。一旦超过 64 字节,就会先覆盖 EBP,再覆盖返回地址。
我在项目中遇到过类似的情况。一个老旧的网络服务,用的就是 strcpy。当时我做个压力测试,随便发了个超长请求,服务直接崩溃。一查日志,程序计数器(PC)指向了一个完全随机的地址。这就是典型的栈溢出导致控制流劫持。
3.2 经典攻击手法
知道了原理,咱们看看攻击者怎么利用它。这里介绍两种最经典的手法。
3.2.1 ret2shellcode
“ret2shellcode” 这个名字很直白:把返回地址改成指向 shellcode 的地址。
Shellcode 是什么?说白了,就是一段机器码。这段代码通常用来启动一个 shell(比如 /bin/sh),或者执行其他恶意操作。
攻击步骤大致如下:
- 注入 shellcode:把 shellcode 放在
buffer里,或者放在环境变量里。 - 覆盖返回地址:让返回地址指向 shellcode 的起始位置。
- 函数返回:CPU 从返回地址取出新地址,跳过去执行 shellcode。
代码示例(伪代码思路):
// 假设 buffer 在栈上的地址是 0xbffffabc
// shellcode 放在 buffer 的开头
char shellcode[] = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80";
// 构造 payload: shellcode + 填充 + 返回地址
// 返回地址 = 0xbffffabc
char payload[100];
memcpy(payload, shellcode, sizeof(shellcode));
// 填充到偏移 64+4 的位置(假设 32 位系统,EBP 占 4 字节)
// 在偏移 68 处写入返回地址 0xbffffabc
我个人习惯在调试时,先用 gdb 确定 buffer 的实际地址。因为栈地址在不同环境下可能不一样。你想想看,如果地址猜错了,程序就直接崩溃,攻击也就失败了。
3.2.2 ROP(Return-Oriented Programming)
当栈不可执行时,攻击者怎么办?他们想了个聪明的办法:不写新代码,而是用程序里已有的代码片段。
这些代码片段叫 “gadget”。每个 gadget 通常以 ret 指令结尾。攻击者把多个 gadget 的地址串起来,形成一个 “gadget 链”。
举个例子:
// 假设程序里有这些 gadget
// gadget 1: pop rdi; ret (地址 0x400123)
// gadget 2: system (地址 0x400456)
// gadget 3: "/bin/sh" 字符串地址 (地址 0x600789)
// 构造的栈布局(从低到高):
// 返回地址 -> 0x400123 (pop rdi; ret)
// 参数 -> 0x600789 ("/bin/sh")
// 下一个返回地址 -> 0x400456 (system)
当函数返回时:
- 执行
pop rdi; ret,把0x600789弹出到rdi寄存器。 ret指令再弹出0x400456,跳转到system函数。system("/bin/sh")被执行。
ROP 的威力在于:它完全绕过了 NX 保护。攻击者不需要执行栈上的代码,只需要控制栈上的数据(返回地址和参数)。
我曾经在分析一个 IoT 设备漏洞时,就遇到了 ROP 链。那个设备固件里没有开启 ASLR,所以 gadget 地址是固定的。攻击者只需要找到合适的 gadget,就能完成任意代码执行。嗯,那次分析让我深刻体会到:防御机制必须组合使用,单一防御是不够的。
3.3 防御机制
有矛就有盾。下面介绍三种主流的防御机制。
3.3.1 ASLR(地址空间布局随机化)
ASLR 的核心思想:让关键内存区域的地址随机化。
每次程序启动时,栈、堆、共享库的基址都会变化。攻击者无法预测 shellcode 或 gadget 的具体地址。
| 内存区域 | 无 ASLR | 有 ASLR |
|---|---|---|
| 栈基址 | 固定(如 0xbffffxxx) | 随机变化 |
| 堆基址 | 固定 | 随机变化 |
| 共享库(如 libc) | 固定 | 随机变化 |
但 ASLR 不是万能的。如果攻击者能通过信息泄露(比如格式化字符串漏洞)拿到某个地址,就能推算出其他地址。另外,32 位系统的 ASLR 熵值较低,暴力破解也是可能的。
3.3.2 DEP/NX(数据执行保护)
DEP(Windows 叫法)或 NX(Linux 叫法)的作用很简单:标记内存页为“不可执行”。
栈和堆默认被标记为不可执行。即使攻击者把 shellcode 写到了栈上,CPU 也不会执行它,会直接抛出异常。
这就是为什么 ret2shellcode 在现代系统上基本失效。但 ROP 正是为了绕过 NX 而生的——它只执行代码段里的指令,不碰栈上的数据。
3.3.3 栈金丝雀(Stack Canary)
这个名字挺有意思。金丝雀在矿井里用来预警有毒气体。栈金丝雀的作用类似:在返回地址和局部变量之间放一个“哨兵”值。
看代码:
void safe_function(char *input) {
char buffer[64];
// 编译器自动插入:canary = 随机值
// 比如:*(int*)(buffer+64) = random_value;
strcpy(buffer, input); // 如果溢出,会覆盖 canary
// 函数返回前,检查 canary 是否被修改
// 如果被改,调用 __stack_chk_fail() 终止程序
}
金丝雀的值在函数入口时生成,保存在栈上。函数返回前,编译器会检查这个值是否被修改。如果被覆盖了,说明发生了溢出,程序立即终止。
我曾经在调试一个崩溃时,看到日志里出现了 *** stack smashing detected ***。这就是金丝雀机制在起作用。它阻止了一次潜在的缓冲区溢出攻击。
- 栈缓冲区溢出:利用
strcpy等无边界检查函数,覆盖返回地址。 - ret2shellcode:直接跳转到注入的 shellcode(需要栈可执行)。
- ROP:利用程序内的 gadget 链,绕过 NX 保护。
- ASLR:随机化地址,增加攻击难度。
- DEP/NX:标记栈和堆为不可执行。
- 栈金丝雀:在返回地址前放哨兵值,检测溢出。
这三种防御机制组合使用,能有效抵御大部分栈溢出攻击。但记住,没有绝对的安全。攻击者会不断寻找新的绕过方式,比如结合信息泄露来破解 ASLR,或者利用 __stack_chk_fail 的异常处理路径。
好了,这一节的内容就到这里。咱们把原理、攻击、防御都过了一遍。下一节,我会继续深入堆溢出和格式化字符串漏洞。到时候见。
可可精品资料库