首页
/ BOLT 优化 AArch64 pac-ret 加固二进制的原理与实践:PointerAuthCFIAnalyzer 与 PointerAuthCFIFixup 双 Pass 设计

BOLT 优化 AArch64 pac-ret 加固二进制的原理与实践:PointerAuthCFIAnalyzer 与 PointerAuthCFIFixup 双 Pass 设计

2026-09-05 11:45:27作者:仰钰奇

本文围绕 BOLT 设计文档 PointerAuthDesign.md 展开,讲解 BOLT 如何处理 AArch64 平台上的 DW_CFA_AARCH64_negate_ra_state(negate-ra-state)CFI 指令,使经过 pac-ret 指针认证(Pointer Authentication)加固的二进制文件也能被 BOLT 安全地重排优化。读完本文,你将理解 RA state 语义、BOLT 如何在 CFI 读取阶段保存注解、两个新 Pass 在优化管线中的位置与职责、错误处理与函数忽略机制,以及如何通过命令行参数控制该功能并用仓库中的真实测试验证重写结果。

背景:Pointer Authentication 与 negate-ra-state CFI

BOLT 对二进制进行基本块重排、函数拆分等布局优化。对启用 pac-ret 加固的 AArch64 程序来说,重排会改变指令的物理布局,而 CFI(Call Frame Information)中的 RA state 标记位置恰恰依赖于布局,这就可能导致栈展开(unwinding)信息失效。pac-ret 的背景可参阅 BOLT 二进制分析文档的 pac-ret 小节

DW_CFA_AARCH64_negate_ra_state 的语义

negate-ra-state 是 Arm ABI 定义的厂商专用 Call Frame Instruction,在文档与源码中还有两个等价称呼:

  • 汇编指令层面写作 .cfi_negate_ra_state
  • BOLT 源码中写作 OpNegateRAState(见 bolt/lib/Passes/PointerAuthCFIFixup.cppMCCFIInstruction::createNegateRAState)。

其作用是翻转 RA_SIGN_STATE 伪寄存器 bit[0],该位告知展开器当前返回地址是否已被签名。展开器据此对指针做认证并剥离 PAC 位。negate-ra-state 放错位置会造成两类后果:

  • 展开器对未签名指针尝试认证,触发段错误;
  • 展开器跳过对已签名指针的认证,同样可能触发故障。

需要注意术语区分:CFI 在此有两层含义——Call Frame Instruction(单条 DWARF 指令,如 negate-ra-state)与 Control Flow Integrity(控制流完整性安全机制,如指针认证)。另外文档特别提醒:部分展开器用 xpac 指令直接剥离 PAC 位而不做认证,这是不完整(不正确)的实现,因为它在展开路径上允许控制流被修改。

DWARF 没有直接置位/清位 RA state 的指令,但还有两条 CFI 会间接影响 RA state:

  • DW_CFA_remember_state:把当前的寄存器规则压入隐式栈;
  • DW_CFA_restore_state:从该栈弹出规则。

三者的组合行为可以这样理解(RA state 初始值为 0,即 unsigned):

CFI 对 RA state 的影响
(默认) 0
DW_CFA_AARCH64_negate_ra_state 0 → 1
DW_CFA_remember_state 将 1 压入栈
DW_CFA_AARCH64_negate_ra_state 1 → 0
DW_CFA_restore_state 0 → 1(从栈中弹出)

Arm ABI 还定义了 DW_CFA_AARCH64_negate_ra_state_with_pc,但该 CFI 使用范围很小且趋向废弃,BOLT 不对其做特殊处理。

哪些位置需要 negate-ra-state

只要两条相邻指令的 RA state 不同,展开器就必须被告知这一变化。这种变化通常发生在返回地址的签名(pac*)或认证(aut*)指令处。若相邻指令 RA state 不同但两条都不签名/认证,则它们必然属于不同的控制流路径:一条走已签名 RA 路径,另一条走未签名 RA 路径。

下面这个例子说明“跨基本块边界”的场景:第一个基本块以条件分支结束,跳转的两个后继块各自做认证和返回。第二个基本块末尾的 ret 处于 unsigned 状态,而第三个基本块的入口在控制流上位于 paciasp 之后、认证之前。此时必须在第二个基本块末尾插入一条 negate-ra-state:

        +----------------+
        |     paciasp    |
        |                |
        |      b.cc      |
        +--------+-------+
                 |
+----------------+
|                |
|       +--------v-------+
|       |                |
|       |    autiasp     |
|       |      ret       |   // RA: unsigned
|       +----------------+
+----------------+
                 |
        +--------v-------+  // RA: signed
        |                |
        |     autiasp    |
        |      ret       |
        +----------------+

