栈溢出原理与基础利用

大家好,欢迎来到《二进制漏洞利用技术精讲》的第一章。今天我们来聊聊栈溢出——这个在漏洞利用领域里最经典、也最基础的话题。

说实话,栈溢出这玩意儿,我入行那会儿就觉得它特别有意思。你想想看,一个看似普通的缓冲区,稍微多写几个字节,就能让程序完全失控。我第一次成功控制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字节——那就是返回地址了!

我曾经调试过一个真实漏洞,攻击者就是通过这种方式,把返回地址改成了一个跳板地址,最终拿到了系统权限。嗯,那会儿我还在想,这代码写得也太随意了。

小技巧:在32位系统上,返回地址占4字节;64位系统上占8字节。调试时可以用pattern工具(比如pattern_create.rb)快速定位偏移量。

控制EIP/RIP

EIP(32位)或RIP(64位)是CPU的指令指针寄存器,它指向下一条要执行的指令。控制了EIP/RIP,就等于控制了程序的执行流。

怎么控制?很简单:

  1. 找到溢出点,确定偏移量(buffer到返回地址的距离)
  2. 构造payload:填充数据 + 覆盖EBP + 目标地址
  3. 让程序执行到返回指令时,跳转到我们指定的地址

举个例子,假设偏移量是76字节(64字节buffer + 12字节对齐/其他变量),那么payload结构就是:

[76字节填充] + [4字节目标地址]

目标地址可以是:

  • Shellcode的地址(如果栈可执行)
  • 系统函数地址(如system())
  • ROP gadget的地址
注意:现代系统大多启用了ASLR(地址空间布局随机化)和NX/DEP(栈不可执行),直接跳转到栈上的shellcode已经行不通了。后面我们会讲怎么绕过。

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。

避坑指南:我曾经在写shellcode时,忘了处理al寄存器的高位,结果syscall number不对,程序直接崩溃。调试了一下午才发现。所以写shellcode时,一定要确保所有寄存器都正确初始化。

ret2libc绕过NX/DEP

好,现在问题来了。现代系统都启用了NX/DEP,栈上不能执行代码。那我们的shellcode放哪儿?

答案是用ret2libc技术。libc是C标准库,里面有很多现成的函数,比如system()、execve()。我们只需要把返回地址改成这些函数的地址,再安排好参数就行了。

ret2libc的基本思路:

  1. 找到system()函数的地址
  2. 找到"/bin/sh"字符串的地址(也在libc里)
  3. 构造栈布局:填充 + 覆盖返回地址为system + 伪造返回地址 + 参数地址

栈布局长这样:

低地址
+------------------+
| 填充数据         |  (覆盖buffer和EBP)
+------------------+
| system()地址     |  (覆盖返回地址)
+------------------+
| 伪造返回地址     |  (system执行完后跳到这里,可以填0或exit地址)
+------------------+
| "/bin/sh"地址    |  (作为system的参数)
+------------------+
高地址

为什么中间要插一个伪造返回地址?因为函数调用约定要求,调用者把返回地址压栈,被调用者执行完后会弹出这个地址并跳转。如果我们不伪造一个,system执行完就会崩溃。

我记得有一次,我在一个CTF题目里用ret2libc,结果怎么也拿不到shell。后来发现是libc版本不对,地址算错了。从那以后,我养成了一个习惯:先确认目标系统的libc版本,再用readelf或objdump提取函数偏移。

关键点:ret2libc的核心是"代码复用"——我们不写新代码,而是利用已有的库函数。这完美绕过了NX/DEP,因为libc所在的代码段是可执行的。

当然,ret2libc也有局限性。如果系统启用了ASLR,libc的基址每次启动都会变,我们就没法直接硬编码地址了。这时候需要信息泄露,或者用更高级的ROP技术。这些我们后面的章节会详细讲。

好了,第一章的内容就到这里。栈溢出是二进制漏洞利用的基石,理解了它,后面的堆溢出、格式化字符串、ROP这些都会容易很多。下一章我们会深入讨论如何绕过ASLR,以及更高级的利用技巧。

如果你在练习中遇到问题,欢迎随时交流。记住,调试器是你最好的朋友——GDB用熟了,很多问题都能迎刃而解。