栈缓冲区溢出从 1980 年代就存在,但直到 Morris 蠕虫(1988)才真正唤醒行业。应对是零散的——这里打个编译器补丁、那里加个内核特性——每一层保护都反映了当时攻击者在做什么。

我花过太多夜晚盯着线上系统的 coredump,发现某层保护被一个错误的编译选项静默禁用了。这篇文章从实战者视角过一遍每层保护——我排过它们的故障、为测试禁用过它们、也见过攻击者绕过它们。

攻击面:保护的是什么

经典的栈溢出覆盖函数的返回地址:

[buf (64 bytes)] [saved ebp (4)] [return addr (4)] [args...]
    ↑ 写入越界     ↑ 被覆盖      ↑ 被劫持

每层保护针对这条链的不同部分,引入顺序也很重要:

  1. Canary——在 ret 之前检测破坏。第一道防线,但能泄漏就能绕过。
  2. NX——让栈上的 shellcode 无法执行。这一项就催生了整个 ROP 体系。
  3. ASLR——让攻击者找不到目标。迫使他们先泄漏地址。
  4. PIE——堵上可执行文件自身的 ASLR 缺口。没有 PIE,ASLR 废了一半。
  5. RELRO——锁死 GOT,让函数指针劫持这条路走不通。
  6. CFI/CET——硬件辅助的终局方案,还在逐步部署中。

1. 编译器级别的保护

