漏洞挖掘基础:四大内存破坏漏洞原理

大家好,我是你们的老朋友。今天我们来聊聊漏洞挖掘里最基础、也最核心的四个家伙——栈溢出、堆溢出、整数溢出和格式化字符串漏洞。说实话,这四种漏洞几乎覆盖了早期二进制安全领域80%以上的攻击面。我当年刚入行时,就是靠把这四个原理吃透,才慢慢摸到了漏洞挖掘的门道。

你想想看,不管现在CTF比赛里花样怎么翻新,底层逻辑还是这些。嗯,咱们一个一个来拆解。

核心观点: 这四种漏洞的本质,都是程序对内存或数据边界的信任过度。说白了,就是程序员假设输入不会越界,结果被攻击者利用了。

1. 栈溢出原理

栈溢出,我个人觉得是入门第一课。为什么?因为它最直观,也最容易复现。

栈是什么?就是程序运行时用来存局部变量、函数参数、返回地址的一块内存。它的特点是后进先出,由系统自动管理。但问题来了——如果某个函数往栈上的缓冲区里写数据,写多了,就会覆盖掉相邻的数据。

我在项目中遇到过最经典的场景:一个简单的strcpy,没有检查源字符串长度,直接往固定大小的局部数组里拷贝。结果呢?返回地址被覆盖了,程序跳到了攻击者指定的位置。

void vulnerable_function(char *input) {
    char buffer[64];
    strcpy(buffer, input);  // 没有长度检查!
    // 如果 input 超过 64 字节,就会覆盖返回地址
}

为什么会这样?因为strcpy不会自动在末尾加\0之外做任何边界检查。你传进来128字节,它就老老实实写128字节。栈上的布局是这样的:

栈地址(低→高)内容
buffer[0..63]局部变量区
EBP(基址指针)4字节
返回地址4字节 ← 攻击目标
函数参数...

你想想看,只要输入超过64字节,EBP就被覆盖;超过68字节,返回地址就被覆盖。攻击者精心构造这4字节,就能让程序跳转到任意地址——比如shellcode。

我的习惯: 调试栈溢出时,我一般先用pattern.py生成一串无重复的字符,看崩溃时EIP的值,就能精确算出偏移量。这个技巧在CTF和实战中都非常好用。

避坑指南: 我曾经在32位程序上调试得好好的,换到64位就翻车了。因为64位下参数传递用寄存器,栈布局略有不同。另外,现代编译器默认开启栈保护(canary),会检测返回地址是否被篡改。所以实战中往往需要先绕过canary。

2. 堆溢出原理

堆溢出比栈溢出复杂一些。堆是动态分配的内存,由程序员手动管理(malloc/free)。堆溢出发生在你往堆块里写数据时,写到了相邻堆块的地盘上。

我记得有一次分析一个开源软件的漏洞,就是堆溢出。攻击者通过溢出覆盖了相邻堆块的元数据(比如size字段),然后利用free时的unlink操作,实现了任意地址写。

void heap_overflow() {
    char *a = malloc(64);
    char *b = malloc(64);
    // 假设 a 和 b 在堆上是相邻的
    strcpy(a, "AAAA...");  // 如果数据超过64字节,就会覆盖b的头部
    free(b);  // 此时b的元数据已被篡改,可能触发异常
}

堆溢出的利用方式主要有两种:

  • 覆盖相邻堆块数据: 比如覆盖一个函数指针,或者覆盖一个长度字段,造成后续的越界读写。
  • 覆盖堆块元数据: 利用glibc ptmalloc的unlink宏,实现任意地址写。这个技巧在glibc 2.26之前非常经典。

嗯,这里要注意:堆管理器(比如ptmalloc)维护着一堆链表(bins)。每个空闲堆块里都有fd和bk指针,指向链表中的前后节点。如果你能伪造这些指针,就能在free或malloc时触发任意地址写。

个人经验: 堆溢出调试起来比栈溢出麻烦得多。因为堆的状态是动态变化的,同一个漏洞在不同分配次数下表现可能完全不同。我建议用GDB的heap插件(比如pwndbg的heap命令)来查看堆布局,比肉眼盯着代码强多了。

3. 整数溢出

整数溢出,说白了就是整数运算结果超出了类型能表示的范围。C语言里整数类型有固定宽度,比如unsigned char是0~255,int是-2^31~2^31-1。一旦超出,就会发生回绕。

