1996年,程序一个叫Aleph One的溢出黑客组织发布了一篇论文《Smashing The Stack For Fun And Profit》。这篇文章教会了整整一代人怎么用一行超长字符串把程序的漏洞执行流程劫持到自己的代码上。近三十年后,程序这个手法依然活着,溢出只是漏洞换了无数种外壳。
溢出的程序本质说穿了很朴素:程序向一块固定大小的内存区域写入数据,但写入的溢出长度超过了这块区域的容量,多出来的漏洞部分就漫到了相邻的内存空间里。C语言里那个从不检查边界的程序strcpy(),就是溢出经典的肇事者。你声明一个char buf[64],漏洞然后往里塞了200个字节,程序剩下136个字节就踩到了别人的溢出地盘上。
踩到谁的漏洞地盘,后果完全不同。如果溢出发生在栈上,攻击者可以覆盖函数的返回地址——CPU执行完当前函数后,会跳到这个地址继续执行。控制了这个地址,就控制了程序的下一步。把它指向一段注入的shellcode,程序就变成了攻击者的傀儡。栈溢出的可怕之处在于,它不需要程序本身有任何逻辑缺陷,只要有一个不检查长度的输入点就够了。
堆溢出稍微绕一点。堆是程序运行时动态分配的内存,没有返回地址那么直白的东西可以覆盖。但堆上有函数指针、有虚表指针、有链表结构。攻击者通过精心构造的数据把这些指针改成自己想要的地址,同样能达到执行任意代码的目的。2001年Windows的MS03-026漏洞(也就是后来被冲击波蠕虫利用的那个)就是典型的堆溢出。
还有一种容易被忽略的类型叫整数溢出。看起来和内存没关系,但它是很多溢出的源头。比如程序要分配n * size字节的内存,n和size都是用户可控的。如果n特别大,乘法结果超过了整数能表示的范围,就会回绕成一个很小的数。程序以为分配了足够的内存,实际拿到的是一小块,后续的写入自然就溢出了。这类漏洞在图像处理库和压缩库中反复出现。
防御手段这些年也在进化。栈保护(Stack Canary)在返回地址前面放一个随机值,函数返回前检查这个值有没有被改动,溢出一碰就露馅。ASLR让内存地址随机化,攻击者很难准确知道该跳到哪里。DEP让栈上的数据不可执行,shellcode注入进去也跑不起来。但这些机制并非无懈可击——Canary可以被泄露,ASLR可以被暴力破解或信息泄露绕过,DEP可以用ROP(面向返回编程)技术绕过。
2014年的心脏滴血漏洞(Heartbleed)虽然不是传统意义上的缓冲区溢出——它是读越界而非写越界——但根本原因是一样的:OpenSSL没有检查心跳请求中声明的长度是否与实际数据匹配,导致服务器把内存中的敏感数据(包括私钥)直接返回给了攻击者。一个缺失的长度检查,影响了半个互联网。
写C或C++代码的人至今仍然要面对这个问题。Rust试图用所有权系统在编译期消灭这类错误,但整个世界的底层基础设施不会一夜之间重写。缓冲区溢出不会消失,它只是藏在越来越深的代码路径里,等着下一个忘记检查长度的人。