arm64e 指针认证:PAC 密钥、PACIASP 与 A12

Lobsters Hottest 新闻

摘要

一篇深入解析 Apple arm64e 指针认证(PAC)ABI 的博客文章,讲解 PAC 密钥如何存储在内存之外,PACIASP/AUTIASP 等指令如何对指针位进行签名与验证,以及如何在原厂硬件上直接测量 A12 芯片的 PAC 字段布局。

<p><a href="https://lobste.rs/s/ruq2us/arm64e_pointer_authentication_pac_keys">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/30 09:12

# arm64e 指针认证:PAC 密钥、PACIASP、A12 来源:https://sigreturn.com/blog/pointer-authentication-arm64e/ 上一篇文章(https://sigreturn.com/blog/zone-allocator/)在差一步的地方收了尾。凭据、MACF(强制访问控制框架)标签和进程状态都搬进了只读 zone,而少数被允许写入这些数据的分配器例程运行在特权上下文中。任意内核写操作够不到这些数据。但调用其中一个例程就能,而调用意味着要让你选定的地址进入一个硬件即将跳转的寄存器。这一步过去是最简单的:找到目标稍后会调用的指针,覆盖它,等着。Apple 的应对不是在写入端再加一道检查。在 arm64e——Apple 的指针认证 ABI——中,指针在其自身的未使用位中携带一个密码学代码,硬件在跳转时验证该代码,而验证用的密钥根本不存储在内存中。再多的读写操作也恢复不了密钥,因此本文的攻击转向修饰符(modifier)、签名操作,或崩溃本身。本文所涉一切均为公开内容:ARM 架构、Apple 自己的 arm64e ABI 文档、开源的 XNU 和 objc4,以及已发表的研究。实机演示在一台未做任何修改的原装 Mac 上进行。本文不包含漏洞利用,也没有绕过链。 ## 指针的多余位 指针不需要 64 位。在下面使用的这台 Mac 上,用户进程获得 47 位虚拟地址,因此每个有效用户指针的第 63 位到第 47 位为零,而内核指针中这些位全为 1。硬件强制要求如此:地址顶部的未使用位必须是地址本身的符号扩展,否则地址转换失败。指针认证(PAC)将代码写入这一死区,但必须绕开第 55 位,因为第 55 位告诉 MMU 该地址属于用户半区还是内核半区。因此该字段分为两段: ``` 63 56 55 54 47 46 0 +-----------+--+-----------+---------------------------------+ | PAC | | PAC | virtual address | +-----------+--+-----------+---------------------------------+ ^ bit 55, preserved: 0 selects TTBR0 (user), 1 selects TTBR1 (kernel) ``` 顶部字节忽略(Top-byte-ignore,TBI)允许软件在第 63 位到第 56 位中保留一个标签,MMU 会忽略该标签;当该类指针启用 TBI 时,代码会丢失顶部字节。在这台机器上,指令指针获得 16 位——顶部字节中的 8 位,加上第 54 位到第 47 位中的另外 8 位——实机演示直接测量了这一点。Brandon Azad 在 2019 年对 A12 上的内核指针做了同样的测量,发现掩码为 `0xff7fff8000000000`,是一个 24 位字段,因为那里的内核地址更窄,留下的空间更多。 ## 签名、验证、失败 两条指令完成签名和检查: ``` pacia x0, x1 ; sign the pointer in x0 with key IA, modifier in x1 autia x0, x1 ; authenticate x0 against the same key and modifier ``` `autia` 根据它找到的地址和给定的修饰符重新计算代码,将其与指针中存储的代码比较,成功后返回一个干净的、可解引用的地址。`xpaci` 在不检查的情况下剥除代码,适用于只需要裸地址的工具场景。`pacga` 完全不操作指针:它取一个寄存器中的值和第二个寄存器中的修饰符,将一个 32 位代码写入第三个寄存器的高位半部分,用于对非地址数据进行签名。每条指令都有一个修饰符不来自寄存器的变体。末尾的 `z` 表示修饰符为零:`paciza`、`autiza`、`braaz`、`blraaz`。另一些硬编码寄存器,链路寄存器就是这样处理的:`pacibsp` 对栈指针签名,`autibsp` 对其验证,而 `paciasp` 和 `autiasp` 是密钥 A 下的同一对指令。 失败时的行为随时间发生了变化。ARMv8.3 最初的规范不触发异常。它会剥除指针并写入一个两位错误码——密钥编号及其补码——因此 A 密钥留下 `01`,B 密钥留下 `10`。这对位在该类指针的 TBI 关闭时位于第 62 位和第 61 位,TBI 开启时位于第 54 位和第 53 位,无论哪种情况地址都成为非规范地址,因此*下一次*解引用会触发转换异常。`FEAT_FPAC` 将异常提前到 `AUT` 指令本身,`FEAT_FPACCOMBINE` 进一步扩展到融合形式——即认证后立即跳转或加载的单条指令。这些是累积的功能级别,一颗芯片要么停在其中某一级,要么实现下一级。这台 Mac 报告: ``` sysctl hw.optional.arm | grep -i 'pauth\|fpac\|pac' hw.optional.arm.FEAT_PACIMP: 1 hw.optional.arm.FEAT_PAuth: 1 hw.optional.arm.FEAT_PAuth2: 1 hw.optional.arm.FEAT_FPAC: 1 hw.optional.arm.FEAT_FPACCOMBINE: 0 ``` `FEAT_PAuth2` 是基础功能 `FEAT_PAuth` 的 ARMv8.6 修订版,`FEAT_FPAC` 建立在其之上,因此这里一个失败的独立 `autia` 会当场陷入异常——下面的实机演示遇到了这一点。融合指令不会,因为 `FEAT_FPACCOMBINE` 是关闭的:这些指令内部认证失败仍然产生一个被污染的指针,在后续跳转或加载时才触发异常。大多数签名实际上发生在这些融合形式中:`retab` 验证链路寄存器并返回,`braa` 和 `blraa` 验证并跳转,`ldraa` 验证并加载。 ## 五个密钥和一个修饰符 五把 128 位密钥驱动这些指令:**IA** 和 **IB** 用于指令指针,**DA** 和 **DB** 用于数据指针,**GA** 用于 `pacga`。它们存放在只有特权代码才能读写的系统寄存器中,而这就是其他一切所依赖的属性。用前两篇文章的术语来说:对自己地址空间的任意读取够不到它们,对内核内存的任意读取也够不到,因为它们不是内存。 arm64e 按用途对它们进行区分。Apple 的 ABI 文档称 A 密钥为进程无关的,用于全局内容——虚表和函数指针。B 密钥是局部的:返回地址和帧指针。返回地址足够重要,以至于文档原话说 IB "几乎完全保留用于此目的",这也是为什么下面的反汇编显示 `pacibsp` 和 `retab` 而不是 `paciasp` 和 `retaa`。 第二个操作数是修饰符,也称为判别式(discriminator)。代码由密钥、地址和修饰符共同计算,因此同一地址在两个不同修饰符下签名会得到两个毫不相关的签名,一个站点计算的签名在另一个站点无法通过验证。 arm64e 从两个输入构建修饰符:指针存储的地址(提供地址多样性),以及一个 16 位常量(提供常量多样性)。将常量混入地址会替换该地址的高 16 位。常量本身通常从名称派生:`ptrauth_string_discriminator` 对字符串运行 SipHash-2-4,并将结果折叠到 16 位范围内,因此一个字段可以通过其名称获得多样性。 面向返回的编程(ROP)是说明为何两半都必需的典型案例。ROP 链是一个充满地址的栈:覆盖当前函数的已保存返回地址,之后每个 `ret` 将你列表中的下一个条目弹入程序计数器,从而按你的顺序运行你的小工具。在 arm64e 上,该函数在入口处用 `pacibsp` 对返回地址签名,在返回时用 `retab` 验证,而密钥和修饰符各自去掉了填充该列表的一种方式。密钥去掉了伪造。你无法计算一个小工具地址的代码,因为计算它需要一个存储在系统寄存器中的值。修饰符去掉了复用。如果返回地址仅用密钥签名,进程中每个返回地址都会携带一个可互换的签名,那么从任何栈帧中用你已有的读取原语读出一个有效签名,就能在每个帧中通过验证,无须伪造任何东西。因为修饰符是入口时的栈指针,一个签名的返回地址只在某一个栈深度有效,其他地方都无效,所以十个小工具的链需要十个特定 SP 值下的十个有效签名。这留下的缺口很精确:恰好处于同一深度的两个帧共享同一个修饰符,而本文末尾的复用攻击寻找的就是这种情形。 计算代码的算法不是公开的。ARM 规定 QARMA——一系列轻量级可调块密码——作为实现可以使用的一种算法,并允许实现自定义替代算法。上面输出中的 `FEAT_PACIMP: 1` 旁边没有列出任何 QARMA 变体,这是 Apple 在说自己使用自有实现。 密钥分化是按任务进行的,A 密钥和 B 密钥的获取方式不同。XNU 从任务的共享区域获取 `jop_pid`(A 密钥背后的分化器),因此同一个共享缓存上的所有进程获得相同的值,而 `rop_pid`(B 密钥背后的分化器)则按任务从 `early_random()` 中获取。不是 arm64e 的任务运行时用户 PAC 完全关闭:`bsd/kern/kern_exec.c` 为该映像标记 `IMGPF_NOJOP`,随后 `bsd/kern/mach_loader.c` 用 `PMAP_CREATE_DISABLE_JOP` 创建其地址空间。Azad 在 2019 年的研究发现用户态线程携带随机密钥种子,而内核线程共享一个常量。他无法弄清真实实现,因此在该研究的其余部分假设了最健壮的设计:真实密钥是随机的,且保存在 SoC 内部。 ## Apple 签名的内容 arm64e 随 A12 于 2018 年首次搭载,M1 将其带到了 Mac 上,它覆盖了 Apple 构建的一切:内核、dyld 共享缓存(所有系统库合并进去的那个单一映像)、系统二进制文件。但你自己的代码不会得到它。下面的程序是 arm64e 仅仅因为我传了 `-arch arm64e`,macOS 对此也有门控:`bsd/kern/kern_exec.c` 拒绝执行非平台 arm64e 二进制文件上标注 ptrauth ABI 版本 0(预览 ABI)的文件,除非设置了 `-arm64e_preview_abi` 启动参数。当前的 clang 标注了一个更高的版本,在二进制文件上通过 `otool -hv` 显示为 `USR01`,因此它运行时未启用任何特殊功能。 在进程内部,ABI 对以下内容签名: - **返回地址**,密钥 IB,修饰符为入口时的栈指针。 - **函数指针**,密钥 IA,无修饰符,这是 ABI 中最弱的方案,也是与返回地址相对的另一端——因此通过 C 函数指针的间接调用编译为 `blraaz`,即零修饰符形式。 - **C++ 虚函数**,判别式从方法的名称和签名派生,因此一个条目不能从一个槽位或一个类中提升到另一个。 - **Objective-C `isa` 指针**,指向对象类别的字段,objc4 用 DA 密钥签名,将对象自身地址与常量 `0x6AE1` 混合,在 `objc-config.h` 中标注为 `ptrauth_string_discriminator("isa")`。 其中两项比 arm64e 本身还年轻。第一批 arm64e 设备上 `isa` 完全没有签名:随 macOS 10.15 出货的 objc4 既没有 `ISA_SIGNING_KEY` 也没有 `ExtractISA`,Apple 2019 年的 ABI 文档更是直说指针认证无法保护它。两者都在 objc4-818.2 中出现,该版本随 macOS 11.0.1 和 iOS 14 出货,因此在运行 iOS 13 的 A12 上,写入原语仍可将对象指向任意类别,而在运行 iOS 14 的同一台手机上则不行。 XNU 在同一版本中也有变动:`jop_pid` 在 xnu-6153(iOS 13 内核)中完全没有出现,那个版本只分化 `rop_pid`,不分化其他任何东西,因此在 iOS 14 之前,设备上每个 arm64e 进程都用同一组密钥对 A 密钥指针签名,一个进程中产生的签名在另一个进程中也能通过验证。 内核以同样的方式签名,它的判别式值得阅读,因为它们说明了该机制的用途。这一个保护 Mach 端口头部中的标签指针,位于 `osfmk/ipc/ipc_object.h`: ``` label.iol_pointer = ptrauth_auth_data(label.iol_pointer, ptrauth_key_process_independent_data, ptrauth_blend_discriminator(io, (uint32_t)(label.io_bits + ptrauth_string_discriminator("ipc_object.iol_pointer")))); ``` 修饰符中有三样东西:对象的地址、对象的 `io_bits`(携带其类型的字段),以及字段自身名称的哈希值。因此该指针仅在该类型的对象在该地址上的该字段中能通过验证。将一个有效签名的标签指针从一个端口复制到另一个端口会失败。改写携带标签的端口上的 `io_bits` 也会破坏该标签的指针,因为类型位是对其旁边指针签名的输入。手动设置 `io_bits` 是 XNU 那篇文章(https://sigreturn.com/blog/xnu-under-the-hood/)中伪造端口手法的前半步,而这正是使这种攻击代价高昂的耦合。 zone 分配器也考虑了 PAC。上篇文章中决定结构体从哪个 zone 分配的类型签名 `kalloc_type` 为它定义了一个粒度值:`KT_GRANULE_PAC`,在 `osfmk/kern/kalloc.h` 中记录为"表示受 PAC 约束的指针"。两个大小相同但仅因某个字段是否签名而不同的结构体,会得到不同的签名,因此属于不同的签名组。 ## 实机演示:观察指针被签名 先从一个同时涉及 arm64e 签名两种对象的函数开始:它通过函数指针调用,且不是叶子函数,因此必须保存返回地址。 ``` // t.c int call_it(int (*fn)(int), int x) { return fn(x) + 1; } ``` 分别用两种 ABI 编译两次,并反汇编两份: ``` clang -O2 -arch arm64 -c t.c -o a64.o clang -O2 -arch arm64e -c t.c -o a64e.o otool -tv a64.o otool -tv a64e.o ``` ``` a64.o: (__TEXT,__text) section _call_it: 0000000000000000 stp x29, x30, [sp, #-0x10]! 0000000000000004 mov x29, sp 0000000000000008 mov x8, x0 000000000000000c mov x0, x1 0000000000000010 blr x8 0000000000000014 add w0, w0, #0x1 0000000000000018 ldp x29, x30, [sp], #0x10 000000000000001c ret a64e.o: (__TEXT,__text) section _call_it: 0000000000000000 pacibsp 0000000000000004 stp x29, x30, [sp, #-0x10]! 0000000000000008 mov x29, sp 000000000000000c mov x8, x0 0000000000000010 mov x0, x1 0000000000000014 blraaz x8 0000000000000018 add w0, w0, #0x1 000000000000001c ldp x29, x30, [sp], #0x10 0000000000000020 retab ``` 相同的 C 代码,多了一条指令,两处改动。`pacibsp` 在链路寄存器被溢出到栈上之前,用密钥 IB 对其针对栈指针签名,`retab` 在返回之前针对同一个栈指针对其进行验证,普通的 `blr` 变成了 `blraaz`:用密钥 IA 和零修饰符验证,然后调用。 第二个程序通过手动签名指针来打印字段本身: ``` // pac.c #include <stdio.h> #include <stdint.h> int main(void) { uint64_t as_compiled = (uint64_t)(uintptr_t)main; uint64_t bare = as_compiled; __asm__ volatile("xpaci %0" : "+r"(bare)); uint64_t a = bare, b = bare, c; __asm__ volatile("pacia %0, %1" : "+r"(a) : "r"((uint64_t)0)); __asm__ volatile("pacia %0, %1" : "+r"(b) : "r"((uint64_t)1)); c = a; __asm__ volatile("autia %0, %1" : "+r"(c) : "r"((uint64_t)0)); printf("&main as compiled 0x%016llx\n", as_compiled); printf("xpaci 0x%016llx\n", bare); printf("pacia, modifier 0 0x%016llx\n", a); printf("pacia, modifier 1 0x%016llx\n", b); printf("autia, modifier 0 0x%016llx\n", c); fflush(stdout); uint64_t d = a; __asm__ volatile("autia %0, %1" : "+r"(d) : "r"((uint64_t)1)); printf("autia, modifier 1 0x%016llx\n", d); return 0; } ``` ``` clang -O0 -arch arm64e pac.c -o pac && ./pac ``` ``` &main as compiled 0x0e550001041404b0 xpaci 0x00000001041404b0 pacia, modifier 0 0x0e550001041404b0 pacia, modifier 1 0x0d7d8001041404b0 autia, modifier 0 0x00000001041404b0 [1] 19332 bus error ./pac ``` 六行输出,每一行都是本文前面某项论断的验证。`main` 的地址**到达时已经是签名过的**。程序中没有任何代码要求这样做;取地址

相似文章

ARM64 指令的真正编码方式

Lobsters Hottest

一篇科普文章,解释 ARM64 (AArch64) 指令如何以 32 位固定长度字编码,破除常见误解,并通过 Apple Silicon 上的 ADD immediate 指令提供动手解码示例。

编写可移植的ARM64汇编代码

Hacker News Top

一份关于编写可在Apple Darwin和Linux/BSD系统间移植的ARM64汇编代码的指南,涵盖ABI、符号命名和向量助记符的差异。

Windows on AArch64 同样支持热补丁,但实现比 x86 上简单得多

The Old New Thing (Raymond Chen)

Raymond Chen 解释了 Windows on AArch64 如何比 x86 更简单地实现函数热补丁:利用 12 字节的补丁空间,通过 xip0 暂存寄存器构建三条指令的跳板(trampoline),同时指出函数入口通常有指针认证(pacibsp)指令进行保护。

Windows堆栈限制检查回顾,后续

The Old New Thing (Raymond Chen)

Raymond Chen跟进了他之前关于ARM64堆栈限制检查的文章,指出了堆栈探测函数中x15寄存器的非常规使用细节,并比较了多个架构的寄存器使用。