Linux保护机制
刚接触 Pwn 时,checksec 能告诉我们保护开没开,却不太能解释它们到底卡住了哪一步。比如同样是程序崩溃,可能是跳进不可执行页,也可能是函数返回前发现 Canary 被改了。这两件事的触发位置完全不同。
下面按 NX、Canary、PIE/ASLR、RELRO、FORTIFY 和动态库搜索路径逐项拆解。实验使用 x86-64、Ubuntu 24.04、GCC 13.3.0;其他架构和工具链的指令、默认编译选项可能不同。
把保护放回程序的运行过程里:它们检查的对象不同,不能用一个“防止溢出”概括。
NX:先看页面有没有执行权限
NX 阻止 CPU 从没有执行权限的内存页取指。放在栈里的字节仍然可以被读写,但把 RIP 转过去时会触发异常。Windows 上常见的相关术语是 DEP;Linux 分析中通常直接看页权限和 ELF 的装载信息。
这里有两个容易混淆的地方:
- NX 不会阻止越界写本身。错误的
strcpy仍可能破坏相邻变量。 - 代码复用会使用原本可执行的代码页,所以 NX 不等于禁止 ROP,也不负责随机化地址。
查看 ELF 对栈的要求:
1 | |
关注 GNU_STACK 的 Flags。实验中的结果是 RW,没有 E;LOAD 段则能看到只读、可执行和可写区域的划分。GNU_STACK 只描述栈的相关属性,不能据此宣布进程里所有可写页都不可执行;共享库和运行时的 mmap/mprotect 同样会影响最终权限。
如果进程正在运行,可以对照 /proc/<pid>/maps 中的 r-xp、rw-p 等字段。ELF 文件中的 section 和运行时的 page 不是同一个粒度,实际执行权限最终落在页上。
Stack Canary:在函数退出前发现栈破坏
典型的受保护函数会在栈上保存一份 guard,退出时与原值比较。发生连续的栈缓冲区越界写时,若覆盖返回地址的路径经过这份 guard,就可能在返回之前被发现。
图是常见 x86-64 栈布局的示意,不表示所有函数都有相同偏移;优化也会改变布局。
Canary 检查的是值有没有变化,并不判断某段代码是否“恶意”。它也不是所有内存错误的兜底:不经过 guard 的对象破坏、堆上的越界、读取越界,都不必然触发它。
GCC 的几个选项也有区别:-fstack-protector 只选部分函数;-fstack-protector-strong 扩大覆盖范围;-fstack-protector-all 为所有函数增加保护。看见二进制里导入了 __stack_chk_fail,只能证明存在相关机制,不能直接断言每个函数都受保护。GCC 的选项说明给出了选择规则。
对照实验
下载 hardening.c。核心函数故意保留一个长度检查缺失的复制:
1 | |
把同一份源文件编译为三组:
1 | |
这三组都保持栈不可执行,便于把观察重点放在 Canary 和 FORTIFY。正常输入 hello 都能打印。用 Python 给后两组传入 80 个 A:
1 | |
本地结果:
1 | |
-6 是 Python 在 Linux 上报告进程被 SIGABRT 终止的形式。第一个检测到栈 guard 破坏;第二个在受检查的复制操作处就终止了。仅凭“都崩了”会错过这个差别。未启用保护的样本只用短输入检查运行,长输入属于未定义行为,没有统一的预期结果。
反汇编可以继续确认:
1 | |
这次构建中,前者能看到 guard 读取、比较及 __stack_chk_fail 路径;后者在复制处出现 __strcpy_chk。具体寄存器、偏移和错误文本以本机编译结果为准。
PIE / ASLR:一个解决可重定位,一个决定怎么摆放
PIE 是编译、链接得到的位置无关可执行文件形式;ASLR 是运行时地址布局随机化。启用了 ASLR,普通非 PIE 主程序的代码基址仍可能固定;与此同时,它的栈、堆和共享库地址可以变化。
| 观察对象 | 主要影响因素 | 常用检查方式 |
|---|---|---|
| 主程序是否可作为 PIE 装载 | 编译链接选项 | readelf -h、readelf -d |
| 系统地址随机化策略 | 内核配置 | cat /proc/sys/kernel/randomize_va_space |
| 本次运行的实际地址 | 装载器、内核、进程行为 | /proc/<pid>/maps、调试器 |
下载 addresses.c,它只打印 main、栈变量和一次 malloc 的地址:
1 | |
这次本地运行的 main 地址如下,栈与堆地址在两种构建中均发生了变化:
| 构建 | 第一次 | 第二次 |
|---|---|---|
| 非 PIE | 0x401196 |
0x401196 |
| PIE | 0x639079fa21a9 |
0x5af15f12b1a9 |
这说明 PIE 和 ASLR 要结合起来看。随机化也不是“任何两次地址必不相同”的数学保证。若调试器关闭随机化、系统策略不同,或者采用其他装载方式,观察结果还会变化。
ET_DYN 也不应孤立解读:共享库同样使用这个类型。识别 PIE 时要结合解释器、动态标志和文件用途,而不是见到 DYN 就下结论。
RELRO:动态链接完成后,把不该再写的区域收起来
动态链接时,程序需要把外部符号解析到实际地址。GNU 工具链中的 -z relro 生成 PT_GNU_RELRO 区域,装载器完成相应重定位后将其设为只读;-z now 则要求尽早解析动态符号。
| 常见构建 | 主要特征 | 对典型 GOT/PLT 的影响 |
|---|---|---|
| Partial RELRO | -z relro,仍可采用 lazy binding |
惰性绑定所需的槽位通常仍需可写 |
| Full RELRO | -z relro -z now |
典型构建中包括 GOT 的相关区域在重定位后只读 |
不要只用 section 名称来推断保护;链接脚本和工具链布局可能不同,最终要看段范围和内存权限。用下面两条命令交叉确认 GNU_RELRO 与 BIND_NOW/NOW:
1 | |
Full RELRO 会改变符号解析发生的时间,启动成本取决于动态符号规模和使用情况,不能直接说“一定大大变慢”。具体选项含义见 GNU ld 文档。
FORTIFY:编译器知道对象大小时,多做一层检查
glibc 的 _FORTIFY_SOURCE 借助编译器对对象大小等信息的分析,让部分库函数调用获得额外检查。能在编译时证明安全的调用可能保持原样;无法直接证明的调用可能变成带运行时检查的版本。它既有编译期诊断,也有运行时检查。
上面的 strcpy(buffer, s) 是很直观的例子:目标数组长度已知,输入长度在运行时才知道,因此构建后的 __strcpy_chk 可以在复制前检查边界。优化等级、编译器和 libc 版本会影响最终生成什么代码。
它不会自动覆盖所有自写循环,也不能修好业务层的权限判断。_FORTIFY_SOURCE=2 的存在更不等于消灭所有格式化字符串问题;相关限制取决于具体函数及输入。glibc 的说明列出了检查方式和覆盖的函数。
RPATH / RUNPATH / LD_LIBRARY_PATH
动态库搜索路径里有三个经常混用的概念:
DT_RPATH、DT_RUNPATH是 ELF 动态段里的信息。LD_LIBRARY_PATH是环境变量。$ORIGIN可以表示相关 ELF 所在目录,常用于随程序部署的库。
RPATH 和 RUNPATH 在搜索优先级、依赖传播范围等方面有区别;具有安全执行语义的进程还可能忽略相关环境变量。不能把它们统称为“程序运行时的一个环境变量”。
1 | |
分析时记录 NEEDED、RPATH、RUNPATH,再核对实际装载了哪个文件。如果目录能被低权限用户写入,还要判断它是否真的进入了目标进程的库搜索路径。详细顺序可查 动态装载器 ld.so 手册。
看完 checksec 后还要看什么
| 检查结果 | 下一步要核实的内容 |
|---|---|
| NX enabled | 被使用的内存区域实际有没有执行权限 |
| Canary found | 目标函数是否插桩,相关越界路径是否经过 guard |
| PIE enabled | 运行时是否随机化,已知地址属于哪个映射 |
| Full RELRO | 重定位区域是否只读,程序是否还有其他可写控制数据 |
| FORTIFY enabled | 当前危险调用有没有被强化,对象大小能否推导 |
这些保护增加了错误被发现或利用受阻的机会,但错误的源头仍要回到具体读写操作。对照源代码、反汇编和运行结果,比单记几个开关更可靠。