这里有一个关键事实:展开器不沿着控制流图走,而是按布局顺序读取展开信息。因此 negate-ra-state CFI 的有效性完全依赖函数布局——一旦 BOLT 重排了基本块,原有位置就可能失效。这正是 BOLT 需要重写这类 CFI 的根本原因。

解决方案总体设计:两个新 Pass

该特性通过引入两个新的二进制函数级 Pass 实现(均在 bolt/lib/Passes/ 下,头文件位于 PointerAuthCFIAnalyzer.hPointerAuthCFIFixup.h):

  1. PointerAuthCFIAnalyzer:在优化重排任何内容之前运行。它根据输入二进制中的 CFI,为每条指令计算出其 RA state 并保存下来;
  2. PointerAuthCFIFixup:在优化之后运行,是 Analyzer 的“逆过程”。它读取保存的 RA state 注解,在布局顺序下两条相邻指令 state 发生变化的位置,发射 DW_CFA_AARCH64_negate_ra_state CFI。

从源码结构看,两个 Pass 通过 BinaryPassManager.cpp 中的注册顺序体现了这一时序:PointerAuthCFIAnalyzer 在 AArch64 目标下被注册为优化管线中最早的 Pass 之一,而 PointerAuthCFIFixup 注册在管线末尾(BinaryPassManager.cpp#L549),即所有布局优化完成之后。

为了跟踪单条指令上的元数据,BOLT 扩展了 MCAnnotation 类,并在 MCPlusBuilder 中提供了辅助函数。Pass 运行前后还可以用隐藏开关打印函数状态:--print-pointer-auth-cfi-analyzer--print-pointer-auth-cfi-fixup(定义见 BinaryPassManager.cpp#L137-L144),后文验证示例中会用到。

CFI 读取阶段:保存注解而不干扰既有 CFI 处理

CFI 由 CFIReaderWriter::FillCFIInfoFor 读取并填入 BinaryFunction。BOLT 在这个阶段就把三类会影响 RA state 的 CFI(negate-ra-state、remember-state、restore-state)作为 MCAnnotation 挂到它们所指向的指令上,而不是立刻改写 CFI。这样做的目的是不干扰 BOLT 已有的 CFI 处理流程——例如 remember-state 和 restore-state CFI 会在 normalizeCFIState 中被移除,这与 PAC 无关。

一个特殊边界情况:CFI 修改的是其前面指令的 RA state,因此若函数从第一条指令起 RA state 就已经被改变(例如函数入口紧跟 .cfi_negate_ra_state,即 CFI 的 Offset 为 0),注解无法挂到“第一条指令之前”。这种情况通过给每个 BinaryFunction 增加 initialRAState 布尔字段来处理:FillCFIInfoFor 中遇到偏移为 0 的 CFI 时不存注解,而是设置 initialRAState,供 PointerAuthCFIAnalyzer 在计算时作为初始值使用。该字段对外暴露为 containedNegateRAState() / getInitialRAState(),声明见 BinaryFunction.h#L157

无 DWARF 信息的二进制:以“函数是否含 negate-ra-state”作为运行开关

有些二进制被 strip 掉了 DWARF 表,这类程序通常有其它展开机制。为了让 BOLT 在这些场景下行为不变,两个 Pass 都只对至少包含一条 negate-ra-state CFI 的函数运行(源码中的跳过谓词见 PointerAuthCFIAnalyzer.cpp#L128-L134),由此形成三种清晰的行为:

  • 未使用指针认证:Pass 不运行,无变化;
  • 使用了指针认证但 DWARF 被剥离:Pass 不运行,无变化;
  • 使用了指针认证且存在 DWARF CFI:Pass 运行并重写 negate-ra-state CFI。

注释中还特别指出,以 -fno-exceptions -fno-unwind-tables -fno-asynchronous-unwind-tables 编译的代码虽然用了 pac-ret,但因为没有 unwind 表,也会自然落入“不处理”的分支。

PointerAuthCFIAnalyzer:为每条指令计算 RA state

该 Pass 在一切重排之前运行,核心逻辑在 PointerAuthCFIAnalyzer.cpp#L61-L116

  • BF.getInitialRAState() 作为初值压入一个 RA state 栈;
  • 按布局顺序遍历每条非 CFI 指令,先做合法性检查(见下文错误处理),然后给当前指令打上 RA state 注解(setRAState);
  • 再遍历该指令上所有注解,依次应用 kNegateState(翻转)、kRememberState(压栈)、kRestoreState(弹栈)对状态栈的更新——所有更新从下一条指令开始生效。注意源码特意循环遍历注解而不是按固定顺序检查,因为同一条指令上可能同时存在 remember 与 restore,顺序敏感。

Pass 运行前后注解的对比(设计文档中的示例):

指令 Pass 前(CFI 注解) Pass 后(RA state 注解)
paciasp negate-ra-state unsigned
stp x29, x30, [sp, #-0x10]! signed
mov x29, sp signed
ldp x29, x30, [sp], #0x10 signed
autiasp negate-ra-state signed
ret unsigned

错误处理:发现不一致就“忽略函数”

PointerAuthCFIAnalyzer 发现当前 BinaryFunction 存在 RA state 不一致时,调用 BF.setIgnored() 把该函数标记为忽略。BOLT 之后不会再优化该函数,而是把它原样输出到 .bolt.org.text 段。三类不一致条件(与源码检查逻辑一一对应,见 PointerAuthCFIAnalyzer.cpp#L42-L93):

  1. 已经处于 signed 状态时又遇到 pac* 指令;
  2. 已经处于 unsigned 状态时又遇到 aut* 指令;
  3. pac* / aut* 指令上没有配套的 .cfi_negate_ra_state 注解。

被忽略的函数会以 BOLT-INFO 级别逐条报告函数名与不一致原因;Pass 结束后还会汇总“共处理多少个函数、忽略了多少个(百分比)”。源码中还实现了文档之外的一个细节:当忽略比例达到 10% 以上时,输出 BOLT-WARNING 提示用户该输入可能使用了同步展开表(synchronous unwind tables),C 编译器应改用 -fasynchronous-unwind-tables。原因在源码注释中解释得很清楚:同步展开表会丢弃 call 点之后的展开 CFI,导致 autiasp 后看起来缺少 .cfi_negate_ra_state,从而被批量误判(见 PointerAuthCFIAnalyzer.cpp#L153-L167)。

PointerAuthCFIFixup:在正确位置重新发射 CFI

该 Pass 在优化之后运行(PointerAuthCFIFixup.cpp),做 Analyzer 的逆过程:

  1. 读取指令上保存的 RA state 注解;
  2. 在布局顺序下相邻指令 state 发生变化时,插入一条持有 OpNegateRAState CFI 的 PseudoInstruction。

主循环(PointerAuthCFIFixup.cpp#L44-L74)遍历的是 FunctionFragment(函数布局碎片)内的基本块与指令,跳过 CFI 伪指令本身;当 *RAState != PrevRAState 时,通过 BF.addCFIInstruction(...) 在当前迭代器位置插入 MCCFIInstruction::createNegateRAState(nullptr)。若某条指令推断后仍无 RA state,Pass 报 BOLT-ERROR 并置 PassFailed,最终经 createFatalBOLTError 使整个 BOLT 运行失败。Pass 结束后会打印重写统计,例如 BOLT-INFO: rewritten pac-ret DWARF info in N out of M functions (xx.xx%)PointerAuthCFIFixup.cpp#L257-L261)。

覆盖新生成的指令:inferUnknownStates

部分 BOLT Pass 会添加新指令,Fixup 必须知道它们的 RA state。赋值逻辑集中在 inferUnknownStatesPointerAuthCFIFixup.cpp#L77-L89),分两种情况:

  1. 基本块内混合情况:块内部分指令已知 RA state、部分未知时,把已知指令的 RA state 复制给未知指令。理由是控制流只在基本块之间切换,同一基本块内的指令拥有相同的返回地址(noreturn 调用是例外,但只有当新插入指令恰好紧跟该调用之后才会有问题);
  2. 整块未知(stub)情况:基本块没有任何已知 RA state 的指令时,按布局顺序复制其前一个基本块(更精确地说是前一条非 CFI 指令)的 RA state,由 fillUnknownStubs 完成(PointerAuthCFIFixup.cpp#L196-L236)。这里源码注释引用了 LLVM issue #160989:stub 缺失展开信息,为错误的返回地址标注“正确”的签名状态没有意义,因此只能继承前序状态;若整个函数尚未遇到任何非 CFI 指令,则回退到函数入口的 initialRAState

细节上,fillUnknownStateInBB 在传播时还会考虑前一条指令是 pac*(其后必为 signed)或 aut*(其后必为 unsigned)的情况,保证状态传播与指令语义一致。

函数拆分需要特殊处理

在优化之前打好 RA state 标记,使得指令可以自由搬动。唯一需要特殊关照的优化是函数拆分(function splitting):拆分出的部分在输出二进制中成为独立的新函数,展开器需要对它“重放”从函数入口到拆分点的所有 CFI。BOLT 对其它 CFI 已经做了这件事,但 negate-ra-state 并不作为真实 CFI 读取(只以 Annotation 形式保存),所以必须在 PointerAuthCFIFixup 中手工处理:coverFunctionFragmentStartPointerAuthCFIFixup.cpp#L91-L119)检查每个 FunctionFragment 首个非空基本块(函数拆分可能产生仅作跳转目标的空基本块)的第一条非伪指令,如果其 RA state 为 signed,就在该位置插入一条 negate-ra-state CFI。这个处理同时也覆盖了输入二进制本身就已被拆分、第一个 FF 即以 signed state 开始的情形。

关闭该功能的开关

该功能可以用 --update-branch-protection 参数守护,默认开启。当它被设为 false,而某个函数在 FillCFIInfoFor() 之后 containedNegateRAState() 为真时,BOLT 直接退出并报错。对应实现在 RewriteInstance.cpp#L4036-L4045

BOLT-ERROR: --update-branch-protection is set to false, but <函数名> contains .cfi-negate-ra-state

参数声明见 BinaryPassManager.cpp#L292。需要注意:设计文档中写作 --update-branch-prediction 是笔误,仓库源码与测试中的实际参数名是 --update-branch-protection,请以源码为准。这个开关的实际语义是:如果你的下游环境无法处理重写的 negate-ra-state CFI,可以用它让 BOLT 拒绝处理含该 CFI 的函数而不是产出错误结果。

用仓库测试验证重写行为

仓库内有多条测试链路可以端到端验证该设计,均位于 AArch64 目录下:

  • pacret-cfi.s:核心测试。构造 foo/bar 函数对——foo 末尾 paciasp 后尾调用 barbar.cfi_negate_ra_state 开头(即函数从 signed state 开始,专门验证 initialRAState 处理)。用 llvm-mc + clang -Wl,-q 生成二进制后运行 llvm-bolt --no-threads --print-all | FileCheck,检查点包括:两个函数都不报 inconsistent RAStatesfoopaciasp 之后正确生成 OpNegateRAStatebar 的 DWARF CFI 输出为 0: OpNegateRAState1: OpNegateRAState 两条且顺序正确;最终 llvm-objdump -d -j .text 中两函数都位于新的 .text 段。
  • pacret-cfi-incorrect.s:构造 RA state 不一致的输入,验证 Analyzer 报 BOLT-INFO: inconsistent RAStates 并将函数忽略。
  • pacret-cfi-disallow.s:验证上文开关,--update-branch-protection=false 时输出 BOLT-ERROR: --update-branch-protection is set to false, but foo contains .cfi-negate-ra-state
  • 运行时验证:pacret-synchronous-unwind.cpp 在真机上运行带同步展开表的程序,确认忽略路径不会破坏运行;单元测试 PointerAuthCFIFixup.cpp 则以 IR 片段形式直接验证 inferUnknownStates 等推断逻辑。

如果你在自己的机器上复现,最小命令形如(测试中的等价流程):

# 由仓库内测试 pacret-cfi.s 的 RUN 行给出
llvm-mc -filetype=obj -triple aarch64-unknown-unknown pacret-cfi.s -o out.o
clang $CFLAGS out.o -o out.exe -Wl,-q
llvm-bolt out.exe -o out.exe.bolt --no-threads --print-all
llvm-objdump out.exe.bolt -d -j .text

注意这些测试依赖 AArch64 目标与 llvm-bolt 构建,运行前提以 bolt/docs/GettingStarted.md 中的构建说明为准。

小结

BOLT 通过“优化前分析 + 优化后重写”的双 Pass 架构解决 pac-ret 加固二进制的 CFI 失效问题:PointerAuthCFIAnalyzer 在 CFI 读取阶段以 MCAnnotationinitialRAState 保存全部信息,逐指令计算 RA state 并隔离不一致函数;PointerAuthCFIFixup 在最终布局上推断未知状态(含函数拆分、stub 等新指令场景),仅在相邻指令 state 变化处重新发射 DW_CFA_AARCH64_negate_ra_state。对使用者而言,默认开启的行为是无感的,出错函数会被安全地原样保留在 .bolt.org.text 中,而 --update-branch-protection=false 提供了显式的禁用开关;配合 bolt/test/AArch64/pacret-cfi*.s 系列测试,整个重写过程是可检查、可回归验证的。

登录后查看全文
热门项目推荐
相关项目推荐