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 文件和反汇编确认。

生产构建建议

  1. 支持 -fhardened 时优先使用,并记录 gcc --help=hardened 输出。
  2. 不支持时显式启用 -fstack-protector-strong、PIE、Full RELRO、No-exec stack 和 Stack Clash Protection。
  3. CI 使用 -Wformat -Werror=format-security,把可静态发现的问题挡在合并前。
  4. 测试构建使用 AddressSanitizer、UndefinedBehaviorSanitizer 和模糊测试;不要把 Sanitizer 当成生产加固替代品。
  5. 在实际发行镜像中检查最终二进制,不能只检查 Makefile 或基础镜像声明。
  6. 修复越界写本身。加固的价值是降低利用成功率、缩小失败窗口,不是给内存破坏兜底。

参考链接