ARM64 PAC 与 BTI:硬件级控制流完整性的深度工程实践

现代处理器安全已经走出了纯软件缓解的泥潭。ARMv8.3 引入的指针认证(Pointer Authentication Codes, PAC)和 ARMv8.5 引入的分支目标识别(Branch Target Identification, BTI)代表了硬件辅助控制流完整性的工业级实现。本文将从 ISA 机制、编译器支持、内核实现到生产部署,全面剖析这两项关键技术。

一、为什么需要硬件级 CFI

控制流完整性(Control Flow Integrity, CFI)是防御 ROP/JOP/COP 等代码复用攻击的核心策略。软件层面的 CFI 实现——如 LLVM-CFI、Microsoft CFG、Intel CET——要么性能开销过大,要么粒度太粗可被绕过。

ARM 的解决方案独辟蹊径:利用指针中冗余的虚拟地址高位(VA 高位被 MMU 标记为 IGNORED),嵌入一小段密码学认证码。这不是额外的内存访问,而是利用已知会被忽略的位来承载安全元数据。

二、指针认证(PAC)的 ISA 机制

2.1 核心概念

PAC 的核心思想极为简洁:对指针值和修饰值(modifier,通常为栈指针 x29 或.constant 0)进行签名,将结果写入指针高位。在指针解引用前验证签名,若失败则触发异常。

ARM64 提供四种认证场景:

指令后缀 场景 指令示例 用途
IA/IB 通用数据指针 _AUTIA sp 认证通用指针
DA/DB 数据指针 AUTDA / AUTDB 保护数据指针
GA 通用(简化) AUTG 简化签名

修饰值(modifier)的选择决定了签名的上下文绑定强度。使用 sp 作为修饰值意味着认证码与特定栈帧绑定;使用 x29(帧指针)则与特定调用链绑定。

2.2 密钥管理

每个异常级别(EL)拥有独立的认证密钥:

APIAKey_EL1  —  EL0/EL1 使用 IA 指令时的 128 位密钥
APIBKey_EL1  —  EL0/EL1 使用 IB 指令时的 128 位密钥
APDAKey_EL1  —  EL0/EL1 使用 DA 指令时的 128 位密钥
APDBKey_EL1  —  EL0/EL1 使用 DB 指令时的 128 位密钥
APGAKey_EL1  —  通用(GA 指令)密钥

密钥在上下文切换时不会自动保存/恢复(由内核负责),且每个线程可以拥有独立的密钥值。这意味着攻击者在不知道密钥的情况下,即使知道PAC算法也无法伪造有效签名。

2.3 指令集详解

以函数返回地址保护为例,完整的 PAC 流程:

// PACIASP - 使用 SP 作为修饰值,对 LR (x30) 进行签名
// 等价于:LR' = PACIA(LR, SP)
paciasp          // 函数入口:对返回地址签名
...
// AUTIASP - 验证签名,失败时修改 LR 使其解引用时触发异常
autiasp          // 函数返回前:验证签名
ret              // 跳转到已验证的地址
间接调用保护的典型模式:

