先看结论与判断条件

  • 开启 Full RELRO 与 BIND_NOW 后,动态链接器在将控制权交给 main 之前完成所有符号解析并使 GOT 只读,从而降低基于重定位写入的 GOT 覆写利用风险。
  • 该机制仅作用于 ELF 装载周期的重定位阶段,对运行时通过 mprotect 改变内存属性后修改 GOT、或利用 dlopen 新载入库的方式无防护能力。
  • Partial RELRO 只保护部分重定位相关段,攻击者仍可覆写 .got.plt 函数指针,因此必须组合 BIND_NOW 才达到 Full RELRO 效果。
  • 工程判断:BIND_NOW 把符号解析集中到装载阶段,可能增加冷启动工作量;本文来源未给出目标应用的 CPU、I/O 或时延数据,需用实际构建做前后基准。
  • 通过 readelf 检查 PT_GNU_RELRO 段与 dynamic section 中的 DF_BIND_NOW 标志可以静态确认保护状态,但静态字段无法证明动态加载路径的运行时完整性。
  • 16 KB 页大小兼容性要求 ELF LOAD 段对齐与 APK 对齐同时满足,仅检查 RELRO 标志或单一 ABI 并不足以保证目标设备上正常启动。

RELRO 与 BIND_NOW 能在装载阶段阻断什么

RELRO 与 BIND_NOW 的组合在 ELF 装载器将控制权交给 init 数组和 main 之前,把全局偏移表(GOT)中由动态链接器填充的部分重映射为只读,从而阻断了基于 GOT 覆写的攻击路径。具体而言,当攻击者能够触发一次任意地址写入但无法直接更改代码段时,传统的利用方式就是覆写 GOT 条目使后续函数调用劫持到恶意逻辑。一旦 GOT 变为只读,这种依赖于装载后写入的重定位利用就被彻底阻断。

该防护依赖 PT_GNU_RELRO 程序头将可写段在装载完成后标记为只读,结合 DF_BIND_NOW 标志要求动态链接器在返回用户代码前解析所有函数符号。Partial RELRO 仅仅将非 PLT 相关的重定位区域设为只读,.got.plt 仍然可写,攻击者可以覆写尚未解析的 PLT 函数条目。只有同时指定 -z relro 与 -z now,编译器驱动 ld 才会同时产生 PT_GNU_RELRO 段与 DF_BIND_NOW 标志,实现完整的立即绑定与全面 GOT 只读。

可以在装载期被控制的攻击建立在动态链接器行为之上:攻击者必须依赖于装载时的惰性绑定机制,在符号首次调用时通过动态链接器写入 GOT,或者在装载完成后利用某次写原语直接篡改已填写的条目。Full RELRO 将整个重定位相关的 GOT 段在 main 之前全部填入正确地址,然后立即 mprotect 为不可写,从根本上消除了装载结束后仍可写入的窗口,也让基于偏移计算的 GOT 覆写变为只能触发崩溃的无效操作。

RELRO 级别与 GOT 防护对照
配置GOT 段保护范围.got.plt 可写性需 BIND_NOW典型脆弱点
无 RELRO无只读保护始终可写任意写可劫持所有函数指针
Partial RELRO仅 .got(非 PLT 重定位)可写仍可覆写 PLT 函数条目
Full RELRO.got 与 .got.plt 均设为只读装载后只读仅限装载前竞态或内核漏洞
Full RELRO + System Integrity全部 GOT 只读且段属性受内核保护只读仍无法防御运行时修改页属性攻击
  • 确认 ldflags 中同时给出 -Wl,-z,relro -Wl,-z,now
  • 使用 readelf -l 检查是否存在 GNU_RELRO 段
  • 使用 readelf -d 确认 BIND_NOW 标志未缺失
  • 核查最终 .so 或可执行文件的段映射是否仅包含 R 无 W

ELF 重定位与 GOT 数据面的核心威胁模型

