12、堆溢出防御机制:ASLR、NX、Stack Canary、PIE、RELRO

堆溢出,说白了就是程序在堆上写数据时写过了头。我早年做嵌入式开发时,就吃过这个亏——一个字符串拷贝没控制长度,直接把堆块的管理结构给覆盖了。结果程序跑着跑着就崩,查了两天才定位到问题。从那以后,我对堆溢出防御机制就特别上心。

今天咱们把这五个防御机制掰开揉碎了讲。它们分别是:ASLR、NX(栈不可执行)、Stack Canary、PIE、RELRO。每个机制都有它的原理,也有它的局限性。我一个个说。

核心观点:没有银弹。这五个机制组合使用才能有效防御堆溢出攻击,但每个机制都有绕过方法。

堆溢出防御机制知识体系 堆溢出防御 ASLR NX Stack Canary PIE RELRO 地址随机化 栈/堆不可执行 栈溢出检测 代码地址随机化 GOT只读保护 可被信息泄露绕过 不阻止ROP/JOP 可被覆盖绕过 需配合ASLR Partial RELRO可绕过 组合使用才是王道 ASLR + PIE + NX + Full RELRO + Stack Canary 单一机制可被绕过,组合防御大幅提升攻击成本

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 猜测、地址爆破……每一步都不容易。这就是纵深防御的意义。

最后说一句:安全没有绝对。这些机制只是提高了攻击门槛,不是彻底杜绝攻击。保持警惕,持续学习,才是安全工程师的生存之道。