StackGuard / Stack canary(-fstack-protector

StackGuard 最初是 Immunix 的 GCC 补丁。思路很简单:在局部变量和保存的帧指针/返回地址之间放一个随机值(canary),ret 之前检查它,变了就 abort。

GCC 分三个级别:

  • -fstack-protector——只保护有 8+ 字节局部数组的函数。
  • -fstack-protector-strong——保护所有有局部变量的函数(范围更广,代码大小增加有限)。
  • -fstack-protector-all——每个函数都保护。别在生产用这个,除非你擅长跟经理解释 15% 的性能回退。

Canary 存在 %gs:0x14(线程局部存储),从 /dev/urandom 取种子,有时间回退。它包含 null 字节(0x000x0a0x0d0xff),专门对付基于字符串的溢出——strcpy 遇到 null 就停了。

push   %ebp
mov    %esp,%ebp
sub    $0x??,%esp
mov    %gs:0x14,%eax       ; 从 TLS 加载 canary
mov    %eax,-0x??(%ebp)    ; 放在返回地址之前
; ... 函数体 ...
mov    -0x??(%ebp),%eax    ; 重新加载 canary
xor    %gs:0x14,%eax       ; 和原始值比较
jne    .L_stack_chk_fail   ; 不同就 abort
leave
ret

线上实际绕过 canary 的模式:

我在实战中见过三种:

  1. 先泄漏 canary。 格式化字符串、未初始化栈读取、或任何能读到 canary 位置的信息泄漏。拿到值之后,溢出一字不差地复现就行。这是最常见的绕过,也是 canary + 非 PIE 二进制如此危险的原因——攻击者泄漏一次,跨连接复用 canary。

  2. 跳过返回地址。 覆盖函数指针(GOT 条目、vfork 回调、C++ vtable),在当前函数返回前就被调用了。canary 检查永远不会触发。这就是 RELRO 重要的原因——没有它,不管有没有 canary,一个 GOT 覆盖就能拿下进程。

  3. 直接打 __stack_chk_fail 如果二进制是 Partial RELRO(大多数系统默认),__stack_chk_fail 的 GOT 条目是可写的。把它覆盖成一个 gadget 地址,触发 canary 检查,你的"abort"就成了通往 ROP 链的跳板。我在 CTF 里发现这个的时候,花了不少时间才说服自己在生产环境也能用。确实能。

性能现实: -fstack-protector-strong 增加约 1-3% 代码大小,现代硬件上 CPU 开销可忽略(canary 加载在 L1 缓存)。-fstack-protector-all 在热路径上可达 10-15%,我见过它把函数推出 icache 行边界导致可测的变慢。除非有特殊理由,否则用 -strong

StackShield

StackShield 走另一条路——函数入口时把返回地址复制到一个受保护的特殊栈,退出时恢复。即使栈帧被破坏,返回地址也是安全的。主要局限:单字节溢出只覆盖 %ebp 的最低字节不会被抓到(受保护栈上的返回地址仍是好的,但帧指针本身被破坏了,通过帧指针的代码路径仍可能被利用)。

我没在生产环境见过 StackShield。基本只有历史意义了。

2. 库级别的保护

FormatGuard

一个 glibc 补丁,在编译时检查 *printf() 格式化字符串——比较格式说明符数量和实际参数数量。不匹配就记录并 abort。直接调 write()syscall() 的代码保护不了,而且它从未进入主线 glibc。只在高度定制的嵌入式系统上能看到。

Libsafe

通过 LD_PRELOAD 拦截危险函数(strcpystrcatsprintfgetsvsprintf),替换为带边界检查的版本。局限:只保护栈破坏和格式化字符串。堆溢出不管。运行 RHEL 4 时代发行版的遗留系统偶尔还有。

3. 不可执行栈(NX / DEP)

这是有史以来影响最大的保护。也是上线时搞坏最多东西的。

软件方案:Linux 内核补丁

CPU 支持 NX 之前,内核补丁通过各种创意手段实现类似效果:

  • Solar Designer 的内核补丁(1997):缩小代码段限制,使高地址的栈落在可执行范围之外。ret 到栈地址会触发 GPF。巧妙,但意味着总 RAM 不能超过 ~2.7GB(CS limit 是 0xABBBFFFF 左右)。我接手过一台打了这个补丁的机器,花了一天才搞明白为什么 4GB RAM 只能寻址 2.7GB。

  • Exec-shield(Red Hat):维护一个进程级"可执行边界"。边界以上的(栈、mmap、堆)默认不可执行。可以用 sysctl -w kernel.exec-shield=0 切换。RHEL3 带了它,搞坏了大量 JIT 编译器、嵌套函数 trampoline,以及在栈上用 mprotect 的旧 BSD 移植代码。

  • kNoX、RSX:更早 2.4 内核的类似方案。如果你还在跑这么老的系统,你有更大的问题。

硬件方案:NX 位(AMD)/ XD 位(Intel)

AMD 在 K8(2003)加入 NX 位。Intel 在 Prescott(2004)跟进。内核在页表项里设一个位。如果页面有 NX 位且 %rip 指向它,触发缺页异常。

检查 NX 状态:

$ dmesg | grep -i 'execute|NX'
NX (Execute Disable) protection: active

ELF 的 GNU_STACK 头控制加载器是否标记栈为可执行:

$ readelf -lW ./a.out | grep GNU_STACK
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10

RWE = 栈可执行。RW- = 不可执行。如果你的二进制在生产环境有 RWE,你最好有一个非常好的理由(带闭源 JIT 的 JVM、旧 Lisp 运行时、2004 年的某个供应商库)。

改变一切的 NX 绕过:ROP

NX 迫使攻击者停止在栈上注入 shellcode。直接反应是 return-to-libc——把返回地址直接指向 libc 的 system(),参数在栈上设置好。

但 return-to-libc 有限制,最多链几个 libc 调用。ROP(Return-Oriented Programming)打开了大局面:在二进制或库里找以 ret 结尾的短指令序列(gadget),串联起来执行任意逻辑。每个 ret 从栈上弹出下一个 gadget 地址并跳过去。

NX → ROP 的转变是过去 20 年漏洞利用技术最重要的演变。之后的每个绕过都是 ROP 的变种。

我踩过的 NX 坑:

  • JIT 代码在堆上: 如果你有 JIT 往 mmap 内存写代码,确保它用 mmap(PROT_READ | PROT_WRITE | PROT_EXEC) 明确指定。有些 JIT 假设堆是可执行的(默认十年以上不是了)。如果 JIT 在堆页面里的指令指针上 SIGSEGV,就是这个原因。

  • mprotect 作为攻击原语: 如果攻击者控制了一个 mprotect 调用(通过 ROP),他们可以把任何页面标记为可执行,完全绕过 NX。没有 PaX 级别的 MPROTECT 限制就很难防。

  • 信号处理 trampoline: 旧内核用栈来放 sigreturn trampoline。如果栈不可执行,信号传递就坏了。2.6.x 通过把 trampoline 移到 vsyscall 页面修复。

4. ASLR(地址空间布局随机化)

NX 催生了 ROP,ROP 需要知道代码在哪里。ASLR 否定了这个知识。

Linux ASLR 级别

/proc/sys/kernel/randomize_va_space 控制:

含义
0 禁用
1 部分——栈、mmap、共享库随机化
2 完全——加上 brk 堆随机化

自内核 2.6.12 起默认为 2。除非有人明确设置,基本见不到 1。

$ cat /proc/sys/kernel/randomize_va_space
2

为测试禁用(别在生产干这事):

$ sudo sysctl -w kernel.randomize_va_space=0

实际随机化什么

  • 栈: exec 时加随机偏移。x86-64 上 22 位熵,32 位 11 位。
  • mmap 基址: ld.so、共享库、vdsommap() 分配的位置。x86-64 上 ~28 位熵,32 位 8 位。
  • 堆: brk 基址仅在级别 2 时加随机偏移。
  • 可执行文件: 仅当编译为 PIE 时才随机化。这是最常见的缺口——非 PIE 二进制意味着 readelf -h 显示固定地址。

我在实战见过的 ASLR 绕过

  1. 信息泄漏是唯一的路。 格式化字符串、未初始化栈读取、侧信道计时、推测执行(Spectre)。如果攻击者能从进程读出任何地址,ASLR 就塌了。这就是为什么只开 ASLR 不开 PIE 和 Full RELRO 的加固方案是危险的不完整。

  2. 32 位熵形同虚设。 x86 上 8-11 位的 mmap 熵意味着猜 libc 基址最多 2048 次。在 fork 型服务器(Apache prefork、旧 SSH)上,攻击者可以在多次连接请求中暴力枚举。这就是 32 位系统多年前就退出安全敏感部署的原因。

  3. 部分覆盖。 返回地址上的单字节溢出只改变低字节。高字节(带有 ASLR 熵)不变。如果附近的函数做有用的事,攻击者不需要知道完整地址。

  4. vsyscall 页面。 旧内核在固定地址(0xffffffffff600000)映射了 vsyscall 页面。即使有 ASLR 和 PIE,这个页面永远在同一个地址。包含有用的 gadget(retsyscall)。通过 ret2vsyscall 技术攻击。现代内核用 vdso(正确随机化)替代 vsyscall,或用仿真模式(trap-and-emulate,很慢)。

5. PIE(位置无关可执行文件)

没有 PIE,可执行文件的 text 段在固定地址。ASLR 随机化库和栈,但主二进制始终在同一位置。攻击者从二进制里读 gadget(prologue、__libc_csu_init 等),不需要任何泄漏。

检查:

$ readelf -h ./a.out | grep Type
Type:  DYN (Shared object file)    ← PIE
Type:  EXEC (Executable file)      ← 不是 PIE

PIE 的性能代价:

x86-64 上大约 5-10%,x86-32 上 10-15%(因为寄存器压力——32 位 PIC 需要额外寄存器存 GOT 基址)。可测量但很少是关键问题。例外:数据库和延迟敏感的网络服务——每微秒都重要。我见过有组织把二进制固定到特定地址来避免 PIE 开销。然后因为非 PIE 二进制使 ASLR 绕过变简单而被攻破。

现代发行版默认 PIE。如果你用旧工具链从源码编译,-no-pie 是默认值,必须显式传 -fpie -pie。我审计过 Docker 镜像,基础镜像是 Debian Buster,但应用在 Ubuntu 16.04 编译,没有 PIE 标志。检查你的构建容器。

6. RELRO(只读重定位)

RELRO 控制 GOT 的可写性。GOT 覆盖是绕过 canary + NX 的标准方式:覆盖一个频繁调用函数的 GOT 条目(比如 strlenfree)指向 system(),下一次调用就是你的 shell。

三种状态:

  • 无 RELRO——整个 GOT 可写。-Wl,-z,norelro。几乎没有现代工具链默认这样。
  • Partial RELRO——.got(全局变量)设为只读,但 .got.plt(用于延迟绑定的函数指针)保持可写。这是 GCC/Clang 的默认值。
  • Full RELRO——-Wl,-z,relro,-z,now。所有重定位在加载时解决,然后整个 GOT 通过 mprotect 设为只读。禁用延迟绑定。

检查:

$ readelf -l ./a.out | grep GNU_RELRO
  GNU_RELRO      0x000e78 0x00000000000e78 ... R   0x1

Full RELRO 何时搞坏事情:

  • 启动时间增加,因为所有符号在加载时而不是首次调用时解决。对于有数千个符号的大型 C++ 应用,启动可能增加 200-500ms。通常 OK。对于需要在 <50ms 内启动退出的 CLI 工具就不行。
  • 依赖延迟绑定的插件或 dlopen’d 库会出问题。如果插件系统用 RTLD_LAZY 加载 .so,主二进制的 Full RELRO 强制立即解析,如果插件引入新的符号依赖就可能失败。
  • glibc 的 dlsym 仍然可以工作——Full RELRO 不阻止运行时符号查找,它只是在初始化后把 GOT 设为只读。

我踩过的一个坑: Partial RELRO + Full ASLR + PIE + NX 看起来是个加固了的二进制。但服务器里一个格式化字符串 bug,加上它用了延迟绑定,攻击者就有了一个可写的 .got.plt。他们把 free 覆盖成 system,等下一次 free(buf)buf 包含 /bin/sh,你就完了。Full RELRO 能堵住这个,但大多数构建系统默认不加 -z now

7. 内核级 MAC 和加固

PaX

PaX 是超越主线 Linux 的全面内核补丁,是 Grsecurity 的一部分(维护版本)。几个关键特性:

  • MPROTECT:不能创建同时可写和可执行的映射。这阻止了 JIT spray 和 shellcode 加载器常用的"分配 RWX 内存、写 shellcode、跳过去"模式。
  • RANDMMAP:每次调用独立随机化 mmap 基址,不只是每 exec 一次。即使攻击者泄漏了一个分配地址,也无法推断其他地址。
  • 内核常量加固:syscall 表、IDT、GDT 在初始化后标记为只读。

独立 PaX 死了。只有 Grsecurity 能给你 PaX,而且现代内核需要付费订阅。

PaX 实战: 我在生产环境跑过大约三年的 Grsecurity 内核。唯一出问题的是一个 JVM,它用 mprotect 把 JIT 代码页设为 RWX。我们不得不给 Java 进程加 PaX 例外。如果你没有遗留 JIT 或二进制 blob 驱动,PaX 兼容性相当好。

Grsecurity

PaX + MAC(强制访问控制)。关键特性:

  • /tmp 加固,防止符号链接竞争。
  • ptrace 限制为只能追踪子进程。
  • Chroot 加固。
  • 网络栈加固。

SELinux

本身不是缓冲区溢出保护,但限制爆炸半径。如果进程被攻破,SELinux 限制它能做什么(在特定目录执行代码、写特定文件、连特定端口)。Chrome 沙箱严重依赖 SELinux(或 AppArmor)来限制渲染器被攻破后的影响。

SELinux 不能替代上面的保护——它是补充。一个没有 canary、NX、ASLR、PIE 的二进制在 SELinux 下照样可以被随便利用。你需要两者都有。

8. 现代 CFI(控制流完整性)

硬件辅助的前向和后向 CFI。这是行业的方向。

Shadow Stack(CET)

一个独立的、硬件保护的栈,存储返回地址。CALL 时返回地址同时压入常规栈和 shadow stack。RET 时 CPU 比较两者。不匹配 → #CP(Control Protection)异常。

没有软件漏洞能破坏 shadow stack——它在 CPU 管理的受保护内存里。即使有任意读写,也改变不了 shadow stack 条目。

IBT(间接分支追踪)

ENDBRANCH 指令(ENDBR64ENDBR32)标记有效的间接跳转目标。如果间接跳转或调用落到不是 ENDBRANCH 的指令上,CPU 触发异常。这阻止了跳转到 gadget 中间的 ROP/JOP 链。

当前状态: Intel CET 从 Tiger Lake(2020+)开始支持。AMD 从 Zen 3(2022)支持 Shadow Stack。Linux 内核 5.18+ 支持 IBT,6.1+ 支持 Shadow Stack。glibc 2.33+ 支持 CET 二进制。

现实检验: 截至 2026 年,大多数生产环境的 x86-64 系统没有 CET 能力的 CPU。Shadow Stack 在云环境中尤其罕见——支持 CET 的 EC2 实例仍然是少数。但 IBT 越来越普遍(Intel 要求 Sapphire Rapids Xeon 支持)。如果你在构建现代部署目标,在编译时启用 CET(GCC 的 -fcf-protection=full)。它是向前兼容的:带 CET 标记的二进制在旧硬件上也能跑(标记在旧 CPU 上是 NOP)。

9. 检查二进制保护状态

我常备这个清单——数不清多少次一个"加固"的二进制缺了其中一项:

# PIE
readelf -h ./a.out | grep Type

# NX 栈
readelf -lW ./a.out | grep GNU_STACK

# RELRO(检查是否 NOW——Partial vs Full)
readelf -a ./a.out | grep BIND_NOW
readelf -l ./a.out | grep GNU_RELRO

# Canary
objdump -d ./a.out | grep __stack_chk_fail

# ASLR(系统级)
cat /proc/sys/kernel/randomize_va_space

汇总:

保护 检查方式 禁用(仅测试)
Canary `objdump -d grep __stack_chk_fail`
NX `readelf -lW grep GNU_STACK`
PIE `readelf -h grep Type`
RELRO `readelf -l grep GNU_RELRO+BIND_NOW`
ASLR /proc/sys/kernel/randomize_va_space sysctl -w kernel.randomize_va_space=0

生产二进制应该有什么

一个现代化、加固的 x86-64 二进制:

  • PIE(Type DYN)
  • Full RELRO(GNU_RELRO 段 + BIND_NOW)
  • NX 栈(GNU_STACK 标记 RW-)
  • Canary-fstack-protector-strong
  • CET-fcf-protection=full)如果目标支持
  • ASLR 系统级必须为 2

缺任何一项就是缺口。这个缺口能否被利用取决于你的其他攻击面——但我的经验是,攻击者找到缺失那块比你想象得快。

参考链接

  • PaX 文档:https://pax.grsecurity.net/
  • Grsecurity 特性:https://grsecurity.net/features.php
  • Intel CET 规范:https://www.intel.com/content/www/us/en/developer/articles/technical/technical-look-control-flow-enforcement-technology.html
  • Linux ASLR 文档:https://www.kernel.org/doc/html/latest/admin-guide/sysctl/kernel.html#randomize-va-space