当可执行与可链接格式(ELF)被操作系统加载时,动态链接器需要修正代码与数据中引用的外部符号地址,这个过程称为重定位。对于函数符号,动态链接器通常默认采用惰性绑定:在首次调用时通过过程链接表(PLT)跳转到一段桩代码,由桩代码调用解析函数并将结果写入 GOT,之后调用直接通过 GOT 跳转。此时 GOT 必须保持可写属性,这为运行时内存破坏漏洞留下了直接的函数指针覆写路径。

仅由编译器选项控制的段属性不会主动防御运行时 mprotect 或跨进程攻击,因为攻击者可能通过另一条漏洞链获得足以调用 mprotect 的 system 调用能力,重新将只读 GOT 改为可写,再写入伪造函数地址。因此,理解 RELRO 的边界必须从重定位面的静态数据流出发:它解决的是装载完成后无需再写入的数据段被误写的问题,而不是进程内可信代码被恶意修改的通用内存安全。

静态分析工具可以通过检测 PT_GNU_RELRO 段的存在和段映射属性推断 Full RELRO 已启用,但无法推断 GOT 的内容是否在装载后被外部篡改。例如,在某些定制安卓设备上,内核模块缺失可能导致 RELRO 段仅表现为普通只读段,而系统完整性监视能力不足,攻击者仍有可能利用设备特定的内核漏洞翻转页表写入权限。因此 GOT 覆写防护最终依赖内核与装载器协调维护的页属性,而不是单一的链接选项。

GOT 覆写攻击在 RELRO 上下文中的可行性
攻击场景攻击前提Full RELRO 效果残余风险
装载后 .got.plt 覆写任意写原语,GOT 可写阻断攻击者可能先改属性
惰性绑定期间的竞态多线程首次调用同一函数阻断(全部预解析)无竞态窗口
动态加载库的 GOTdlopen 加载新 SO 并触发解析新库若未启用则不受保护需对新库同样施加标志
绕过 RELRO 的运行时改写mprotect 调整页属性后覆写不能防护需要内核或 VMP 层保护
检查 ELF program headers 与 dynamic flags 的 readelf 诊断脚本
#!/bin/bash
set -euo pipefail

usage() {
  echo "用法:$0 <elf_file>" >&2
  exit 1
}

check_elf() {
  local elf="$1"
  if [ ! -f "$elf" ]; then
    echo "错误:文件 $elf 不存在" >&2
    exit 2
  fi
  if ! readelf -h "$elf" >/dev/null 2>&1; then
    echo "错误:$elf 不是有效的 ELF 文件" >&2
    exit 3
  fi
}

check_relro() {
  local elf="$1"
  if ! readelf -l "$elf" | grep -q 'GNU_RELRO'; then
    echo "失败:未找到 PT_GNU_RELRO 段" >&2
    exit 4
  fi
}

check_bind_now() {
  local elf="$1"
  if ! readelf -d "$elf" | grep -q '(BIND_NOW)'; then
    echo "失败:缺少 DF_BIND_NOW 标志" >&2
    exit 5
  fi
}

