ASLR 与安全防护

本页目录

运行时加固让某些错误更早终止,或降低错误被进一步利用的机会。它不证明程序不存在越界、释放后使用和数据竞争。理解每个机制保护的对象、需要的工具链支持以及没有覆盖的部分,比收集一组“全部已开启”的标签更有用。

不同机制限制不同能力,不能互相替代 ASLR + PIE 降低地址位置的可预测性 NX / W^X 限制数据页直接作为代码执行 RELRO + NOW 重定位后保护相应链接数据 Stack protector 检测部分栈内破坏 Shadow stack 校验返回地址的控制流 CFI / IBT 约束特定间接控制转移

ASLR 与 PIE:允许重定位,才有相应随机化空间

ASLR 根据内核策略随机化部分地址布局。Linux randomize_va_space 的 0 表示关闭相应随机化,1 随机化 mmap、栈、vDSO 等,2 进一步包含堆相关随机化。默认值和实际范围与内核配置、架构有关,不是所有对象每次分配都各自独立随机。内核 randomize_va_space

PIE 让主可执行映像可按加载偏移定位,配合 ASLR 使其加载地址变化;非 PIE 的主映像通常使用链接时固定布局,但其动态库、栈等仍可随机化。因此“不是 PIE 就整个进程没有 ASLR”也不准确。

编译器是否默认生成 PIE 由发行版构建和工具链设置决定。需要明确时,编译使用 -fPIE,最终链接使用 -pie;构建静态库时是否应 -fPIC 是另一项目标要求。不能只给一个源文件加链接开关,就忽略整个工程的对象兼容性。

ASLR 限制可预测性,不防止地址通过其他错误泄露。fork 往往继承已有布局,独立 exec 才重新建立映像;也不能把某个示例中的固定地址或随机范围泛化到所有平台。

RELRO:先完成需要的写入,再收紧权限

链接器用 PT_GNU_RELRO 标记适合重定位后转为只读的区域,动态加载器完成相应工作后调整权限。-z relro 请求该区域,-z now 请求即时绑定;完整 RELRO 通常要求两者配合,单独 NOW 不会自动创建所有只读保护。

lazy binding 需要保留相关可修改位置,布局会影响哪些 GOT 区域能保护。不能只看到 .got 或 .dynamic 名字就保证所有字节都按同一种方式只读;应结合程序头、动态标记和实际映射。GNU ld 选项

即时解析把一些工作移到启动阶段,成本依符号与依赖规模而定。RELRO 保护对应映射,不能阻止任意堆对象中的函数指针被错误覆盖,也不阻止仍具备权限的程序主动改变某些内存策略。

RELRO 是加载时的权限变化,不是 section 名字自带的属性 链接时描述范围 PT_GNU_RELRO + NOW 加载时填入地址 完成此时需要的重定位 随后收紧权限 相应页面转为只读 三个证据:产物范围 → 绑定策略 → 当前映射权限 只看 readelf 的一项输出,不能代替整个加载阶段的验证 示意适用的动态 ELF;页边界与实际范围由链接布局和加载器实现共同决定。

如何把文件中的范围和运行时只读区域对起来

readelf -lW 中 GNU_RELRO 给出链接视角的虚拟地址与长度;对 PIE,先取得主映像加载偏移 B,再换算运行时范围。maps 按页面和权限显示区间,因此不能期待一行 maps 的起止地址与 GNU_RELRO 原始字节范围完全一致。可将地址换算方法与 ELF 文件格式 对照使用。

观察可以得出的结论不足以推出
有 GNU_RELRO链接器描述了候选保护范围当前所有相关页已经只读
有 BIND_NOW / NOW请求提前解析适用动态符号任意函数指针都受保护
映射对应范围只读当前不能按普通写权限修改进程生命周期中永远不会改变权限
同目录另一个库未检查主程序证据仍有效所有加载对象都使用同一加固配置

检查插件和依赖库时应以各自对象为单位。主程序带有某种标记,不会在事后自动把其他对象重新编译成同样的形式。

