Linux 栈溢出保护:Canary、NX、ASLR、PIE 和 RELRO
用一份可复现实验拆解 Linux 二进制加固:Stack Canary、FORTIFY、NX、Stack Clash、ASLR、PIE、RELRO 与 CET 分别防什么,以及如何验证构建产物。
Linux 二进制加固不是“编译参数越多越安全”。每项机制只截断一段利用链:Canary 检查栈帧破坏,NX 阻止从栈执行代码,PIE 配合 ASLR 随机化主程序地址,RELRO 收紧重定位后的可写区域。
本文只讨论现代 GNU/Linux 用户态程序。重点放在构建一个样例,逐项检查最终 ELF 是否真的带上了保护。
先看边界:每层保护挡什么
| 机制 | 主要作用 | 不能替代什么 |
|---|---|---|
| Stack Canary | 函数返回前检测部分栈帧覆盖 | 不阻止越界写,也不保护所有函数指针 |
_FORTIFY_SOURCE |
在编译期或运行时检查部分 libc 缓冲区操作 | 依赖优化、对象大小和受支持函数 |
| NX / No-exec stack | 禁止从栈页面执行指令 | 不阻止 ROP、ret2libc 或数据破坏 |
| Stack Clash Protection | 逐页探测大栈分配,避免跨过 Guard Page | 不检测普通缓冲区越界 |
| ASLR + PIE | 随机化主程序、共享库、栈等地址 | 地址泄漏可能削弱随机化效果 |
| Full RELRO | 重定位完成后收紧 GOT 等区域写权限 | 不保护堆、普通全局变量或业务函数指针 |
| CET / Shadow Stack | 在支持的平台上保护返回地址控制流 | 需要 CPU、内核、加载器和构建产物共同支持 |
这些机制应该叠加使用,但仍不能替代边界检查、内存安全 API、模糊测试和代码审计。
准备一份可复现样例
保存为 overflow.c:
#include <stdio.h>
#include <string.h>
static void copy_input(const char *input) {
char buffer[32];
strcpy(buffer, input);
puts(buffer);
}
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <text>\n", argv[0]);
return 2;
}
copy_input(argv[1]);
return 0;
}
这段代码故意保留 strcpy,只能用于隔离实验,不能进入真实服务。
先构建一个关闭常见保护的对照版本:
gcc -O0 -g \
-fno-stack-protector \
-U_FORTIFY_SOURCE \
-fno-pie -no-pie \
-Wl,-z,norelro,-z,execstack \
overflow.c -o overflow-weak
再构建显式加固版本:
gcc -O2 -g \
-fstack-protector-strong \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 \
-fstack-clash-protection \
-fPIE -pie \
-Wl,-z,relro,-z,now,-z,noexecstack \
overflow.c -o overflow-hardened
新版本 GCC 在 GNU/Linux 上还提供 -fhardened,用于启用一组生产加固选项:
gcc --help=hardened
gcc -O2 -g -fhardened overflow.c -o overflow-auto
-fhardened 的具体组合会随 GCC 版本变化。发布记录里应保存编译器版本和完整链接命令,不能只记录“开启了 hardened”。
1. Stack Canary:检查函数返回前的栈帧
-fstack-protector-strong 会保护包含局部数组、局部变量地址引用等高风险特征的函数。它不是“所有有局部变量的函数都保护”;需要覆盖每个函数时才使用 -fstack-protector-all。
检查构建产物:
readelf -Ws ./overflow-hardened | grep __stack_chk_fail
objdump -d ./overflow-hardened | sed -n '/<copy_input>/,+30p'
不同架构的 TLS 寄存器和偏移不同,不要把 %gs:0x14 或 %fs:0x28 当成跨平台规则。稳定证据是受保护函数的入口保存 Canary、返回前比较、失败时调用 __stack_chk_fail。
单独验证 Canary:
gcc -O0 -g \
-fstack-protector-strong \
-U_FORTIFY_SOURCE \
-fno-pie -no-pie \
overflow.c -o overflow-canary
set +e
./overflow-canary "$(printf 'A%.0s' {1..128})"
test $? -ne 0
非零退出只说明破坏被检测到,不说明漏洞已经修复。攻击者若能泄漏 Canary,或在函数返回前改写别的控制数据,仍可能继续利用。
2. _FORTIFY_SOURCE:利用编译器知道的对象大小
启用优化后,glibc 的 Fortify 包装可以把部分 strcpy、memcpy、sprintf 等调用改写成带边界检查的版本。检查是否出现对应符号:
readelf -Ws ./overflow-hardened | grep -E '__.*_chk|__fortify_fail'
是否生成 _chk 调用取决于优化结果和对象大小是否可推导。没有匹配符号不一定代表配置失效;需要结合编译告警和反汇编确认。
_FORTIFY_SOURCE 不是通用内存安全方案。编译器不知道目标缓冲区大小、代码绕过 libc、或越界发生在自定义数据结构中时,它可能帮不上忙。
3. NX 与 Stack Clash Protection:两个不同问题
链接器通过 PT_GNU_STACK 描述栈权限:
readelf -lW ./overflow-weak | grep GNU_STACK
readelf -lW ./overflow-hardened | grep GNU_STACK
加固版本应显示可读写但不可执行的栈,通常为 RW,而不是 RWE。NX 阻止直接执行栈上的 Shellcode,但攻击者仍可能复用现有可执行代码。
-fstack-clash-protection 处理的是另一类问题:大栈分配按页探测,避免一次跳过内核提供的 Guard Page。它不检查 buffer[32] 这种普通越界,也不替代 Canary。
4. ASLR 与 PIE:系统随机化和构建产物缺一不可
Linux 的 randomize_va_space 常见值为:
0:关闭地址空间随机化。1:随机化mmap基址、栈、VDSO 等区域。2:在级别 1 基础上增加堆随机化,通常是默认值。
检查系统配置:
cat /proc/sys/kernel/randomize_va_space
再检查主程序是否为 PIE:
file ./overflow-hardened
readelf -hW ./overflow-hardened | grep 'Type:'
readelf -lW ./overflow-hardened | grep INTERP
PIE 通常显示为 ET_DYN,但共享库也是 ET_DYN。不能只用 Type: DYN 下结论,应结合 file、PT_INTERP 和程序入口判断。
5. RELRO:检查 GNU_RELRO 和 BIND_NOW
Full RELRO 需要两个条件:存在 GNU_RELRO Segment,并且动态链接器在初始化阶段完成相关绑定。
readelf -lW ./overflow-hardened | grep GNU_RELRO
readelf -dW ./overflow-hardened | grep -E 'BIND_NOW|FLAGS.*NOW'
- 只有
GNU_RELRO,通常只能证明 Partial RELRO。 GNU_RELRO加BIND_NOW/NOW,才是常见的 Full RELRO 证据。dlopen与dlsym仍可工作;Full RELRO 不是“禁止运行时加载共享库”。
启动阶段立即解析符号可能增加启动成本,也会更早暴露缺失符号。这是可测试的发布影响,不是关闭 Full RELRO 的默认理由。
6. CET 与 Shadow Stack:构建标记不等于运行时启用
x86 GNU/Linux 可以使用 -fcf-protection 生成控制流保护相关指令和 ELF 属性:
gcc -O2 -fcf-protection=full overflow.c -o overflow-cet
readelf -nW ./overflow-cet | grep -E 'IBT|SHSTK'
看到 IBT 或 SHSTK 属性,只能说明构建产物声明了能力。实际执行还依赖 CPU、内核、动态加载器,以及进程加载的共享对象是否兼容。
Linux 提供的运行时检查包括:
grep -w user_shstk /proc/cpuinfo
grep '^x86_Thread_features' /proc/$$/status
grep '^x86_Thread_features_locked' /proc/$$/status
不要用 CPU 型号年份或云实例名称推测 CET 状态,直接检查目标节点与目标进程。
7. 五分钟验收构建产物
binary=${1:?usage: check-hardening.sh /path/to/binary}
file "$binary"
readelf -hW "$binary" | grep 'Type:'
readelf -lW "$binary" | grep -E 'INTERP|GNU_STACK|GNU_RELRO'
readelf -dW "$binary" | grep -E 'NEEDED|BIND_NOW|FLAGS.*NOW'
readelf -Ws "$binary" | grep -E '__stack_chk_fail|__.*_chk' || true
readelf -nW "$binary" | grep -E 'IBT|SHSTK' || true
这组命令是验收入口,不是完整安全扫描:静态链接、符号裁剪、LTO 和不同 libc 会改变输出。出现疑问时,回到链接命令、Map 文件和反汇编确认。
生产构建建议
- 支持
-fhardened时优先使用,并记录gcc --help=hardened输出。 - 不支持时显式启用
-fstack-protector-strong、PIE、Full RELRO、No-exec stack 和 Stack Clash Protection。 - CI 使用
-Wformat -Werror=format-security,把可静态发现的问题挡在合并前。 - 测试构建使用 AddressSanitizer、UndefinedBehaviorSanitizer 和模糊测试;不要把 Sanitizer 当成生产加固替代品。
- 在实际发行镜像中检查最终二进制,不能只检查 Makefile 或基础镜像声明。
- 修复越界写本身。加固的价值是降低利用成功率、缩小失败窗口,不是给内存破坏兜底。