Linux保护机制

刚接触 Pwn 时,checksec 能告诉我们保护开没开,却不太能解释它们到底卡住了哪一步。比如同样是程序崩溃,可能是跳进不可执行页,也可能是函数返回前发现 Canary 被改了。这两件事的触发位置完全不同。

下面按 NX、Canary、PIE/ASLR、RELRO、FORTIFY 和动态库搜索路径逐项拆解。实验使用 x86-64、Ubuntu 24.04、GCC 13.3.0;其他架构和工具链的指令、默认编译选项可能不同。

Linux 保护机制分别作用于内存权限、返回检查、装载和函数边界检查

把保护放回程序的运行过程里:它们检查的对象不同,不能用一个“防止溢出”概括。

NX:先看页面有没有执行权限

NX 阻止 CPU 从没有执行权限的内存页取指。放在栈里的字节仍然可以被读写,但把 RIP 转过去时会触发异常。Windows 上常见的相关术语是 DEP;Linux 分析中通常直接看页权限和 ELF 的装载信息。

这里有两个容易混淆的地方:

  • NX 不会阻止越界写本身。错误的 strcpy 仍可能破坏相邻变量。
  • 代码复用会使用原本可执行的代码页,所以 NX 不等于禁止 ROP,也不负责随机化地址。

查看 ELF 对栈的要求:

1
readelf -W -l ./canary

关注 GNU_STACK 的 Flags。实验中的结果是 RW,没有 ELOAD 段则能看到只读、可执行和可写区域的划分。GNU_STACK 只描述栈的相关属性,不能据此宣布进程里所有可写页都不可执行;共享库和运行时的 mmap/mprotect 同样会影响最终权限。

如果进程正在运行,可以对照 /proc/<pid>/maps 中的 r-xprw-p 等字段。ELF 文件中的 section 和运行时的 page 不是同一个粒度,实际执行权限最终落在页上。

Stack Canary:在函数退出前发现栈破坏

典型的受保护函数会在栈上保存一份 guard,退出时与原值比较。发生连续的栈缓冲区越界写时,若覆盖返回地址的路径经过这份 guard,就可能在返回之前被发现。

示意栈布局:局部缓冲区、Canary、保存的帧指针与返回地址

图是常见 x86-64 栈布局的示意,不表示所有函数都有相同偏移;优化也会改变布局。

Canary 检查的是值有没有变化,并不判断某段代码是否“恶意”。它也不是所有内存错误的兜底:不经过 guard 的对象破坏、堆上的越界、读取越界,都不必然触发它。

GCC 的几个选项也有区别:-fstack-protector 只选部分函数;-fstack-protector-strong 扩大覆盖范围;-fstack-protector-all 为所有函数增加保护。看见二进制里导入了 __stack_chk_fail,只能证明存在相关机制,不能直接断言每个函数都受保护。GCC 的选项说明给出了选择规则。

对照实验

下载 hardening.c。核心函数故意保留一个长度检查缺失的复制:

1
2
3
4
5
__attribute__((noinline)) static void copy_arg(const char *s) {
char buffer[32];
strcpy(buffer, s);
puts(buffer);
}

把同一份源文件编译为三组:

1
2
3
4
5
6
7
8
gcc -g -O0 -fno-stack-protector -fno-pie -no-pie \
-Wl,-z,relro,-z,lazy,-z,noexecstack hardening.c -o baseline

gcc -g -O0 -fstack-protector-strong -fPIE -pie \
-Wl,-z,relro,-z,now,-z,noexecstack hardening.c -o canary

gcc -g -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE -pie \
-Wl,-z,relro,-z,now,-z,noexecstack hardening.c -o fortify

这三组都保持栈不可执行,便于把观察重点放在 Canary 和 FORTIFY。正常输入 hello 都能打印。用 Python 给后两组传入 80 个 A

1
2
3
4
5
import subprocess

for name in ("canary", "fortify"):
result = subprocess.run([f"./{name}", "A" * 80], capture_output=True, text=True)
print(name, result.returncode, result.stderr.strip())

本地结果:

1
2
canary -6 *** stack smashing detected ***: terminated
fortify -6 *** buffer overflow detected ***: terminated

-6 是 Python 在 Linux 上报告进程被 SIGABRT 终止的形式。第一个检测到栈 guard 破坏;第二个在受检查的复制操作处就终止了。仅凭“都崩了”会错过这个差别。未启用保护的样本只用短输入检查运行,长输入属于未定义行为,没有统一的预期结果。

反汇编可以继续确认:

1
2
objdump -d -M intel ./canary
objdump -d -M intel ./fortify

这次构建中,前者能看到 guard 读取、比较及 __stack_chk_fail 路径;后者在复制处出现 __strcpy_chk。具体寄存器、偏移和错误文本以本机编译结果为准。

PIE / ASLR:一个解决可重定位,一个决定怎么摆放

