前言
在 上一篇文章 中我们介绍了非 arm64e 下通过 IOTrap 实现 kexec 的过程。阻碍 arm64e 实现这一过程的主要因素是 PAC (Pointer Authentication Code) 缓解措施,在这一篇文章中我们将介绍 Undecimus 中绕过 PAC 机制的过程。
整个绕过过程十分复杂,本文的主要参考资料为 Examining Pointer Authentication on the iPhone XS 和 Undecimus 中与 arm64e 相关的 PAC Bypass 代码。
PAC 的一些特点
什么是 PAC 这里不再赘述,简言之就是一种对返回地址、全局指针等的一种签名与验签保护机制,详细定义和机制读者可以自行查阅资料,这里仅给出一个简单的例子来帮助理解 PAC 实现。
下面这段代码中包含了一个全局数值变量、一个基于函数指针 fptr 的动态函数调用,猜一下哪些值会被 PAC 保护呢?
1 | // pac.cpp |
下面我们用 clang 将 cpp 编译链接并生成 arm64e 下的汇编代码:
1 | clang -S -arch arm64e -isysroot `xcrun --sdk iphoneos --show-sdk-path` -fno-asynchronous-unwind-tables pac.cpp -o pace.s |
生成的完整汇编结果为:
1 | __TEXT,__text,regular,pure_instructions |
返回地址保护
这里有几个值得注意的地方,第一个是每个嵌套了调用的函数的开头和结尾处都被插入了 PAC 指令:
1 | __Z8tram_onei: |
这里 PAC 用 Instruction Key B 保护了函数的返回地址,有效防止了 JOP 攻击。
再看一下全局变量的声明和访问:
1 | __ ,__ |
可见常规的数值变量并没有在 PAC 的保护之下。
指针保护
下面我们来看一下函数指针的赋值与调用:
1 | int tram_one(int t) { |
首先可以看到 tram_one 函数地址这一全局符号受到了 PAC 保护:
1 | __ ,__auth_ptr |
step_ptr
函数中对应的访问代码:
1 | __Z8step_ptrPv: |
在执行 (reinterpret_cast<int (*)(int)>(fptr))(g_somedata);
调用时,采用了带 PAC 验证的指令:
1 | _main: |
PAC 对 JOP 的影响
在上一篇文章中我们实现 kexec 的关键在于劫持一个虚函数,这里所修改的地址有:
- 修改虚函数表的 getTargetAndTrapForIndex 指针指向 Gadget;
- 构造 IOTrap,其 func 指向要执行的内核函数。
不幸的是,这两个地址都受到了 PAC 机制的保护[1],所以我们之前的 kexec 方法在 arm64e 上就失效了。以下的代码摘自于参考资料[1]:
1 | loc_FFFFFFF00808FF00 |
由上面的代码可知,在 arm64e 架构的 iOS 12.1.2 内核代码中,虚函数表、虚函数指针和 IOTrap 的函数指针都得到了 PAC 保护。
需要特别注意的是,这里的 trap->func 调用所使用的 context 寄存器 X9 被写入了 0,即 BLRAA 相当于验签了一个 PACIZA 签名的地址,这是实现第一个受限 kexec 的重要突破口。
绕过 PAC 的理论分析
限制条件
在 参考资料[1] 的 write-up 中很大篇幅讲述了从软件白盒、硬件黑盒的角度对 PAC 进行的分析与绕过尝试,并得到了如下结论:
- 储存 PAC Key 的寄存器只能在 EL1 模式下访问,而用户态处于 EL0,无法直接访问这些系统寄存器;
- 即使我们能从内核的内存中读取到 PAC Key,如果不能逆向出完整的加解密过程,依然无法伪造签名;
- Apple 在 EL0 和 EL1 中使用了不同的 PAC Key,这就打破了 Croess-EL PAC Forgeries;
- Apple 在实现 PACIA, PACIB, PACDA 和 PACDB 这些指令时采用了不同的算法,即使全部使用相同的 Key 也会得到不同的结果,这就打破了 Cross-Key Symmetry;
- 虽然在软件层面看 PAC Key 是 hardcode 的,但事实证明每次启动 PAC Key 都会变化。
这 5 条限制每一条都刺痛着尝试绕过 PAC 的人们的心,可见苹果在这一方面做了非常多变态的保护企图将 JOP 彻底解决。此外苹果还在公开的 XNU 代码中删除了与 PAC 相关的细节,并通过控制流混淆等手段阻止黑客在 kernelcache 中轻易找到可用的 Signing Gadgets。
有利条件
不得不佩服这些内核大佬的功力,即使在如此重重保护下 Brandon Azad 依然找到了 PAC 在实现上的一些软件漏洞:
- PAC 在进行验签时,如果发现验签失败,它会将 2 位 error code 插入到指针的 62~61 区域,这里是 pointer’s extension bits;
- PAC 在执行签名时,如果发现指针的 extension bits 异常,它仍然会插入正确的签名,只是会通过翻转 PAC 的最高位 (第 62 位) 来使指针失效。
有趣的事情来了,如果我们把一个常规的地址交给 PAC 验签 (AUT*
),那么它会给指针的 extension bits 插入一个 error code 使其异常。此后如果再将这个值进行签名 (PAC*
),由于 error code 的存在会签名失败,但是正确的 PAC 依然会被计算并插入,只是指针的第 62 位被翻转了。因此我们只要找到一个先对指针的值进行 AUT*
,随后再进行 PAC*
最后将值写入固定内存的代码片段即可作为 Signing Gadget。
PACIZA Signing Gadget
基于上面的理论,Brandon Azad 在 arm64e 的 kernelcache 中发现了一个满足上述有利条件的代码片段:
1 | void sysctl_unregister_oid(sysctl_oid *oidp) |
可以看到在代码的最底部有一个 unauth 与 auth 的嵌套调用,先对 handler 执行 auth 即 AUT*
,随后立即执行 unauth,即 PAC*
,正好满足了 Signing Gadget 条件。另外一个重要条件是签名结果必须写入稳定的内存,使得我们能够轻易、稳定地读取到。这里写入的 handler_field
指向 old_oidp->oid_handler
,继续分析可知它来自于函数入参的 oidp
。
寻找 Gadget
下一步的关键就是如何触发 sysctl_unregister_oid
并控制 oidp
的值。幸运的是 sysctl_oid
是被 global sysctl tree
所持有的,用于向内核中注册参数。虽然没有任何直接指向 sysctl_unregister_oid
的指针,但许多 kext 在启动时会通过 sysctl 注册参数,在结束时会通过 sysctl_unregister_oid
实现反注册,这是一个重要的线索。
最终 Brandon Azad 在 com.apple.nke.lttp
这一 kext 中找到了一对函数 l2tp_domain_module_stop
和 l2tp_domain_module_start
,调用前者时会传递一个全局变量 sysctl__net_ppp_l2tp
来实现反注册,调用后者可以重新启动模块,并且这对函数包含可被定位的引用,该引用是通过 Instruction Key A 无 Context 签名的。
还记得文章开头提到的非虚函数地址在进行 IOTrap->func
调用时也是通过 Instruction Key A 和无 Context 进行验签的。因此我们只需要通过 XREF 技术定位到函数地址和全局变量地址,即可通过修改 sysctl__net_ppp_l2tp
来篡改 old_oidp->oid_handler
,接下来只要找到调用 l2tp_domain_module_stop
的方法就可以实现对任意地址的 PACIZA 签名了。
触发 Gadget
似乎找到 l2tp_domain_module_stop
和找到一个 kexec 一样困难,但事实上它比一个完整的 kexec 简单的多,这是因为 l2tp_domain_module_stop
是无参的。我们依然可以尝试利用 IOTrap,但这一次我们无法劫持虚函数,因此需要找到一个已存在的包含 IOTrap 调用的对象。
所幸 Brandon Azad 在 kernelcache 中找到了一个 IOAudio2DeviceUserClient 类,它默认实现了 getTargetAndTrapForIndex 并提供了一个 IOTrap:
1 | IOExternalTrap *IOAudio2DeviceUserClient::getTargetAndTrapForIndex( |
这里的 getTargetAndTrapForIndex
将 target 指定为自己,这使得 trap->func
调用的隐含参数无法修改,即通过这种方式无法传递 arg0,也就只能通过篡改 trap->func
实现无参函数或是代码块的调用。
基于上述讨论,整个 PACIZA Signing Gadget 的构造和调用过程如下:
- 通过 IOKit 的 userland 接口启动一个 IOAudio2DeviceService,获取到 IOAudio2DeviceUserClient 的
mach_port
句柄; - 通过句柄找到其
ipc_port
,其ip_kobject
指针指向的是真正的 IOAudio2DeviceUserClient 对象。先记录下对象地址,随后在对象上找到 traps 地址,由于 IOAudio2DeviceUserClient 只声明了一个 trap,traps 的首地址即我们要修改的 IOTrap 的地址; - 通过 String XREF 技术定位
l2tp_domain_module_start
,l2tp_domain_module_stop
和sysctl__net_ppp_l2tp
的地址,先缓存原始的sysctl_oid
,随后构造sysctl_oid
满足sysctl_unregister_oid
特定的执行路径,最后将sysctl_oid->oid_handler
赋值为需要签名的地址; - 修改第 2 步找到的 trap,将其 func 指向
l2tp_domain_module_stop
,并通过 IOConnectTrap6 触发 IOAudio2DeviceUserClient 对象的IOTrap->func
调用,这里便实现了对l2tp_domain_module_stop
的调用,随后会执行到sysctl_unregister_oid
,并将签名失败的结果写入sysctl__net_ppp_l2tp->oid_handler
,此时我们可以读取结果,并翻转第 62 位得到正确的签名; - 最后一步是通过
l2tp_domain_module_start
重启服务,但这里需要传递新的sysctl_oid
作为入参,通过上面的 Primitives 是无法完成的。
清理环境
由于 IOAudio2DeviceUserClient 的 IOTrap 调用仅能实现无参的 kexec,我们无法在完成 PACIZA 签名后重启 IOAudio2DeviceUserClient 服务,这会使得 Signing Gadget 失去幂等性,或是留下其他隐患,因此必须找到一个能有参调用 kexec 的办法来重启服务。
问题的关键是 IOTrap->func
调用时 arg0 指向了 this,因此单次调用时肯定无法修改 arg0 了,我们这里可以尝试多次跳转。所幸在 kernelcache 中有这样的一段代码:
1 | MOV X0, X4 |
由于我们通过 IOConnectTrap6 能控制 x1 ~ x6,所以通过 x4 既能间接控制 x0,x5 即是下一跳的地址,我们先让 IOTrap->func
指向这一片段的 PACIZA’d 地址,然后通过 x4 控制 arg0,x1 ~ x3 控制 arg1 ~ arg3,x5 控制 JOP 的目标地址,即可实现一个 4 个参数的 kexec。
因此我们只需要用上面的无参调用去签名一下上述代码块的地址,然后将其作为 IOTrap->func
的地址,再通过 IOConnectTrap6 的入参控制 x1 ~ x5 即可实现对 l2tp_domain_module_start
的带参调用,这里传递的是之前备份的 sysctl_oid
,从而完美的恢复现场。
到这里,一个完美的 PACIZA Signing Gadget 就达成了,同时我们还得到了一个非常有用的代码片段的 PACIZA 签名:
1 | MOV X0, X4 |
我们将其称为 G1,也是这是后续工作的一个重要 Gadget。
PACIA & PACDA Signing Gadget
遗憾的是许多调用点(例如虚函数)都采用了带有 Context 的调用方式,例如上文中提到的片段:
1 | context = (0x14EF << 48) | ((uint64_t)handler_field & 0xFFFFFFFFFFFF); |
这就要求我们找到包含 PACIA 和 PACDA 的代码块,且他们要将签名结果写入稳定的内存。所幸这样的 Gadget 也是存在的:
1 | ; sub_FFFFFFF007B66C48 |
这一段代码同时包含了 PACIA 和 PACDA,且后续都通过 STR 写入了内存。唯一不足的是在执行完语句后距离 RET 还有很远的距离,且当前入口点位于函数的中间位置。所幸函数真正的开场白位于这些指令之后:
1 | PACIBSP |
所以似乎我们从中部进入函数不会有太多的不良影响,在这里我们只需要控制 x9 作为指针,x10 作为 context,x2 控制写入的内存区域,即可实现一个 PACIA & PACDA 的签名伪造。
但是基于 IOAudio2DeviceUserClient 的 IOConnectTrap6 我们只能控制 x1 ~ x6,无法直接控制 x9 和 x10,这里就需要我们寻找更多的 Gadget 来实现组合调用来控制 x9 和 x10。
随后 Brandon Azad 在 kernelcache 中又搜索到了几个可利用的 Gadget,截止到目前我们总共有 3 个可用的 Gadget:
1 | ; G1 |
G1 使我们能通过 x4 控制 x0,再通过 G2 可将 x0 写入 x9,最后通过 G3 将 x3 写入 x10,G1 -> G2 通过 X5 指向 G2 实现,G2 - > G3 通过 X1 指向 G3 实现,最后通过 x6 即可跳转到包含 PACIA & PACDA 的 Gadget,此时 x2, x9, x10 均已间接填入合适的参数,因此可以完成一个 PACIA & PACDA Forgery。
上述调用环环相扣,且不能有任何寄存器上的重叠,否则将无法有效地准备参数,我们难以想象找到这么一组 Gadget 耗费了多么大的精力,在这里向大佬致敬。基于上述讨论,我们以 G1 为 IOTrap->func
的入口点,如下准备 IOConnectTrap6 的参数:
1 | trap->func = paciza(G1); |
这会形成一个链式调用,控制流如下:
1 | MOV X0, X4 |
到这里我们就通过一系列的 Gadget 和 IOConnectTrap6 实现了 PACIA & PACDA 的 Forgery。
完美的 kexec
到这里我们已经可以伪造 Key A 的任意签名,但依然没有实现完美的 kexec,此时我们还只能实现 4 个参数的 kexec,其根本原因是我们依赖于 IOAudio2DeviceUserClient 对 getTargetAndTrapForIndex 的默认实现,遗憾的是这一实现中将 target 设置为了 this 从而导致我们无法直接控制 arg0,转向 Gadget 后则会遇到 4 个参数的限制:
1 | IOExternalTrap *IOAudio2DeviceUserClient::getTargetAndTrapForIndex( |
为了能实现完美的 kexec,最好的办法依然是劫持虚函数,虽然 PAC 对虚函数表和虚函数指针做了签名,但它是通过 Key A 完成的,到这里我们已经能够伪造这些签名,从而再次实现虚函数的劫持。
修改 getTargetAndTrapForIndex 为默认实现
IOAudio2DeviceUserClient 覆盖实现的 getTargetAndTrapForIndex 给我们带来了麻烦,这里我们可以将其修改为父类的默认实现:
1 | IOExternalTrap * IOUserClient:: |
由于 IOAudio2DeviceUserClient 的 traps 不是通过 getExternalTrapForIndex 取得的,这里我们还需要继续修改 getExternalTrapForIndex 方法,使其能够返回一个构造的 IOTrap,这里遇到的一个问题是父类默认实现为返回空值:
1 | IOExternalTrap * IOUserClient:: |
这就需要我们在 IOUserClient 上找到一个合适的函数和成员变量,使得该函数返回成员变量或成员变量的某个引用,这样我们就能间接地通过控制成员变量来返回特定的 IOTrap。幸运的是 IOUserClient 间接继承了超类 IORegistryEntry,它包含了一个 reserved 成员和一个返回该成员的成员函数:
1 | class IORegistryEntry : public OSObject |
可见我们只要将虚函数表中的 getExternalTrapForIndex
指向 IORegistryEntry::getRegistryEntryID
,再修改 UserClient 实例的 reversed 使其 reserved->fRegistryEntryID
指向我们构造的 IOTrap 即可。
通过上述改造,我们再次获得了一个完美的支持 7 个入参的 kexec,理论分析起来容易,要实施这一过程是十分复杂的,因为每一个虚函数所使用的 sign context 是不同的,这就要求 dump 出所有的 sign context 再进行处理。
绕过 PAC 的代码导读
经过理论分析相信读者已经对整个绕过的过程有了整体认识,由于整个过程太过复杂,单单进行理论分析难免会让人云里雾里,将上述理论分析结合阅读 Undecimus 中的代码可以很好的加深理解。
这部分代码位于上一篇文章提到的 init_kexec
和 kexec
两个函数中,针对 arm64e 架构采用了完全不同的手段。鉴于本文的理论分析部分已涉及到大量的代码,这里不再完整的进行分析,只说几个理论分析中未完全提及的内容。完整的代码请读者结合上述理论分析自行阅读,相信你会有很大的收获。
经过上面的分析相信读者能够轻易地理解 kernel_call_init
中的 stage1_kernel_call_init
和 stage2_kernel_call_init
,这两个阶段主要是完成 UserClient 的启动和 G1 的签名工作,需要注意的是在 stage2_kernel_call_init->stage1_init_kernel_pacxa_forging
的结尾处创建了一个 buffer,用来存储新的虚函数表以及 PACIA & PACDA 的签名结果:
1 | static void |
此外 A12 在 iOS 12.1.2 的 PAC 机制也允许在 userland 通过 XPAC 指令直接将一个加签的指针还原,这给我们拷贝虚函数表带来了极大的便利,这段代码位于 stage3_kernel_call_init
中:
1 | uint64_t |
在 patch 虚函数表时,每个函数都有其特定的 context,因此这里使用了 dump 出来的对应于每个虚函数的 PAC Code,这段代码位于 stage2_patch_user_client_vtable
中:
1 | static size_t |
这里针对每个虚函数都采用了不同的 PAC Code,dump 出的 PAC Code 通过静态变量存储,并借助宏 VTABLE_PAC_CODES
进行访问,这里的每个 context 长度只有 16 位:
1 | static void |
其他部分基本在理论分析中都已提到,这里不再赘述。
总结
本文介绍了 PAC 缓解措施的特点以及 iOS 12.1.2 在 A12 上的绕过方法,整个过程可以说是让人叹为观止。通过研究整个 bypass 过程不仅让我们对 PAC 机制有了更深刻的认识,也学到了许多 JOP 的骚操作。