解决缓冲区溢出的漏洞
2014年4月,解决OpenSSL爆出Heartbleed漏洞。缓冲攻击者构造一个最大64KB的区溢心跳请求包,实际只发送1字节payload,漏洞服务器就会把内存里紧挨着的解决64KB数据原样吐回来——包括私钥、会话令牌、缓冲用户密码。区溢这个漏洞的漏洞本质,就是解决一次典型的缓冲区越界读取。
缓冲区溢出这事,缓冲说穿了很简单:程序往一块固定大小的区溢内存区域里写数据,写进去的漏洞比这块区域能装的多,多出来的解决部分就漫出去了。漫出去的缓冲数据会覆盖相邻内存,如果相邻的区溢是函数返回地址,攻击者就能改写它,让程序跳到自己的shellcode上执行。1996年,Aleph One在Phrack杂志上发表《Smashing The Stack For Fun And Profit》,把栈溢出的利用方法讲得明明白白,此后二十年,这玩意儿一直是漏洞利用的头号武器。
那怎么堵?
最直接的办法是在编译层面加保护。GCC有个-fstack-protector选项,原理是在栈上的局部变量和返回地址之间插入一个canary值——你可以理解成一根探针。函数返回前检查canary有没有被改动,被改了说明发生了溢出,程序立刻abort。这招不复杂,但能挡住大部分栈溢出攻击。Linux各发行版从2006年前后默认开启这个选项,效果立竿见影。
可是canary只保护栈。堆上的溢出呢?格式化字符串呢?光靠编译器选项远远不够。
更彻底的做法是让溢出的数据根本无法执行。这就是DEP(数据执行保护)的思路——把内存页标记为不可执行,就算攻击者成功写入了shellcode,CPU也拒绝执行那段内存。Windows从XP SP2开始默认启用DEP,Linux对应的是NX bit。这招逼着攻击者转向ROP(面向返回编程),用现有代码片段拼凑攻击链,门槛一下子高了很多。
ASLR(地址空间布局随机化)是另一层保险。每次程序启动时,栈、堆、共享库的基地址都随机偏移,攻击者没法硬编码跳转地址。2001年OpenBSD最先默认启用,Linux在2005年后跟进。不过ASLR的随机熵有限,32位系统上只有十几个bit的随机空间,暴力猜测并非不可能。64位系统好很多,熵足够大。
但我要说一个容易被忽略的点:这些缓解措施全是“让利用变难”,不是“让漏洞消失”。缓冲区溢出本身还在那里。2019年,WhatsApp被爆出一个基于栈溢出的远程代码执行漏洞(CVE-2019-3568),攻击者通过特制通话请求就能控制目标手机。2021年,Log4Shell虽然本质是JNDI注入,但配合利用链里同样出现了缓冲区相关的内存操作问题。
真正从源头解决,得回到代码本身。C语言不检查数组边界,这是原罪。用strncpy替代strcpy,用snprintf替代sprintf,这些老生常谈的东西至今仍有大把程序员不当回事。我见过一个嵌入式项目,代码里到处是char buf[256]; gets(buf);——gets函数连长度参数都没有,2011年就从C11标准里删掉了,可实际工程里还在用。
语言层面的解法更根本。Rust的所有权和借用检查在编译期就消灭了缓冲区溢出,不需要运行时的canary或ASLR。微软从2019年开始在Windows组件里逐步引入Rust,Android的蓝牙协议栈也用Rust重写了。代价是学习曲线陡、生态迁移成本高,但方向是明确的。
回到Heartbleed。那个漏洞的修复其实只改了几行代码——在读取心跳请求时加一个长度校验,确认payload长度和声明长度一致。就这么简单的一个检查,漏了,全球三分之二的HTTPS服务器受影响。缓冲区溢出防不住,往往不是因为技术太难,是因为写代码的人觉得“这里不会出问题”。









