16、编译时防御机制:栈保护(Stack Canary)、ASLR、PIE、RELRO、NX位

各位同学,今天我们来聊聊编译时防御机制。说实话,这五个机制是C语言安全编程的「五虎上将」。我在做安全审计时,经常看到一些项目只开了其中一两个,结果漏洞还是被利用得明明白白。你想想看,攻击者又不是傻子,你少了一道锁,他就多了一条路。

这五个机制分别是:栈保护(Stack Canary)、ASLR、PIE、RELRO、NX位。它们各自防守不同的战场,但目标一致——让漏洞利用变得困难甚至不可能。我习惯把它们分成两类:编译时直接生效的(Canary、NX、RELRO),和运行时才起作用的(ASLR、PIE)。

核心观点: 单一防御机制可以被绕过,但组合使用能让攻击者「望码兴叹」。我见过最惨的一次,是某产品只开了NX位,结果被ROP链打得满地找牙。

编译时防御机制知识体系 编译时防御机制 Stack Canary ASLR PIE RELRO NX位 检测栈溢出 随机Canary值 地址空间随机化 堆/栈/库随机 代码段随机化 与ASLR配合 GOT表只读 Full RELRO 栈不可执行 数据段不可执行 组合使用效果最佳 单一机制可被绕过,多层防御才是王道

1. 栈保护(Stack Canary)—— 栈溢出的「哨兵」

栈保护,也叫Canary,名字来源于金丝雀(煤矿里用来检测毒气的鸟)。说白了,就是在栈上放一个「哨兵值」。函数返回前检查这个值有没有被篡改。如果变了,说明栈被踩了,程序直接终止。

我个人习惯在编译时加上 -fstack-protector-strong,而不是普通的 -fstack-protector。为什么?因为前者覆盖更广,连包含局部数组的函数都会被保护。我在项目中遇到过一种情况:某个函数只声明了一个 char buf[8],结果被溢出后直接改写了返回地址。加了strong保护后,这种问题立刻现形。

// 示例:Canary的工作原理
void vulnerable_func(const char *input) {
    char buf[16];
    // 编译器自动在栈上插入Canary值
    strcpy(buf, input);  // 如果溢出,会覆盖Canary
    // 函数返回前检查Canary
    // 若不一致,调用 __stack_chk_fail()
}

提示: 我建议你在所有项目中默认开启 -fstack-protector-strong。性能开销几乎可以忽略不计,但安全性提升明显。GCC和Clang都支持这个选项。

2. ASLR(地址空间布局随机化)—— 让攻击者「摸黑」

ASLR是操作系统层面的机制,不是编译器直接控制的。但编译时的一些选项会影响ASLR的效果。它的核心思想很简单:每次程序运行时,堆、栈、共享库的基地址都随机变化。

你想想看,如果没有ASLR,攻击者知道 system() 函数一定在 0x7ffff7a3d000,那写ROP链就跟抄作业一样简单。有了ASLR,这个地址每次启动都不一样,攻击者只能靠猜。

我记得有一次做渗透测试,目标程序没开ASLR,结果一个简单的缓冲区溢出就直接拿到了shell。开了ASLR之后,同样的漏洞需要结合信息泄露才能利用,难度直接翻了好几倍。

警告: ASLR不是万能的。32位系统下地址空间有限,随机化熵值不够,攻击者可以通过暴力枚举绕过。64位系统就好很多。另外,如果程序有信息泄露漏洞(比如能打印指针值),ASLR也会被绕过。

3. PIE(位置无关可执行文件)—— 让代码段也随机

PIE和ASLR是「黄金搭档」。ASLR随机化的是堆、栈和库,但代码段(.text)的地址是固定的。PIE的作用就是让代码段也变成位置无关的,这样整个程序都能被随机化。

我见过很多开发者只开了ASLR,没开PIE。结果攻击者虽然不知道库函数在哪,但程序自身的函数地址是固定的。比如 main() 永远在 0x400600,那攻击者就可以利用程序内部的gadget来构造ROP链。

编译时加上 -fpie -pie 就能开启PIE。嗯,这里要注意:PIE会带来轻微的性能开销,因为所有地址访问都需要通过GOT/PLT。但在现代CPU上,这点开销基本可以忽略。

# 编译时开启PIE
gcc -fpie -pie -o program program.c

# 检查是否开启了PIE
readelf -h program | grep Type
# 输出应为:DYN (Shared object file)  # 表示开启了PIE
# 如果是:EXEC (Executable file)      # 表示没开

4. RELRO(只读重定位)—— 保护GOT表

RELRO的全称是「Relocation Read-Only」。它的作用是保护GOT表(全局偏移表)不被篡改。攻击者经常通过改写GOT表来劫持函数调用,比如把 printf@got 改成 system 的地址。

RELRO有两种级别:

级别 编译选项 保护范围 性能影响
Partial RELRO 默认开启 GOT表只读(.got.plt除外) 几乎无
Full RELRO -Wl,-z,relro,-z,now 整个GOT表只读 启动时完成所有重定位,略慢

我个人建议使用Full RELRO。虽然启动时多花几毫秒,但换来的安全性是值得的。我曾经分析过一个被攻破的服务器,攻击者就是通过改写 .got.plt 中的 free 函数指针来实现任意地址写的。如果当时开了Full RELRO,这个攻击路径就被堵死了。

关键点: Full RELRO会把GOT表标记为只读,同时把所有延迟绑定(lazy binding)改为立即绑定。这意味着程序启动时会解析所有动态符号,之后GOT表就不能再被修改了。

5. NX位(不可执行位)—— 让数据段「不能跑代码」

NX位,也叫DEP(数据执行保护)。它的作用很简单:标记内存页是否可执行。栈和堆默认被标记为不可执行,这样即使攻击者把shellcode写到了栈上,CPU也不会执行它。

你想想看,如果没有NX位,一个简单的栈溢出就能直接跳转到shellcode执行。有了NX位,攻击者必须走ROP(返回导向编程)路线,利用现有代码片段来拼凑攻击逻辑,难度大了不止一个数量级。

我记得早期做漏洞利用时,最怕的就是目标开了NX。那时候得花大量时间找gadget,拼ROP链。现在大多数系统默认都开了NX,所以攻击者都转向了信息泄露 + ROP的组合拳。

# 检查NX位是否开启
readelf -l program | grep GNU_STACK
# 如果输出包含 "E"(Execute),表示栈可执行(没开NX)
# 如果输出包含 "RW"(Read Write),表示栈不可执行(开了NX)

# 编译时显式开启NX
gcc -Wl,-z,noexecstack -o program program.c

避坑指南: 我曾经遇到过一种情况:用 gcc 编译时没加任何选项,结果默认开了NX。但后来换了一个老版本的编译器,默认居然没开。所以不要依赖编译器的默认行为,一定要显式指定 -Wl,-z,noexecstack

总结:五道防线,缺一不可

这五个机制各自防守不同的攻击面:

  • Stack Canary 防栈溢出
  • ASLR 随机化地址空间
  • PIE 让代码段也随机
  • RELRO 保护GOT表
  • NX位 阻止数据段执行代码

我见过太多只开了一两个机制的项目,结果被攻击者逐个击破。安全不是「够用就行」,而是「能开全开」。你想想看,攻击者只需要找到一个突破口就能得手,而防守方必须把所有路都堵死。

所以我的建议是:在编译时把这五个机制全部开启。虽然不能保证100%安全,但至少能让攻击者多掉几层皮。嗯,这就是我今天想分享的内容。


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