摘要

ARM64 的指针认证通过 PACIA/PACIB 指令为返回地址和函数指针附加密码学签名,阻断了传统 ROP/JOP 攻击。然而,PAC 的密钥分域——指令地址用 A-key,数据地址用 B-key——留下了裂隙。攻击者可以在 JavaScript JIT 编译过程中喷射精心构造的 shellcode,利用 JIT 引擎生成混合了返回指令序列的代码,在指令域签名却在数据域校验,从而绕过 PAC 验证。

ARM64 PAC密钥分域与签名

PAC 指令与密钥

ARMv8.3-A 引入了指针认证(PAC),使用基于 QARMA 的轻量级 MAC 算法为 64 位指针生成认证码,嵌入指针的高位。核心指令:

  • PACIA Xd, Xn:使用指令 A-key 对 Xn 生成签名,存储到 Xd。
  • PACIB Xd, Xn:使用指令 B-key 对 Xn 签名。
  • AUTIA Xd, Xn:验证 A-key 签名,若失败则将 Xd 设为无效指针。
  • AUTIB Xd, Xn:验证 B-key 签名。

A-key 用于指令地址(如返回地址、函数指针),B-key 用于数据地址(如虚函数表指针)。这构成了密钥分域:一个域下生成的签名无法在另一个域下通过验证。

密钥分域安全

分域设计假设攻击者无法同时控制指令域和数据域的签名生成。但在 JIT 环境下,这一假设被打破。JIT 编译器动态生成代码,其中包含大量的函数调用和返回指令序列。如果攻击者能够操控 JIT 生成的代码,在指令域中嵌入特定的返回指令序列,并在数据域中喷射与之对应的指针,就可能制造跨域 PAC 伪造。

JavaScript JIT 喷射的跨域攻击窗口

V8 JIT 的代码生成与返回地址签名

V8 的 TurboFan 编译器在生成代码时,对每个函数入口会使用 PACIBSP 对返回地址签名(存储在栈上),在函数返回前使用 AUTIBSP 验证。默认情况下,JIT 生成的代码运行在指令域内,受 A-key 保护。

JIT 喷射中的返回指令序列混淆

JIT 喷射的核心是构造一个巨大的常量数组(如浮点数组),其中包含精心挑选的指令字节,通过跳转到这些常量的不同偏移位置来执行不同的指令序列。在 ARM64 上,攻击者可以喷射以下混合:

  1. 一段模拟 AUTIB 失败后不会崩溃的合法代码路径(即不会产生异常,因为 AUTIB 失败通常会将指针置零,导致空指针访问崩溃,但攻击者可以提前在零地址映射内存,使得零地址解引用有效)。
  2. 使用 B-key 伪造的返回地址。由于 JIT 数据区(常量池)内的数据地址通常使用 B-key 签名,攻击者可以预先用 PACIB 对目标返回地址签名,然后将签名后的指针嵌入数据区。当 JIT 代码加载这个伪造的返回地址并执行 RET 时,由于硬件自动用 B-key 验证(AUTIB),验证通过,CPU 即跳转到攻击者指定的代码。

这一跨域过程本质上是:在指令域(A-key)生成代码,将 B-key 签名的伪造返回地址带入控制流,完成从 A-key 到 B-key 的信任转移。

从JavaScript到跨域PAC伪造

构造恶意 JIT 函数

攻击者需要构造一个特殊的 JavaScript 函数,使其 JIT 编译后生成如下模式的代码:

  1. 读取常量池中的一个 64 位值(即伪造的返回地址)。
  2. 将该值写入 Link Register (X30) 或直接作为返回地址压栈。
  3. 执行返回指令 RET

这可以通过控制 JIT 优化来实现,例如使用 try/catch、生成特定形状的对象等,诱导 TurboFan 发射特定的加载/返回序列。

跨域签名伪造的数据准备

在 JavaScript 中,利用 ArrayBuffer 和 Float64Array 可以精确控制二进制数据。攻击者通过预先计算 B-key 签名的有效载荷地址(例如目标 shellcode 的地址,该地址已经用 B-key 签名),并将其作为常量写入数组。随后诱导 JIT 将这组常量编译为只读数据段,并将对应的机器码生成到指令段。