```asm
// 对 xN 中的目标地址进行签名
mov  x16, target_addr
pacia x16, x29    // 使用帧指针作为修饰值签名
...
autia x16, x29    // 验证:若认证失败,x16 高位被破坏
br   x16          // 安全跳转

三、分支目标识别(BTI)

3.1 设计思想

BTI 是 PAC 的"廉价版"补充。它不提供加密学保护,而是在跳转目标位置放置特殊指令(BTI),处理器执行到非 BTI 标记处的间接跳转时会触发异常。

BTI 有三种检测模式:

BTI 变体 保护的跳转类型 指令编码
BTI c 仅 BR <Xn>(条件间接跳转) 0xD503241F
BTI j 仅 BLR <Xn> / BRAA/BRAB 0xD503245F
BTI jc 两者都保护 0xD503249F

3.2 编译器的 BTI 插入策略

编译器通过在间接跳转的合法目标处插入 BTI 标记来工作。这些位置包括:

  • 每个函数入口(如果函数可能被间接调用)
  • switch/case 跳转表目标(可能被攻击者操控)
  • 虚函数表对应的函数入口

配合 -mbranch-protection=standard 编译选项时,GCC/Clang 自动插入:

# 完整保护配置
-mbranch-protection=standard
# 展开为:
#   -mbranch-protection=pac-ret+bti

四、编译器与工具链支持

4.1 Clang/LLVM 的实现

LLVM 在 ARM64 后端实现了完整的 PAC/BTI 指令选择。关键 pass:

AArch64BranchTargets.cpp    — 识别所有间接跳转目标,插入 BTI
AArch64PointerAuth.cpp      — 指针认证指令选择与签名/验证插入

关键编译选项:

# 启用返回地址 PAC(无需 BTI)
-mbranch-protection=pac-ret

# 返回地址 PAC + BTI
-mbranch-protection=standard

# 指定签名修饰值(默认 sp)
-mbranch-protection-key=[asp|bsp]

# 完整的非标准配置
-mbranch-protection=pac-ret+bti+leaf

4.2 GCC 支持

GCC 10+ 支持等价的 -mbranch-protection 选项。GCC 的 PAC 实现独立于 LLVM,但 ABI 兼容:

# 标准保护
gcc -mbranch-protection=standard -march=armv8.5-a ...

# 仅 BTI
gcc -mbranch-protection=bti

4.3 性能影响

ARM 官方数据与独立 benchmark 显示:

工作负载类型 PAC 开销 BTI 开销
整数运算密集 < 1% < 0.5%
系统调用密集 ~ 1-2% < 0.3%
函数调用密集 ~ 2-3% < 1%
内存密集 < 0.5% < 0.5%

BTI 开销几乎可以忽略,因为 BTI 指令本质上是 NOP 的变体(1 字节解码,零延迟)。PAC 需要真正的密码学运算,开销主要来自动态签名的函数返回和间接跳转。

五、内核层面的实现

5.1 Linux 内核支持

Linux 5.0+ 添加了对 PAC 的初步支持,后续版本持续完善:

// arch/arm64/include/asm/pointer_auth.h
static inline unsigned long pacia_keyi(void)
{
    unsigned long key;
    // 从 per-thread 存储中读取 APIAKey
    // 密钥在 execve() 时由内核生成
    key = thread_info->apia_key;
    return key;
}

// 内核 PAC 启用流程
void __init init_pointer_auth(void)
{
    // 1. 检测硬件支持
    if (!cpus_have_final_cap(ARM64_HAS_POINTER_AUTHUNG))
        return;

    // 2. 生成设备密钥
    get_random_bytes(&global_keys, sizeof(global_keys));

    // 3. 启用 HWCAP 标记
    elf_hwcap |= HWCAP_PACA;
}

5.2 上下文切换

内核在 arch/arm64/kernel/process.c 中处理 PAC 密钥的上下文切换:

// 保存旧任务的 PAC 密钥
#define __pacia_regs(prev) \
    prev->thread.keys_user.apia = read_sysreg(APIAKey_EL1);
    prev->thread.keys_user.apib = read_sysreg(APIBKey_EL1);
    prev->thread.keys_user.apda = read_sysreg(APDAKey_EL1);
    prev->thread.keys_user.apdb = read_sysreg(APDBKey_EL1);

// 恢复新任务的 PAC 密钥
#define __pacia_regs(next) \
    write_sysreg(next->thread.keys_user.apia, APIAKey_EL1);
    write_sysreg(next->thread.keys_user.apib, APIBKey_EL1);
    // ... 依此类推

密钥在线程创建时通过 getrandom() 生成,确保跨线程的 PAC 值不可预测。

5.3 BTI 的内核支持

Linux 5.6+ 开始支持 BTI:

// arch/arm64/kernel/asm-offsets.c
// BTI 通过 ELF 程序头(GNU_PROPERTY_AARCH64_BTI)标记
// 内核解析该标记并设置 PTE 属性

#ifdef CONFIG_ARM64_BTI_KERNEL
// 内核自身使用 BTI 保护
STATIC_ASSERT(BTI_ALIGN == ALIGN);
#endif

用户空间的 BTI 支持通过 ELF 标记传递:

// 检查 ELF 是否要求 BTI
if (phdr->p_flags & GNU_PROPERTY_AARCH64_BTI) {
    // 设置 PTE 的 PTE_BTI 位
    // MMU 在间接跳转时检查目标页是否设置了 BTI
    prot |= PTE_BTI;
}

六、生产环境部署

6.1 Apple Silicon(M1/M2/M3/M4)上的实现

Apple 是 PAC 的最大规模部署者。Apple Silicon 的 PAC 实现有几个关键特点:

// XNU 内核中的 PAC 实现要点:
// 1. 每线程独立密钥(进程共享一个密钥,B键区分)
// 2. 使用 %leaf 模式实现零开销叶子函数保护
// 3. 与 PT_DENY_ATTACH 反调试机制集成
// 4. JIT 代码也需要 PAC(Apple Silicon 的 APRR 机制)

// 性能数据:Apple M1 的 PAC 延迟约为 2-3 个周期
// 签名/验证流水线深度:3 stages
// 不阻塞后续指令的乱序执行

Apple 进一步将 PAC 扩展到指针认证数据(PACDA)用于数据指针,实现了近似于 CFI 对数据的保护效果。这使得 M 系列芯片上的 Objective-C/Swift 方法调用天然安全。

6.2 Android 平台

Android 12+ 在支持 PAC/BTI 的设备上默认启用:

// device/google/gs101/BoardConfig-common.mk
// 启用 PAC/BTI
BRANCH_PROTECTION := 1

// 具体编译标志
TARGET_BTI := true
TARGET_PAC := true

Android 的 Scudo 分配器也集成了 PAC 来保护堆元数据:

// 使用指针认证保护 chunk header
__attribute__((noinline))
void* tag_ptr(void* ptr, uint64_t context) {
    return __builtin_ptrauth_sign_unauthenticated(ptr, 0, context);
}

6.3 服务器级部署

在 ARM Neoverse 平台上部署 PAC/BTI 时需要考虑:

# 1. 确认内核支持
cat /proc/cpuinfo | grep -i paca
cat /proc/cpuinfo | grep -i bti

# 2. 编译选项
export CFLAGS="-mbranch-protection=standard -march=armv8.5-a+pauth"
export LDFLAGS="-z force-bti -z paccrypt"

# 3. 验证二进制
readelf -n binary_file | grep -A2 "AArch64"
# 输出应包含:
#   Properties: aarch64 feature: BTI, PAC_FUNC

七、攻击与防御:PAC 的安全分析

7.1 已知攻击向量

尽管 PAC 大幅提升了攻击门槛,但并非无懈可击:

密钥窃取攻击:

// 若攻击者有任意读原语:
// - 可直接读取 APIAKey_EL1 寄存器(需 EL1 权限)
// - 若在 EL0 下,需找到泄露内核指针的原语

PAC 碰撞攻击:

// PAC 是 16-24 位的密码学哈希(取决于 VA 宽度)
// 生日攻击在约 2^8-2^12 次尝试后成功率显著
// 但实际攻击中很难达到这一点(会因 EXBAD 异常被检测)

签名 oracles:

// 合法代码中存在大量 PAC 签名操作
// 攻击者如果能控制修饰值,可能泄露有效 PAC
// 对策:使用进程独立的密钥 + 线程独立的修饰值

Return Address Stack (RAS) 溢出:

// 某些实现中(如 Apple),RAS 是 PAC 签名值的缓存
// 硬件 RAS 通常只有 8-16 条目
// 超出 RAS 深度的嵌套调用中,PAC 可能被暴力猜测
// 实际攻击:极低概率(1/2^16 每次尝试)

7.2 防御策略最佳实践

为最大化 PAC 的安全收益:

// 1. 使用唯一的上下文值
ptr = ptrauth_sign(ptr, ptrauth_key_function_pointer, (uintptr_t)unique_id);

// 2. 不要重复使用修饰值
//  BAD: 所有函数使用同一个 modifier
// GOOD: 基于栈指纹使用不同 modifier

// 3. 结合其他防御层
// - Stack canaries (PAC 的冗余补充)
// - ASLR/PIE(增加基址熵)
// - W^X / PXN(限制可执行内存)
// - MTE(内存标记,互补保护)

八、PAC/BTI 与其他 CFI 技术的对比

特性 ARM PAC/BTI Intel CET LLVM-CFI Microsoft CFG
保护粒度 指针级 返回地址+间接跳转 函数签名级 间接调用目标集
性能开销 1-3% 2-5% 5-15% < 1%(粗粒度)
兼容性 二进制透明 需重新编译 需源码重编译 有限兼容
攻击面覆盖 返回地址+间接跳转+数据指针 返回+间接跳转 有限(类型匹配) 极低
部署成熟度 Apple/Android 亿级部署 Windows+Linux 实验阶段 Windows
密码学强度 HMAC-SHA256 SHA-256 (shadow stack) 无 无

九、实战示例:PAC 保护的自定义内存分配器

下面演示如何用 GCC/Clang 内置函数实现 PAC 保护的指针密封:

#include <ptrauth.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

// 指针密封:保护 allocator 元数据
typedef struct {
    void*  original_ptr;
    size_t size;
    size_t magic;
} alloc_header_t;

static inline void* seal_pointer(void* ptr, uintptr_t context) {
    return ptrauth_sign_unauthenticated(ptr, ptrauth_key_process_independent_data, context);
}

static inline void* unseal_pointer(void* ptr, uintptr_t context) {
    void* result = ptrauth_auth_data(ptr, ptrauth_key_process_independent_data, context);
    if (!result) {
        // 认证失败:可能是篡改或 UAF
        abort();  // 立即终止,比内存损坏更安全
    }
    return result;
}

void* pacc_malloc(size_t size) {
    alloc_header_t* hdr = malloc(sizeof(alloc_header_t) + size);
    if (!hdr) return NULL;

    uintptr_t salt = (uintptr_t)hdr ^ size;  // 上下文绑定到分配器实例

    hdr->original_ptr = hdr + 1;  // 用户指针紧随 header
    hdr->size = size;
    hdr->magic = 0xDEADBEEFCAFE;

    // 密封元数据指针
    return seal_pointer(hdr->original_ptr, salt);
}

void pacc_free(void* user_ptr) {
    // 解封原始指针
    uintptr_t context = (uintptr_t)user_ptr;  // 简化:实际需要从别处获取 salt
    alloc_header_t* hdr = ptrauth_strip(user_ptr, ptrauth_key_process_independent_data);
    hdr -= offsetof(alloc_header_t, original_ptr) / sizeof(void*);

    // 验证 magic
    if (hdr->magic != 0xDEADBEEFCAFE) {
        abort();  // double-free 或 heap overflow
    }

    hdr->magic = 0;  // 清除防止 double-free
    free(hdr);
}

// 函数返回地址保护演示
void protected_function(void) {
    void (*callback)(void) = ptrauth_auth_function(
        some_trusted_callback,
        ptrauth_key_function_pointer,
        0
    );
    callback();
}

int main(void) {
    void* ptr = pacc_malloc(64);
    if (!ptr) return 1;

    printf("PAC-protected allocation: %p\n", ptr);
    pacc_free(ptr);
    return 0;
}

编译并验证:

# 编译(启用 PAC/BTI)
clang -mbranch-protection=standard -march=armv8.5-a+pauth \
      -O2 -o pac_demo pac_demo.c

# 验证 ELF 属性
readelf -n pac_demo | grep -A3 "AArch64"
# GNU          0x00000010      NT_GNU_PROPERTY_TYPE_0
#   Properties: aarch64 feature: BTI, PAC_FUNC

# 反签名验证保护存在
otool -arch arm64 -Vv pac_demo | grep -i "paci\|auti"

十、未来的演进方向

ARMv9.4 引入的 PAuth2 和 FPAC 扩展进一步强化了 PAC:

  • PAuth2:增加碰撞保护,防止不同上下文的 PAC 值互换
  • FPAC:认证失败直接触发异常(而非等到解引用时),消除时间差攻击面
  • LPA2(Large Physical Address):增强 52-bit 物理地址系统的 PAC 强度
  • RME(Realm Management Extension):PAC 在机密计算领域的延伸

在 RISC-V 社区,J 扩展(指针认证)也在讨论中,可能会参考 ARM PAC 的设计哲学。

总结

ARM PAC 和 BTI 代表了一次范式转变:安全不再只是软件栈的负担,而是处理器架构的原生能力。从 Apple Silicon 到 Android 设备到 Neoverse 服务器,PAC/BTI 的亿级部署规模证明了其工程可行性。

对于系统开发者而言,理解 PAC/BTI 不再是"锦上添花",而是现代 ARM64 编程的基础能力。无论是加固内存分配器、保护 JIT 编译器、还是构建零信任的 runtime,PAC 和 BTI 都提供了其他机制难以匹敌的性能-安全平衡点。

关键要点: - PAC 用于保护指针完整性(返回地址、函数指针、数据指针) - BTI 作为轻量级补充,防止间接跳转到非预期目标 - 两者结合 -mbranch-protection=standard 即可启用 - 密钥由操作系统在上下文切换时管理,用户态无法伪造 - BTI 开销可以忽略;PAC 开销在 1-3% 范围内 - Apple Silicon 和 Android 已实现规模化部署

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部