栈缓冲区溢出从 1980 年代就存在,但直到 Morris 蠕虫(1988)才真正唤醒行业。应对是零散的——这里打个编译器补丁、那里加个内核特性——每一层保护都反映了当时攻击者在做什么。
我花过太多夜晚盯着线上系统的 coredump,发现某层保护被一个错误的编译选项静默禁用了。这篇文章从实战者视角过一遍每层保护——我排过它们的故障、为测试禁用过它们、也见过攻击者绕过它们。
攻击面:保护的是什么
经典的栈溢出覆盖函数的返回地址:
[buf (64 bytes)] [saved ebp (4)] [return addr (4)] [args...]
↑ 写入越界 ↑ 被覆盖 ↑ 被劫持
每层保护针对这条链的不同部分,引入顺序也很重要:
- Canary——在
ret之前检测破坏。第一道防线,但能泄漏就能绕过。 - NX——让栈上的 shellcode 无法执行。这一项就催生了整个 ROP 体系。
- ASLR——让攻击者找不到目标。迫使他们先泄漏地址。
- PIE——堵上可执行文件自身的 ASLR 缺口。没有 PIE,ASLR 废了一半。
- RELRO——锁死 GOT,让函数指针劫持这条路走不通。
- 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 字节(0x00、0x0a、0x0d、0xff),专门对付基于字符串的溢出——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 的模式:
我在实战中见过三种:
-
先泄漏 canary。 格式化字符串、未初始化栈读取、或任何能读到 canary 位置的信息泄漏。拿到值之后,溢出一字不差地复现就行。这是最常见的绕过,也是 canary + 非 PIE 二进制如此危险的原因——攻击者泄漏一次,跨连接复用 canary。
-
跳过返回地址。 覆盖函数指针(GOT 条目、
vfork回调、C++ vtable),在当前函数返回前就被调用了。canary 检查永远不会触发。这就是 RELRO 重要的原因——没有它,不管有没有 canary,一个 GOT 覆盖就能拿下进程。 -
直接打
__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 拦截危险函数(strcpy、strcat、sprintf、gets、vsprintf),替换为带边界检查的版本。局限:只保护栈破坏和格式化字符串。堆溢出不管。运行 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: 旧内核用栈来放
sigreturntrampoline。如果栈不可执行,信号传递就坏了。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、共享库、vdso、mmap()分配的位置。x86-64 上 ~28 位熵,32 位 8 位。 - 堆:
brk基址仅在级别 2 时加随机偏移。 - 可执行文件: 仅当编译为 PIE 时才随机化。这是最常见的缺口——非 PIE 二进制意味着
readelf -h显示固定地址。
我在实战见过的 ASLR 绕过
-
信息泄漏是唯一的路。 格式化字符串、未初始化栈读取、侧信道计时、推测执行(Spectre)。如果攻击者能从进程读出任何地址,ASLR 就塌了。这就是为什么只开 ASLR 不开 PIE 和 Full RELRO 的加固方案是危险的不完整。
-
32 位熵形同虚设。 x86 上 8-11 位的 mmap 熵意味着猜 libc 基址最多 2048 次。在 fork 型服务器(Apache prefork、旧 SSH)上,攻击者可以在多次连接请求中暴力枚举。这就是 32 位系统多年前就退出安全敏感部署的原因。
-
部分覆盖。 返回地址上的单字节溢出只改变低字节。高字节(带有 ASLR 熵)不变。如果附近的函数做有用的事,攻击者不需要知道完整地址。
-
vsyscall 页面。 旧内核在固定地址(
0xffffffffff600000)映射了 vsyscall 页面。即使有 ASLR 和 PIE,这个页面永远在同一个地址。包含有用的 gadget(ret、syscall)。通过 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 条目(比如 strlen 或 free)指向 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 指令(ENDBR64 或 ENDBR32)标记有效的间接跳转目标。如果间接跳转或调用落到不是 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