利用零地址映射应对验证失败

由于 ARM64 上 AUTIB 失败会导致 X30 被置为零(在某些实现中为 -1),如果 CPU 跳转到零地址,通常会导致崩溃。攻击者可以通过在 JavaScript 中分配大型 ArrayBuffer 并利用内存碎片,在地址 0 附近映射一页,写入 NOP sled 和最终的 shellcode,从而安全接管执行流。

核心利用代码

ARM64 汇编级别的跨域签名伪造演示

// 假设 X1 指向数据段,其中包含使用 B-key 签名的目标地址
// 攻击者预先用 PACIB 计算好的值:target_b_signed
LDR X30, [X1, #0]    // 将伪造的返回地址加载到链接寄存器
AUTIB X30, SP        // 使用 B-key 验证(与 SP 作为上下文)
CBNZ X30, skip_trap  // 如果验证成功(X30 != 0),跳转
// 如果失败,X30 为零,执行零地址 shellcode(攻击者已映射)
MOV X0, #0
BR X0                // 跳转到零地址(已部署 payload)
skip_trap:
RET                  // 正常返回,实际跳转到攻击者期望的目标

在实际攻击中,攻击者会确保 target_b_signed 是用 B-key 正确签名的一个合法代码地址,这样 AUTIB 成功,直接执行 RET 跳转到 shellcode。

JavaScript 侧构造 JIT 常量喷射

// 构造一个包含 B-key 签名地址的 Float64Array
// 假设 target_addr 是 shellcode 的地址,事先用 PACIB 签名(通过原生函数计算)
// 这里仅展示概念,实际签名需要通过外部计算(例如预先在原生代码中计算好)

functiongenBKeySignedAddress(targetAddr, modifier) {
// 模拟 PACIB 计算(实际硬件相关,这里用伪码)
// 实际攻击中,攻击者在编译时计算好签名值
return targetAddr ^ (modifier & 0xFFFF000000000000); // 简化
}

const TARGET_ADDR = 0x1234567800n;  // shellcode 地址
const MODIFIER = 0xabcdef0000000000n;
const signed = genBKeySignedAddress(TARGET_ADDR, MODIFIER);

// 将 64 位值表示为两个 32 位值放入 ArrayBuffer
const buf = newArrayBuffer(8);
const view = new BigInt64Array(buf);
view[0] = signed;

// 创建一个函数,强制 JIT 优化并包含该常量
functionjitSpray(arr) {
let x = arr[0];
// 诱导 JIT 生成 "LDR X0, [常量]; RET" 的代码
// ... 经过一系列优化后,JIT 可能内联常量并执行 RET
}

V8 引擎 Patch 以模拟零地址 shellcode

在真实环境中,需在 V8 启动时预先分配 0 地址内存(通过 mmap 或 VirtualAlloc),并写入 shellcode。攻击者在获得代码执行后,可通过漏洞(如 JIT 内存保护绕过)将 shellcode 写入零页面,然后触发上述跨域 PAC 绕过。

防御

  • 细粒度 PAC 密钥管理:限制 B-key 在用户态的使用,对 JIT 生成的数据段使用不同于标准 B-key 的派生密钥,使攻击者难以预计算签名。
  • JIT 常量隔离:将 JIT 生成的常量数据放置于不可执行页,并禁止在这些页中使用 B-key 签名。
  • 控制流完整性(CFI)补充:配合 Clang CFI,检查间接分支目标是否合法。
  • 硬件更新:未来的 ARMv9 可能引入更细粒度的 PAC 密钥(如每个 EL 级别独立密钥),缩减跨域攻击面。

结语

PAC 并非无懈可击。JavaScript JIT 引擎的动态代码生成能力,为攻击者提供了跨越密钥域的桥梁。通过巧妙地喷射 B-key 签名的返回地址,并配合零地址映射,攻击者能够打破指令与数据之间的密码学壁垒。这一攻击手法再次印证了:在复杂的系统软件栈中,安全特性的设计假设需覆盖从高级语言到微架构的所有层级,任何未预见的交互都可能滋生新的攻击面。对于防御者,除了依赖硬件特性,还需要在编译器、运行时和操作系统层面构建纵深防御,方能封堵这不断涌现的 PAC 绕过新维度。