[ $# -ge 1 ] || usage
ELF="$1"
check_elf "$ELF"
check_relro "$ELF"
check_bind_now "$ELF"
echo "成功:Full RELRO 与 BIND_NOW 已启用"

Partial RELRO 与 Full RELRO 的机制差异

链接器通过识别 -z relro 选项创建 PT_GNU_RELRO 程序头,指示动态装载器将重定位相关数据段在装载后立即设为只读。Partial RELRO 只将 .dynamic、.got 等不参与惰性绑定的区域设为只读,而 .got.plt 仍保持可写以便惰性绑定执行。这意味着攻击者只要获得目标进程内的写能力,就可以覆写任何未解析的 PLT 函数条目。

Full RELRO 要求同时启用 -z now,这会强制装载器在跳转到 init 代码之前解析所有 PLT 条目并填入 GOT,然后将整个 GOT 范围连同 .got.plt 一起改为只读。此时装载器产生 PT_GNU_RELRO 段覆盖了完整的 GOT 区域,而 DF_BIND_NOW 动态标志被写入 dynamic section,供运行时环境与审计工具查证。

实践中的区分手段在于观察只读段映射:Partial RELRO 的 .got.plt 仍处于可写段,Full RELRO 的 .got 与 .got.plt 通常合并到一个只读段。攻击者可利用这种段布局差别判断目标是否提供了真正的立即绑定保护,进而选择不同利用策略。对于无法更改编译器选项的闭源 SDK,只能通过 readelf 或运行时 dl_iterate_phdr 确认实际载入内存的权限。

Partial 与 Full RELRO 技术特征对比
特征维度Partial RELRO 表现Full RELRO 表现安全影响
PT_GNU_RELRO 覆盖范围仅覆盖 .dynamic 和部分 .got覆盖 .got 及 .got.plt决定是否有残留可写区
DF_BIND_NOW 标志状态未设置或缺失明确设置为 BIND_NOW决定是否立即解析符号
.got.plt 段属性RW (可读可写)R (只读)决定能否覆写函数指针
启动时符号解析行为按需延迟解析启动时全量解析决定是否存在竞态窗口
  • 对比不同构建配置下的 readelf -l 输出差异
  • 验证 .got.plt 段在内存映射中的权限位
  • 检查动态节中 FLAGS 字段是否包含 NOW
  • 确认链接脚本未显式排除 GOT 段保护

BIND_NOW 的装载期绑定成本与决策

BIND_NOW 的立即绑定要求动态链接器将所有启用的 PLT 重定位在通过 init 数组之前全部解析并填充,这意味着程序启动时要支付所有外部函数的符号查找与地址重定位开销,无论这些函数在本次运行中是否真正被调用。对于大量导入函数的守护进程或大型框架库,这一成本可能导致启动延迟显著增加,因为原本分散在程序生命周期的惰性绑定 I/O 与字符串操作被集中到装载阶段。

成本大小取决于导入符号数量、符号哈希拖链长度以及装载器的缓存策略。在 Android 类系统中,许多系统库往往已在 zygote 阶段预热,因此 BIND_NOW 对应用冷启动的影响可能高于热启动。在一个大范围采用惰性绑定优化的系统中,提升安全性的代价可能在数百毫秒级别,必须根据业务容忍度决定是否为所有组件开启。

内存受限设备应分别记录开启前后的冷启动时延、VSS 和 RSS,再决定是否为所有组件启用 BIND_NOW。GOT 条目提前解析并不自动等于显著内存增长;没有目标构建的基准结果时,只能把性能影响登记为待测项。

BIND_NOW 装载成本影响因子
影响因子对启动时间的影响方向对应系统组件缓解方法
导入符号数量符号越多,解析时间越长动态链接器符号解析并非必要符号缩减
共享库依赖深度深层依赖增加查找链路glibc/ linker 查询使用 DT_NEEDED 扁平化
目标设备存储 I/O慢速存储放大装载成本mmap 与页面读入保证库位于快速存储
是否 zygote 预加载Android 热启动大幅降低app_process 预烘焙保持应用进程基址不变
  • 测量冷启动与热启动下 main 执行前的时间差
  • 比较 /proc/pid/smaps 中只读段的大小变化
  • 在低端设备上验证 BIND_NOW 是否引入 ANR 风险
  • 若启动成本不可接受,仅对关键守护进程选择性启用

RELRO 与 BIND_NOW 不能防护的运行时攻击面

GOT 重写并不是内存破坏攻击的唯一形式。一旦攻击者获得代码执行能力,他们可以通过内联钩子或直接修改代码段来劫持函数,而这些手段不受 GOT 只读属性影响。特别是在没有代码完整性验证的通用 Linux 进程中,装载后的可执行内存页可能被 mprotect 改为可写再注入指令,或者攻击者直接利用已有的 JIT 区域存放载荷,这些攻击方式完全绕过 ELF 层的重定位保护。

动态库延迟加载也是防护盲区:如果程序在运行时通过 dlopen 装载新库,且新库未以 RELRO + BIND_NOW 编译,那么该库的 GOT 会再次暴露可写重定位面。由于装载器无法跨库强制实施全局 RELRO 规则,安全架构必须要求所有动态组件统一编译选项,并对运行时的 dlopen 调用实施白名单控制,但该控制本身并非 RELRO 提供的机制。

符号可见性控制与导出表压缩属于攻击面收敛的另一维度,它们与 RELRO 没有直接关联。攻击者即便无法覆写 GOT,仍可通过分析导出符号定位关键函数地址,进而实施面向返回编程(ROP)或跳转导向编程(JOP)。降低导出符号数量虽然可以减少信息泄漏,但需要配合版本脚本与 -fvisibility 标志,这属于构建期符号管理,不在重定位只读的防护范围之内。

RELRO 无法覆盖的攻击向量清单
攻击类型利用原理RELRO 防御效果推荐补充措施
内联钩子 (Inline Hook)直接修改 .text 段指令无效,不保护代码段代码完整性校验或 VMP
mprotect 属性翻转调用系统调用修改页权限无效,依赖内核策略SECCOMP 或内核加固
动态库注入 (dlopen)加载未保护的新库无效,仅限已加载库运行时库白名单控制
ROP/JOP 攻击利用现有代码片段构造链无效,不限制代码执行流CFI (控制流完整性)
  • 审查应用中所有 dlopen 调用的来源可信度
  • 评估是否需要针对 .text 段的额外保护措施
  • 检查是否存在允许任意内存写的逻辑漏洞
  • 确认内核版本是否支持严格的页表保护策略

代码虚拟化与 RELRO 的分工边界

代码虚拟化(VMP)将关键算法编译为自定义的中间语言并在虚拟机中解释执行,其设计目标是使逆向分析难以将指令序列还原为原始业务逻辑。与之不同,RELRO 与 BIND_NOW 仅仅限制装载后 GOT 的写入,对代码段自身的可读性、可反汇编性毫无影响。启用 Full RELRO 的二进制,仍然可以通过静态反汇编、动态跟踪等方式提取算法,业务逻辑并未被隐藏。

开发者经常会误以为将 GOT 变为只读等同于增强了代码段保护。实际上,攻击者可以完整打印 .text 段中的指令流,或者通过调试接口和 ptrace 记录运行时行为,而不需要尝试写入 GOT。RELRO 属于内存破坏防御的技术栈,而代码虚拟化属于逆向分析难度的技术栈,二者在攻击树中的位置截然不同,不能互相替代。将 RELRO 等同于 VMP 会错误地认为已经保护了核心算法安全。

划分边界时先确认要保护的是重定位表写入还是业务逻辑可读性:RELRO 处理前者,VMP 面向后者。两类机制在工程上可以同时启用,但具体启动与内存开销仍需用目标 VMP 配置做基准测试,不能从静态标志推算。

RELRO 与代码虚拟化的威胁模型区分
防护目标RELRO/BIND_NOW 覆盖范围代码虚拟化覆盖范围是否互补
GOT 覆写利用是,阻止装载后写入否,无关可以独立叠加
代码段注入或修改否,不保护代码段是,自定义解释器隐藏指令需要体系配合
静态逆向业务逻辑否,ELF 段默认可读是,转换指令集和混淆必须同时启用两者
动态调试与 dump仅间接降低部分信息泄漏需要反调试抑制需额外保护措施
  • 列出需要机密保护的核心算法模块
  • 确认这些模块已通过 VMP 编译器处理
  • 使用 -Wl,-z,relro,-z,now 加固剩余原生组件
  • 测试 VMP 与 RELRO 叠加后启动时间与内存增量

通过 readelf 静态审计 RELRO 与 BIND_NOW 状态

使用 GNU readelf 工具审计二进制保护级别是最直接的非侵入式方法。首先查看程序头表:readelf -l <elf> 应当列出 GNU_RELRO 段,该段的内存权限通常为只读,且其虚拟地址范围必须覆盖 .got 和 .got.plt。如果此段缺失,表明未启用任何 RELRO,若段存在但起始地址仅覆盖 .dynamic 区域则极有可能是 Partial RELRO。

接着检查 dynamic section:readelf -d <elf> 寻找 FLAGS 条目中包含 BIND_NOW 或 FLAGS_1 中包含 DF_1_NOW 的标志。如果 BIND_NOW 未置位,即便存在 PT_GNU_RELRO,也仅仅是 Partial RELRO。诊断脚本应当将两种检查结合,并在发现缺失时返回非零状态,以便植入 CI 流水线,确保每次构建产物都符合预期。

静态审计的局限在于无法保证运行时动态链接器的行为符合标志设定。某些嵌入式平台上可能存在裁剪过的链接器,即使读取到 DF_BIND_NOW 也未执行立即绑定。为了降低误报风险,可以在真机环境下注入一个小型验证 SO,该库从 init 数组中检查自身的 GOT 是否可写,并上报状态。

readelf 审计关键字段解读
检查命令关键字段期望值缺失含义
readelf -lTypeGNU_RELRO未启用任何 RELRO 保护
readelf -lFlagsR (Read only)段属性错误,可能仍可写
readelf -dFLAGSBIND_NOW仅 Partial RELRO,存在竞态
readelf -dFLAGS_1NOW新版标记,等效于 BIND_NOW
  • 编写自动化脚本解析 readelf 输出文本
  • 在 CI 流程中设置 RELRO 缺失即构建失败
  • 定期抽检第三方依赖库的保护状态
  • 记录不同编译器版本生成的默认标志

16 KB 页面对齐、NDK 兼容与 C++ 异常传播的关联边界

Android 推广 16 KB 页大小后,RELRO 机制可能与段对齐策略产生互动。PT_GNU_RELRO 段的起始地址与大小通常保持至少一个页面对齐,但若整个 ELF 的 LOAD 段未对齐到 16 KB,系统装载器在映射只读 RELRO 区域时可能因页分裂而暴露潜在的写回风险。开发者必须同时通过 zipalign 与编译器对齐选项保证 LOAD 段与 RELRO 段在新页大小下依然正确映射。

从 NDK 兼容性视角看,许多项目在引入 Full RELRO 后遇到启动崩溃,根本原因并不是 RELRO 本身,而是未正确处理 C++ 异常或 STL 符号的惰性绑定依赖。当启用 BIND_NOW 时,如果某个共享库引用的符号(如 typeinfo 或 unwind 相关)在装载时无法解析,装载器将立即中止进程,这会暴露之前依赖惰性绑定而掩盖的缺损依赖。必须通过 ndk-build 或 CMake 显式链接正确的 C++ 运行时,并确保异常类通过 RTTI 完整可见。

性能与兼容性最终必须在目标设备上验证。通过单一 ABI 或模拟器得到的结果不能推导至真机,因为内核的页保护策略、链接器的定制行为以及预编译库的装载缓存都存在差异。开发者应当建立包含 RELRO 检查、16 KB 设备上的 LOAD 对齐验证与 C++ 异常回归测试的自动化流水线,以确保防护不会因固件差异而失效。

Android NDK 开启 Full RELRO 的常见兼容性问题排查
现象可能与 RELRO 相关的原因非 RELRO 的常见根因检查步骤
启动时 SIGKILL 或 linker 错误BIND_NOW 强制解析引起未定义符号缺少 libc++_shared 或 DT_NEEDEDreadelf -d 查看 NEEDED,对比符号表
异常未正确捕获无直接关系type_info 未通过 RTTI 导出对照 NDK 符号可见性文档
mmap 失败或段映射错误RELRO 段未 16 KB 对齐LOAD 段未对齐readelf -l 查看 Align,在 16 KB 设备实测
特定设备 RELRO 未生效内核缺少 prctl 或 sepolicy 限制定制 linkers 不处理 BIND_NOW检查 /proc/pid/smaps 权限位
  • 确认所有 LOAD 段 Align 至少为 16384
  • 确保 APK 对齐工具适配了目标页大小
  • 检视 AndroidManifest 中 extractNativeLibs 配置的影响
  • 在 CI 中集成 readelf 段对齐与 BIND_NOW 检查

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
-z relro 生成 PT_GNU_RELRO 段,-z now 要求装载时完成符号解析,结合使用实现立即绑定并设置只读 GOT。GNU ld options链接标志降低部分重定位面风险,但不保护业务算法或运行时明文,也不保证目标系统链接器遵守标志。
readelf 可检查 ELF header、program header、dynamic section、符号、重定位和 unwind 信息,从而静态确认 RELRO 与 BIND_NOW 标志。GNU readelf静态字段存在不证明动态加载或异常传播成功,也不能推断运行时 GOT 是否被外部手段改写。
16 KB 页大小兼容性需要同时核对 ELF LOAD 段对齐、APK 对齐、预编译库格式和目标设备运行时行为,缺一不可。Support 16 KB page sizes只检查 zipalign 或单一 ABI 都不足以得出兼容结论;需要真机测试页保护行为。
NDK 兼容故障常见于 API level、缺失符号、STL、异常类型和错误的依赖装载,可能在启用 BIND_NOW 后暴露。Android NDK common problems故障清单不能替代目标 ABI 的实际启动和异常回归,也不能证明 RELRO 是崩溃根因。
版本脚本和默认隐藏可收敛 Native 导出符号面,并保留明确的 JNI/API 入口,但 RELRO 并不管理符号可见性。Android NDK symbol visibility减少导出符号不能隐藏运行时必须公开的接口,也不等于代码虚拟化,更不增加 RELRO 的保护能力。
C++ 异常、RTTI 与 libc++ 链接方式取决于构建系统和运行库选择,BIND_NOW 可能在装载时暴露异常相关符号缺失。Android C++ library support启用编译选项不证明跨 SO 的 exception type_info 与 unwind 完整,实际行为必须在目标设备上用异常用例验证。
Full RELRO 通过地将可写 GOT 改为只读来阻止装载后覆写,但无法防御 mprotect 或内核模块引起的属性翻转。工程判断该结论建立在标准的 Linux 内核页保护模型上,未接入特定内核防御产品的实测数据。
代码虚拟化保护业务逻辑机密性,与 RELRO 防范控制流劫持位于不同层次,两者可以组合但不可互相替代。工程判断项目证据尚未接入特定虚拟化产品的性能与安全叠加数据,此处仅说明功能边界而非能力结论。

工程常见问题

如何确认我的 .so 库已启用 Full RELRO?

依次执行两条命令:readelf -l <库> 检查是否存在 GNU_RELRO 段,以及 readelf -d <库> 查看 FLAGS 或 FLAGS_1 中是否包含 BIND_NOW。两者同时满足才是 Full RELRO;任一缺失即为 Partial 或未保护。

只开 -z relro 不设 -z now 能防护 GOT 覆写吗?

不能。仅 -z relro 生成 Partial RELRO,.got.plt 仍可写,攻击者可以任意覆写未解析的 PLT 函数条目,必须组合 -z now 实现立即绑定才能将整个 GOT 设为只读。

开启 Full RELRO 后是否不需要担心符号劫持?

符号劫持可通过环境变量 LD_PRELOAD 或先加载恶意库实现,发生在动态链接器的符号解析优先级环节,与 GOT 是否只读无关。RELRO 不解决符号搜索路径与链接顺序问题。

BIND_NOW 会增加多少启动时间?

具体数值取决于导入符号数量、依赖深度和存储性能,一般在数百毫秒范围内。目前缺乏实测数据,无法给出针对特定产品的百分比,但冷启动受影响更大,可考虑选择性用于关键进程。

16 KB 页设备是否会导致 RELRO 失效?

不必然。但如果 ELF LOAD 段或 RELRO 段未能对齐到 16 KB,内核可能因页分裂导致原本只读区域被映射为可写。需要验证所有段对齐值并实际刷机测试。

代码虚拟化可以代替 RELRO 吗?

不能。代码虚拟化保护算法机密性,RELRO 防御 GOT 覆写型控制流劫持,二者位于内存破坏与逆向分析的完全不同的战场,必须分别实施并组合其它防护。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: SO 加固与 Native 兼容性检查清单