12、堆溢出防御机制:ASLR、NX、Stack Canary、PIE、RELRO
堆溢出,说白了就是程序在堆上写数据时写过了头。我早年做嵌入式开发时,就吃过这个亏——一个字符串拷贝没控制长度,直接把堆块的管理结构给覆盖了。结果程序跑着跑着就崩,查了两天才定位到问题。从那以后,我对堆溢出防御机制就特别上心。
今天咱们把这五个防御机制掰开揉碎了讲。它们分别是:ASLR、NX(栈不可执行)、Stack Canary、PIE、RELRO。每个机制都有它的原理,也有它的局限性。我一个个说。
核心观点:没有银弹。这五个机制组合使用才能有效防御堆溢出攻击,但每个机制都有绕过方法。
1. ASLR(地址空间布局随机化)
ASLR 的原理很简单:每次程序启动时,把堆、栈、共享库的基地址随机化。攻击者想用固定地址去覆盖函数指针?没门。地址每次都不一样。
我个人习惯在 Linux 上检查 ASLR 是否开启:
# 查看 ASLR 级别
cat /proc/sys/kernel/randomize_va_space
# 0 = 关闭
# 1 = 部分随机化(栈、mmap)
# 2 = 完全随机化(堆也随机化)
我在项目中遇到过一个问题:某个嵌入式 Linux 系统为了性能,把 ASLR 关了。结果攻击者通过堆溢出直接覆盖了函数指针,跳到了恶意代码。嗯,从那以后我坚持生产环境必须开 ASLR 级别 2。
局限性:ASLR 可以被信息泄露绕过。如果程序存在格式化字符串漏洞或地址泄露,攻击者就能拿到随机化后的地址。说白了,ASLR 防的是「盲打」,防不了「精准打击」。
2. NX(栈不可执行)
NX 的全称是 Non-Executable,也叫 DEP(数据执行保护)。它的核心思想:栈和堆上的数据不能被当作代码执行。攻击者把 shellcode 写到栈上?CPU 直接拒绝执行。
检查 NX 是否开启:
# 查看 ELF 文件是否开启 NX
readelf -l your_program | grep GNU_STACK
# 如果输出包含 RWE,说明栈可执行(危险)
# 如果输出包含 RW_,说明栈不可执行(安全)
我记得有一次做渗透测试,目标程序没开 NX。我写了个简单的堆溢出,把 shellcode 放在堆上,然后覆盖函数指针指向堆地址。一次搞定。后来帮他们修复时,第一件事就是加上 NX。
避坑指南:我曾经见过一个团队,为了兼容旧代码,在编译时加了 -z execstack 参数。这等于把 NX 关掉了。千万别这么干。如果你需要执行动态生成的代码,考虑用 mmap 分配可执行内存,而不是把整个栈设为可执行。
但 NX 也有局限性。它不阻止 ROP(Return-Oriented Programming)和 JOP(Jump-Oriented Programming)。攻击者可以用程序里已有的代码片段(gadget)拼凑出恶意逻辑。说白了,NX 让攻击者不能直接执行 shellcode,但人家可以用你的代码来攻击你。
3. Stack Canary(栈金丝雀)
Stack Canary 这个名字挺有意思。早期矿工带金丝雀下井,鸟死了说明有毒气。栈金丝雀也是这个道理——在栈上放一个随机值,函数返回前检查它有没有被改写。如果变了,说明栈被溢出了,程序直接终止。
看个例子:
void vulnerable_function(char *input) {
char buffer[64];
// 编译器自动插入 canary 值
strcpy(buffer, input); // 如果 input 超过 64 字节,canary 会被覆盖
// 函数返回前检查 canary
// 如果 canary 变了,调用 __stack_chk_fail
}
编译时加 -fstack-protector 就能开启:
gcc -fstack-protector -o program program.c
# 或者更强力的 -fstack-protector-strong
gcc -fstack-protector-strong -o program program.c
我个人建议用 -fstack-protector-strong,它比普通版本覆盖更多函数,性能开销却很小。
局限性:Stack Canary 可以被绕过。如果攻击者能通过堆溢出或其他方式泄露 canary 的值,就可以在构造 payload 时保留 canary 不变。另外,某些编译器优化可能会移除 canary 检查。嗯,这里要注意:不要用 -fno-stack-protector 编译生产代码。
4. PIE(位置无关可执行文件)
PIE 让程序本身的代码段也被随机化。没有 PIE 时,即使 ASLR 开了,程序主代码的地址还是固定的。攻击者可以基于固定地址构造 ROP 链。
编译时加 -pie -fpie:
gcc -pie -fpie -o program program.c
检查是否开启了 PIE:
readelf -h program | grep Type
# 如果输出是 DYN (Shared object file),说明开启了 PIE
# 如果输出是 EXEC (Executable file),说明没开
我在项目中遇到过:一个团队只开了 ASLR,没开 PIE。攻击者通过堆溢出覆盖了函数指针,跳转到程序内部的固定地址执行 ROP。后来加上 PIE 后,攻击难度大幅提升。
局限性:PIE 必须配合 ASLR 才能生效。如果 ASLR 被关闭,PIE 也没用。另外,PIE 会增加一点性能开销(约 1-5%),但在安全面前这点代价值得。
5. RELRO(重定位只读)
RELRO 保护的是 GOT(全局偏移表)。攻击者经常通过堆溢出改写 GOT 表项,把函数调用劫持到恶意代码。RELRO 把 GOT 设为只读,阻止这种攻击。
有两种级别:
| 级别 | 编译选项 | 保护范围 | 性能影响 |
|---|---|---|---|
| Partial RELRO | -Wl,-z,relro | 只保护 .got 段 | 几乎无影响 |
| Full RELRO | -Wl,-z,relro,-z,now | 保护 .got 和 .got.plt | 启动时解析所有符号,稍慢 |
我个人强烈建议用 Full RELRO。虽然启动时慢一点点,但安全性提升明显。Partial RELRO 只保护部分 GOT,攻击者仍然可以改写 .got.plt 中的延迟绑定条目。
避坑指南:我曾经见过一个程序,编译时用了 Partial RELRO,结果攻击者通过堆溢出改写了 printf 的 GOT 条目。每次程序调用 printf,实际上都在执行攻击者的代码。换成 Full RELRO 后,这个问题就解决了。
但 RELRO 也不是万能的。如果攻击者能通过其他方式(比如 mmap 的可写可执行区域)绕过 GOT 劫持,RELRO 就防不住了。另外,Full RELRO 要求所有符号在启动时解析完毕,如果程序用了大量动态库,启动时间会变长。
总结一下
这五个机制各有各的用处,也各有各的短板。我个人的经验是:
- ASLR + PIE:必须一起开,一个管库和栈,一个管代码本身
- NX:默认就应该开,除非你有特殊需求
- Stack Canary:编译时一定要加 -fstack-protector-strong
- Full RELRO:别偷懒用 Partial,那等于没防
你想想看,攻击者要同时绕过这五个机制,难度有多大?信息泄露、ROP 构造、canary 猜测、地址爆破……每一步都不容易。这就是纵深防御的意义。
最后说一句:安全没有绝对。这些机制只是提高了攻击门槛,不是彻底杜绝攻击。保持警惕,持续学习,才是安全工程师的生存之道。