PIE 是编译、链接得到的位置无关可执行文件形式;ASLR 是运行时地址布局随机化。启用了 ASLR,普通非 PIE 主程序的代码基址仍可能固定;与此同时,它的栈、堆和共享库地址可以变化。

观察对象 主要影响因素 常用检查方式
主程序是否可作为 PIE 装载 编译链接选项 readelf -hreadelf -d
系统地址随机化策略 内核配置 cat /proc/sys/kernel/randomize_va_space
本次运行的实际地址 装载器、内核、进程行为 /proc/<pid>/maps、调试器

下载 addresses.c,它只打印 main、栈变量和一次 malloc 的地址:

1
2
3
4
5
6
gcc -fno-pie -no-pie addresses.c -o addr-exec
gcc -fPIE -pie addresses.c -o addr-pie
./addr-exec
./addr-exec
./addr-pie
./addr-pie

这次本地运行的 main 地址如下,栈与堆地址在两种构建中均发生了变化:

构建 第一次 第二次
非 PIE 0x401196 0x401196
PIE 0x639079fa21a9 0x5af15f12b1a9

这说明 PIE 和 ASLR 要结合起来看。随机化也不是“任何两次地址必不相同”的数学保证。若调试器关闭随机化、系统策略不同,或者采用其他装载方式,观察结果还会变化。

ET_DYN 也不应孤立解读:共享库同样使用这个类型。识别 PIE 时要结合解释器、动态标志和文件用途,而不是见到 DYN 就下结论。

RELRO:动态链接完成后,把不该再写的区域收起来

动态链接时,程序需要把外部符号解析到实际地址。GNU 工具链中的 -z relro 生成 PT_GNU_RELRO 区域,装载器完成相应重定位后将其设为只读;-z now 则要求尽早解析动态符号。

RELRO 时间线:装载、重定位、只读保护与后续运行

常见构建 主要特征 对典型 GOT/PLT 的影响
Partial RELRO -z relro,仍可采用 lazy binding 惰性绑定所需的槽位通常仍需可写
Full RELRO -z relro -z now 典型构建中包括 GOT 的相关区域在重定位后只读

不要只用 section 名称来推断保护;链接脚本和工具链布局可能不同,最终要看段范围和内存权限。用下面两条命令交叉确认 GNU_RELROBIND_NOW/NOW

1
2
readelf -W -l ./canary
readelf -W -d ./canary

Full RELRO 会改变符号解析发生的时间,启动成本取决于动态符号规模和使用情况,不能直接说“一定大大变慢”。具体选项含义见 GNU ld 文档

FORTIFY:编译器知道对象大小时,多做一层检查

glibc 的 _FORTIFY_SOURCE 借助编译器对对象大小等信息的分析,让部分库函数调用获得额外检查。能在编译时证明安全的调用可能保持原样;无法直接证明的调用可能变成带运行时检查的版本。它既有编译期诊断,也有运行时检查。

上面的 strcpy(buffer, s) 是很直观的例子:目标数组长度已知,输入长度在运行时才知道,因此构建后的 __strcpy_chk 可以在复制前检查边界。优化等级、编译器和 libc 版本会影响最终生成什么代码。

它不会自动覆盖所有自写循环,也不能修好业务层的权限判断。_FORTIFY_SOURCE=2 的存在更不等于消灭所有格式化字符串问题;相关限制取决于具体函数及输入。glibc 的说明列出了检查方式和覆盖的函数。

RPATH / RUNPATH / LD_LIBRARY_PATH

动态库搜索路径里有三个经常混用的概念:

  • DT_RPATHDT_RUNPATH 是 ELF 动态段里的信息。
  • LD_LIBRARY_PATH 是环境变量。
  • $ORIGIN 可以表示相关 ELF 所在目录,常用于随程序部署的库。

RPATH 和 RUNPATH 在搜索优先级、依赖传播范围等方面有区别;具有安全执行语义的进程还可能忽略相关环境变量。不能把它们统称为“程序运行时的一个环境变量”。

1
readelf -W -d ./program

分析时记录 NEEDEDRPATHRUNPATH,再核对实际装载了哪个文件。如果目录能被低权限用户写入,还要判断它是否真的进入了目标进程的库搜索路径。详细顺序可查 动态装载器 ld.so 手册

看完 checksec 后还要看什么

检查结果 下一步要核实的内容
NX enabled 被使用的内存区域实际有没有执行权限
Canary found 目标函数是否插桩,相关越界路径是否经过 guard
PIE enabled 运行时是否随机化,已知地址属于哪个映射
Full RELRO 重定位区域是否只读,程序是否还有其他可写控制数据
FORTIFY enabled 当前危险调用有没有被强化,对象大小能否推导

这些保护增加了错误被发现或利用受阻的机会,但错误的源头仍要回到具体读写操作。对照源代码、反汇编和运行结果,比单记几个开关更可靠。


Linux保护机制
https://g1at.github.io/2023/02/04/浅谈几种Linux保护机制/
作者
g0at
发布于
2023年2月4日
许可协议