栈溢出原理与基础利用
大家好,欢迎来到《二进制漏洞利用技术精讲》的第一章。今天我们来聊聊栈溢出——这个在漏洞利用领域里最经典、也最基础的话题。
说实话,栈溢出这玩意儿,我入行那会儿就觉得它特别有意思。你想想看,一个看似普通的缓冲区,稍微多写几个字节,就能让程序完全失控。我第一次成功控制EIP的时候,那种感觉,嗯,就像打开了新世界的大门。
栈缓冲区溢出原理
先说说栈是什么。栈是程序运行时的一块内存区域,用来存局部变量、函数参数、返回地址这些。它的特点是后进先出,就像一摞盘子,你最后放上去的,最先拿走。
那栈溢出是怎么发生的?说白了,就是程序往缓冲区里写数据时,没控制好长度。比如你声明了一个64字节的数组,结果往里塞了100个字节。多出来的36个字节,就会覆盖掉栈上相邻的数据。
我在项目中遇到过不少这样的案例。很多开发者觉得"我写代码很小心,不会出这种低级错误"。但现实是,字符串拷贝、用户输入处理、网络数据解析,这些地方特别容易出问题。尤其是那些用了strcpy、sprintf这类不检查长度的函数,简直就是定时炸弹。
来看一个典型的漏洞代码:
#include <stdio.h>
#include <string.h>
void vulnerable_function(char *input) {
char buffer[64];
strcpy(buffer, input); // 这里没有长度检查!
printf("Buffer content: %s\n", buffer);
}
int main(int argc, char *argv[]) {
if (argc > 1) {
vulnerable_function(argv[1]);
}
return 0;
}
这段代码里,strcpy直接把用户输入拷贝到64字节的缓冲区。如果输入超过63个字符(留一个给结尾的'\0'),就会发生溢出。
覆盖返回地址
好,现在我们知道栈溢出了。但怎么利用它?关键就在于——返回地址。
当一个函数被调用时,栈上会保存这么几样东西:
- 函数的局部变量(比如上面的buffer)
- 保存的基址指针(EBP/RBP)
- 返回地址(EIP/RIP)
- 函数参数
栈的布局从高地址到低地址大致是这样:
高地址
+------------------+
| 函数参数 |
+------------------+
| 返回地址 | <-- 我们要控制的目标
+------------------+
| 保存的EBP |
+------------------+
| 局部变量(buffer) | <-- 从这里开始写数据
+------------------+
低地址
你想想看,如果我们往buffer里写数据,写够64字节填满buffer,再写4字节覆盖EBP,再写4字节——那就是返回地址了!
我曾经调试过一个真实漏洞,攻击者就是通过这种方式,把返回地址改成了一个跳板地址,最终拿到了系统权限。嗯,那会儿我还在想,这代码写得也太随意了。
控制EIP/RIP
EIP(32位)或RIP(64位)是CPU的指令指针寄存器,它指向下一条要执行的指令。控制了EIP/RIP,就等于控制了程序的执行流。
怎么控制?很简单:
- 找到溢出点,确定偏移量(buffer到返回地址的距离)
- 构造payload:填充数据 + 覆盖EBP + 目标地址
- 让程序执行到返回指令时,跳转到我们指定的地址
举个例子,假设偏移量是76字节(64字节buffer + 12字节对齐/其他变量),那么payload结构就是:
[76字节填充] + [4字节目标地址]
目标地址可以是:
- Shellcode的地址(如果栈可执行)
- 系统函数地址(如system())
- ROP gadget的地址
Shellcode基础与编写
Shellcode是一段机器码,执行后能给我们一个shell。最简单的shellcode就是调用execve("/bin/sh", NULL, NULL)。
我刚开始写shellcode时,踩过不少坑。比如:
- 不能有NULL字节(因为字符串函数遇到NULL就截断了)
- 要处理地址编码问题
- 要确保代码是位置无关的
一个经典的32位Linux shellcode(21字节):
; execve("/bin/sh", NULL, NULL)
; 汇编代码
xor eax, eax ; 清零eax
push eax ; 压入NULL作为字符串结尾
push 0x68732f2f ; "//sh"
push 0x6e69622f ; "/bin"
mov ebx, esp ; ebx指向"/bin//sh"
push eax ; 压入NULL (argv[1])
push ebx ; 压入字符串地址 (argv[0])
mov ecx, esp ; ecx指向argv数组
xor edx, edx ; edx = NULL (envp)
mov al, 0xb ; syscall number for execve
int 0x80 ; 触发系统调用
对应的机器码:
\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80
你看,这里面没有NULL字节。为什么?因为我把/bin/sh写成了/bin//sh,多一个斜杠不影响执行,但避免了字符串中的NULL。
al寄存器的高位,结果syscall number不对,程序直接崩溃。调试了一下午才发现。所以写shellcode时,一定要确保所有寄存器都正确初始化。
ret2libc绕过NX/DEP
好,现在问题来了。现代系统都启用了NX/DEP,栈上不能执行代码。那我们的shellcode放哪儿?
答案是用ret2libc技术。libc是C标准库,里面有很多现成的函数,比如system()、execve()。我们只需要把返回地址改成这些函数的地址,再安排好参数就行了。
ret2libc的基本思路:
- 找到
system()函数的地址 - 找到
"/bin/sh"字符串的地址(也在libc里) - 构造栈布局:填充 + 覆盖返回地址为system + 伪造返回地址 + 参数地址
栈布局长这样:
低地址
+------------------+
| 填充数据 | (覆盖buffer和EBP)
+------------------+
| system()地址 | (覆盖返回地址)
+------------------+
| 伪造返回地址 | (system执行完后跳到这里,可以填0或exit地址)
+------------------+
| "/bin/sh"地址 | (作为system的参数)
+------------------+
高地址
为什么中间要插一个伪造返回地址?因为函数调用约定要求,调用者把返回地址压栈,被调用者执行完后会弹出这个地址并跳转。如果我们不伪造一个,system执行完就会崩溃。
我记得有一次,我在一个CTF题目里用ret2libc,结果怎么也拿不到shell。后来发现是libc版本不对,地址算错了。从那以后,我养成了一个习惯:先确认目标系统的libc版本,再用readelf或objdump提取函数偏移。
当然,ret2libc也有局限性。如果系统启用了ASLR,libc的基址每次启动都会变,我们就没法直接硬编码地址了。这时候需要信息泄露,或者用更高级的ROP技术。这些我们后面的章节会详细讲。
好了,第一章的内容就到这里。栈溢出是二进制漏洞利用的基石,理解了它,后面的堆溢出、格式化字符串、ROP这些都会容易很多。下一章我们会深入讨论如何绕过ASLR,以及更高级的利用技巧。
如果你在练习中遇到问题,欢迎随时交流。记住,调试器是你最好的朋友——GDB用熟了,很多问题都能迎刃而解。