我见过最坑的案例:一个程序用int来存储数据长度,然后做malloc(len)。攻击者传入一个很大的数,比如0xFFFFFFFF,加上某个偏移后溢出变成一个小数。结果malloc分配了一个很小的缓冲区,后续拷贝大量数据,直接堆溢出。

int size = atoi(user_input);  // 用户输入 0x80000000
int total = size + 0x80000000;  // 溢出!total 变成 0
char *buf = malloc(total);  // 分配了0字节
memcpy(buf, data, size);  // 堆溢出!

整数溢出有三种常见形式:

  • 无符号整数回绕: 比如255+1=0(对unsigned char而言)
  • 有符号整数溢出: 比如INT_MAX+1变成负数
  • 截断溢出: 比如把int赋值给short,高位被丢弃

我的建议: 审计代码时,重点关注那些从外部输入到内存分配/拷贝的路径。只要看到malloc(size)里的size来自用户输入,就要警惕整数溢出。我曾经在审计一个网络协议栈时,发现三个整数溢出漏洞,都是因为长度字段被当作int处理,却没有做范围检查。

4. 格式化字符串漏洞

格式化字符串漏洞,是我觉得最优雅的一种漏洞。它不需要缓冲区溢出,只需要一个printf用错了。

正确的用法是printf("%s", str),错误的用法是printf(str)。如果str里包含%x%n等格式化占位符,printf就会从栈上读取或写入数据。

void format_string_vuln(char *user_input) {
    printf(user_input);  // 漏洞!应该用 printf("%s", user_input)
}

为什么能利用?因为printf不知道有多少个参数。它看到%x,就从栈上取一个整数;看到%n,就把已输出的字节数写入一个地址。攻击者可以:

  • 读取栈数据:%x%p泄露栈上的返回地址、canary、libc地址等。
  • 写入任意地址:%n配合%hn(写2字节)或%hhn(写1字节),实现精确的任意地址写。

我记得在分析一个老版本的SSH服务时,就遇到了格式化字符串漏洞。攻击者通过%s读取了栈上的私钥路径,然后结合其他漏洞实现了远程代码执行。嗯,那个案例让我深刻理解了“信息泄露+任意写”的组合威力。

避坑指南: 我曾经在64位程序上调试格式化字符串漏洞时,发现参数传递方式变了。前6个参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),第7个才开始从栈上取。所以%6$p才能读到栈上的第一个值。这个细节很容易被忽略。

知识体系总览

下面这张图,是我梳理的这四种漏洞的核心逻辑。你可以把它当作一个快速索引:

四大内存破坏漏洞核心逻辑 栈溢出 堆溢出 整数溢出 格式化字符串 覆盖返回地址 控制EIP/RIP 覆盖相邻堆块 伪造fd/bk指针 回绕/截断 分配过小缓冲区 %x/%p泄露 %n任意地址写 共同本质:程序对输入边界缺乏信任,导致内存被篡改 防御思路:边界检查 + 安全编译选项 + 地址随机化(ASLR)

总结与实战建议

好了,四种漏洞的原理都讲完了。你可能会问:学了这些能干嘛?

我个人觉得,理解原理只是第一步。真正的能力体现在两点:

  • 审计能力: 看到一段代码,能快速识别出哪里可能出问题。比如看到strcpysprintfscanfmemcpy等函数,就要条件反射地检查边界。
  • 利用能力: 知道漏洞存在后,能构造输入触发它,并实现代码执行或信息泄露。这需要熟悉操作系统、编译器和调试器。

嗯,这里再分享一个我个人的小习惯:每次分析新漏洞时,我都会先画一张内存布局图,标出哪些数据是可控制的,哪些是关键的(返回地址、函数指针、堆元数据等)。然后问自己三个问题:

  1. 我能控制哪些字节?
  2. 这些字节能覆盖到关键数据吗?
  3. 覆盖后能造成什么后果?

这三个问题想清楚了,漏洞利用的路径也就清晰了。

最后提醒: 实战中不要只盯着一种漏洞。很多高级攻击是组合拳——先用格式化字符串泄露地址,再用整数溢出绕过大小检查,最后用堆溢出写shellcode。嗯,这就是漏洞挖掘的魅力所在。


公众号:蓝海资料掘金营,微信deep3321