重定位为什么必须先写,后保护?

把选项和时刻一起观察:仅有 NOW 时函数地址可以提前解析,却没有由此自动得到 RELRO 保护;仅有 RELRO 时,仍需延迟修改的调用槽位可能留在保护范围外。保护完成后普通堆仍可写,说明加固结论必须指向具体区域。

正在呈现知识画面
重定位为什么必须先写,后保护?

RELRO 描述加载后要收紧的范围,NOW 决定适用函数绑定是否提前完成。普通堆对象不因这些标记自动只读。

栈保护:检测部分栈帧破坏

stack protector 在选中的函数中保存保护值,并在规定退出路径检查。触发时通常进入失败处理并终止。保护范围依 -fstack-protector、-fstack-protector-strong、-fstack-protector-all 与目标编译器规则而不同,不能描述为每个函数都必定放一个固定 8 字节值。

它主要发现穿过相关布局的栈破坏,不能检测所有局部变量覆盖、堆越界或任意数据损坏。保护值的初始化与运行库、架构有关,不能把 AT_RANDOM 的存在直接说成由 randomize_va_space 开关产生。GCC 栈保护选项

NX、W^X 与栈冲突保护

NX/XN 等页执行权限控制数据页能否取指执行。ELF 程序头与运行时映射决定实际权限,PT_GNU_STACK 可表达可执行栈需求;匿名堆和栈并不是都由一个 ELF PF_X 字段直接描述。

W^X 是不同时给同一可写区域执行能力的策略。JIT 等运行时可能需要明确的权限切换或双映射设计,不能只检查一次启动映射就认为整个生命周期不变。

NX 不阻止复用已有可执行代码,也不防止纯数据错误。stack-clash-protection 则通过栈增长探测等方式避免跨过保护区,是另一类机制,与 stack canary 不能互相替代。

FORTIFY_SOURCE:利用对象大小检查部分库调用

FORTIFY 根据编译器能获得的对象大小信息,为部分 libc 接口提供编译期或运行时检查。支持的工具链上,级别 3 可使用动态对象大小信息;效果取决于优化、调用形式和版本。

它不插桩所有 C 指针读写,无法证明每次内存访问合法。安全的调用可能在编译期被证明,不留下 _chk 符号;因此“没有看到某个检查符号”也不能直接证明选项失效。glibc Source Fortification

CET:硬件支持、产物标记和运行启用分开看

CET 包括 shadow stack 和 IBT。Shadow stack 将返回地址与受保护副本核对;IBT 检查特定间接 call/jmp 是否到达被标记的入口。IBT 不负责检查普通 ret,不能把它单独说成阻止所有 ROP 的机制。

是否支持每个特性依 CPU 而定,不能用单一“Intel 第 11 代以后”概括。Linux 内核、运行库、加载器与二进制还需共同支持并实际启用。

本文核对的 Linux 内核文档区分用户态 shadow stack 与内核 IBT 支持。ELF note 中有 SHSTK 或汇编中出现 ENDBR,不足以证明进程运行时已启用相应检查;用户态 shadow stack 可结合 /proc/PID/status 的相关线程特性字段核对。-fcf-protection=full 不是跨任何 Linux 环境自动开启所有 CET 机制的保证。Linux CET 文档

CFI 与 Sanitizer 的用途

CFI 按类型或控制流模型约束某些间接调用与转换,具体策略可比只识别合法入口更细。Clang 不同 CFI 检查的 LTO、可见性和跨共享库要求不同,要按所选检查配置;它仍只覆盖其建模的边与目标集合。Clang CFI

ASan、TSan 等主要用于检测执行时错误,不应当作低成本、无兼容性影响的默认生产沙箱。实际内存错误的修复和验证见 Valgrind 与 Sanitizers。内核 slab 随机化与 kptr_restrict 等控制的对象也不同,不能当成普通用户态堆 UAF 或任意信息泄露的直接修复。

一个明确参数的构建与检查例子

下面用于支持相应选项的现代 Linux GCC/glibc 环境。已有工程应放到正确目标与编译、链接阶段,避免重复定义相互覆盖。旧工具链需依据能力选择支持的 FORTIFY 级别。

先保存以下 demo.c。输入最多 63 字节,检查长度后才复制到局部数组;长输入在复制前拒绝。这个正常程序用于检查编译产物,同时保留清楚的边界,不需要通过制造越界才能观察加固选项。

#include <stdio.h>
#include <string.h>
__attribute__((noinline))
static unsigned checksum(const char *input, size_t length) {
    char buffer[64];
    memcpy(buffer, input, length);
    buffer[length] = '\0';
    unsigned value = 0;
    for (size_t i = 0; i < length; ++i)
        value = value * 33u + (unsigned char)buffer[i];
    return value;
}
int main(int argc, char **argv) {
    if (argc != 2) return 2;
    size_t length = strlen(argv[1]);
    if (length >= 64) { fputs("input too long\n", stderr); return 2; }
    printf("%u\n", checksum(argv[1], length));
    return 0;
}

checksum 的 unsigned 累加允许模回绕;它是演示运算,不是密码学摘要。该函数是内部辅助函数,长度小于 64 是调用前提;若将其改成公共接口,应把容量检查纳入接口本身。

gcc -O2 -fPIE -pie -fstack-protector-strong -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 \
  -Wl,-z,relro,-z,now,-z,noexecstack demo.c -o demo
readelf -hW demo
readelf -lW demo
readelf -dW demo
readelf -nW demo

再运行 ./demo abc,预期输出 108966;空字符串应输出 0,64 字节输入应返回 2 并报告 input too long。用 objdump -d --disassemble=checksum demo 检查实际函数;优化可能调整函数名字或形成专门变体,此时结合 nm 与完整反汇编定位。

要区分源代码验证和加固验证:长输入被应用自己拒绝,是长度检查的证据;它并没有触发 canary 或 FORTIFY。编译器是否保留了局部数组与保护检查,应由对应函数的指令和符号证据判断。noinline 不禁止所有跨函数分析:编译器仍可能从唯一调用点推导 length 范围,把 memcpy 内联而不生成 __memcpy_chk。某个版本若生成该符号,也不能推出所有 memcpy 都经过运行时检查。

检查能提供的证据
ELF 类型、解释器及工具识别主程序是否按预期形成 PIE;ET_DYN 单独也可能是共享库
GNU_RELRO 程序头与 NOW 动态标记RELRO 与即时绑定请求
GNU_STACK 的权限产物是否请求可执行栈
反汇编、符号与编译记录具体函数的保护与检查代码
运行时 maps、线程特性当前映射权限与适用硬件机制是否实际启用

对编译产物的检查与运行时确认应配合进行。加固选项还可能影响启动、代码尺寸、插件接口和调试,验证实际功能与代表性性能后再比较方案,避免引用固定百分比开销。

给加固检查写出可复核的结论

“全部开启”会隐藏证据范围。更有用的记录是:这个具体二进制以哪些编译/链接选项产生,哪几个程序头或动态标记支持结论,哪些函数实际带检查,哪些运行时状态已经观察过。

结论写法为什么更准确
主程序存在 GNU_RELRO 与 NOW明确是产物请求,而非所有依赖的运行状态
GNU_STACK 未请求执行权限指明检查对象是栈需求,不概括所有匿名映射
checksum 中存在栈保护检查指明函数,避免声称全部函数均受保护
输入边界案例通过说明应用功能验证,不等同于触发加固机制
尚未核对 CET 运行启用说明证据缺口,不能用 ENDBR 指令替代

遇到兼容性问题,先定位失败发生在编译、链接、加载还是具体调用,再缩小到相关选项。把所有选项一起取消后程序能运行,只能说明这一组设置影响行为,不能指出哪个机制或哪个源代码契约出了问题。

关键词:ASLR, PIE, RELRO, BIND_NOW, stack protector, NX, W^X, FORTIFY_SOURCE, CET, IBT, shadow stack, CFI