程序里的攻防战每天都在上演。黑客们利用漏洞发动攻击,开发者们在代码里插入检测。每一种新的攻击方式的出现,就会催生出一种新的防守机制。PAC和BTI便是ARM在架构层面提出的两种防守机制,分别应对ROP(Return-oriented programming)和JOP(Jump-oriented programming)的攻击。PAC于Armv8.3-A提出,BTI于Armv8.5-A提出,这里的A表示Application,也即应用处理器架构。在主流芯片纷纷转向Armv9的今天,Android从12(S)开始便已经增加了对这两个机制的支持。
关于PAC和BTI的基本介绍,官方文档已经足够详细,因此本文无意去做重复或翻译的工作,而是把着重点放在了以下几个方面:
- PAC和BTI机制的细节和理解。
- PAC和BTI在Android中所导致的兼容性问题,以及Google针对这块的处理方式。
- PAC机制在Android stack unwind时产生的问题。
ROP和JOP攻击的本质都是利用系统库中已有的汇编代码,将一小段一小段的代码(gadget)拼接在一起,从而实现攻击逻辑。可问题的关键在于:如何从一小段代码跳转到另一小段代码?ROP利用ret
指令实现跳转,JOP则利用br
或blr
指令实现跳转。它们都是间接跳转,所谓间接,指的是跳转地址存储在其他位置(寄存器或栈)。由于需要从其他位置读取跳转地址,因此攻击者只要想办法篡改这些位置存储的跳转地址,便可以控制执行逻辑。
针对这两种攻击方式,防守的核心思路就是校验跳转的合法性,因此问题变为:什么是合法跳转?
首先来看ret
指令。在不指定参数的情况下,ret
指令取出x30
(也即lr
寄存器)存储的返回地址并跳转过去。根据AArch64的调用规则,返回地址一般在函数入口处入栈,返回前再从栈上取出,而攻击所发生的篡改行为必然发生在二者之间(通常为stack-buffer-overflow,注意不是stack-overflow)。尽管编译器层面很早就发明了“金丝雀”,通过校验栈上某个位置的值是否被篡改来检测溢出的发生,但是它无法检测非线性溢出。此外,Clang中也有过SCS(ShadowCallStack)机制,将返回地址存储到一块单独的内存区域来规避stack-buffer-overflow对执行逻辑的影响,但是这种机制并非牢不可破,Shadow Stack的起始地址仍然有办法可以获取。总的来说,现有机制尚存弊端。
上述两种机制将目光瞄准了攻击方式,但是并没有对返回地址本身做校验。举个例子,校验房间里的宝物是否被替换有两种方式,一种是检查窗户和门上的封条是否被破坏,另一种是用仪器检测宝物本身。目前我们还缺少一种对返回地址本身做校验的方式。PAC正好弥补了这个短板。由于入栈时的返回地址肯定是合法的,因此出栈的校验就是为了保证取出的值和入栈时一模一样。
具体的校验过程如下所示。
Function:
paciasp
...
...
autiasp
ret
编译器会在函数入口处插入paciasp
指令,为x30
中的返回地址生成特殊的校验码。校验码的生成需要三个输入,以及特定的算法。第一个输入Pointer
为返回地址;第二个输入Key
是一个随机密钥,用户态无法获知该值;第三个输入Modifier
是一个调整参数。对paciasp
而言,它的Key
为IA(key A for instruction pointers)
,Modifier
为sp
寄存器的值。之所以用sp,一是因为入栈和出栈的sp相同,这样才不会干扰检测;二是因为多次进入同一个函数的sp不同,这样才能保证多次进入的校验码不同。对Android而言,其采用的校验码生成算法为QARMA。最终生成的校验码会做个截断,截断后的数字存在返回地址的高位(Android中64位的地址中只有低39位用于寻址,因此高位可以用来干一些其他事)。这样后续入栈的返回地址就已经包含了校验码。
在函数需要返回时,编译器插入的autiasp
会对返回地址做一个校验。指令内部会根据返回地址重新计算校验码,并和返回地址高位的校验码进行比对。如果相同,则校验成功;反之则校验失败。现在当攻击者篡改返回地址时,他还需要构造一个可以通过校验的校验码。不过这很困难,原因在于Key
在EL0态是无法获知的,只能由指令内部访问,另外我们也无法通过现有的校验码反推Key
值。
那么如果校验失败,我们会看到什么错误呢?
在Armv8.6-A之前,autiasp
校验失败并不会报错,而是让返回地址保持invalid状态(53:54bit变为1),这样后续不管是ret
还是其他跳转,只要用到这个地址就会发生错误(SIGSEGV),从而让进程崩溃。从Armv8.6-A开始,FPAC(Fault PAC)扩展被引入,autiasp
校验失败将会直接触发SIGILL。为什么会有这个改变?官方的解释如下,不过我没太理解,所以就不胡言乱语了。
Where an attacker can gain access to the address returned by an AUT* instruction, they can potentially make repeated guesses at the correct PAC for the address.
To mitigate against such attacks, a new extension (FPAC) is added in Armv8.6-A. Implementations with FPAC generate an exception on an AUT* instruction where the PAC is incorrect. Preventing an attacker making multiple attempts to guess the correct PAC for a given address.
说完ret
指令后,我们再来看看br
和blr
指令。
这两个指令理论上可以跳转到任意位置,但正常函数调用时只会跳转到函数入口或是分支跳转的入口(譬如switch-case),而JOP攻击寻找的那些gadgets基本都是非正常入口。因此我们可以给正常入口增加一个标签,拥有这个标签的跳转才是合法跳转,而这正是BTI所做的事。
当一个ELF文件开启BTI后,编译器会在所有正常入口处插入一条bti c
或bti j
指令,称为”landing pad”,着陆点。当br
或blr
试图跳转到这个ELF文件时,指令内部便会做检测:如果跳转到的地址恰好为”landing pad”,那么检测通过;反之则抛出异常,结束进程。
以上便是我对两个机制的基本理解,下面会讨论一些更加细节和实践的内容。
首先讨论兼容性。这里的兼容性分为几个维度:
- 编译器插入的
pac
、aut
、bti
指令在低版本(譬如Armv8.1-A)的ARM硬件上会不会有问题? - 一个进程中,有些库开启了PAC、BTI,有些库没有开启,跳转交互时会不会有问题?
- Android生态中一些现有的“黑科技”和PAC、BTI是否有冲突?譬如Hook、加固、DRM。
第一个问题,答案是不会有问题,原因是这些指令在编码时特意选择了NOP Space,当运行在低版本硬件上时,CPU会将它们识别为NOP指令。
第二个问题,答案依然是没有问题。原因是PAC检测的是同一个函数的入口和出口,不涉及到不同库的交互;而BTI的检测开关位于”Translation Table”中,当某个库开启BTI后,它所在的那段内存将打开检测。所以说BTI是一个目的地属性,从一个打开BTI的库跳转到关闭BTI的库,并不会触发检测。
第三个问题,答案是有冲突,这也是为什么Android 12上原本打开的PAC、BTI后来关闭的原因。第三方生态(尤其是国内)对于这个变动的准备不足,导致一些严重的兼容性问题短期内无法修复。
PAC打开后,会导致一些使用OpenSSL的App挂掉,原因是那些App使用的OpenSSL版本并未包含这笔改动。
st1 {$ACC4}[0],[$ctx]
.Lno_data_neon:
- .inst 0xd50323bf // autiasp
ldr x29,[sp],#80
+ .inst 0xd50323bf // autiasp
ret
.size poly1305_blocks_neon,.-poly1305_blocks_neon
可以看到改动之前的autiasp
位于ldr x29,[sp],#80
之前,因此验证时使用的sp
值和入栈时paciasp
使用的sp
并不相同,这样即便返回地址没有被篡改,校验也无法通过。
BTI打开后,会导致一些使用了特定加固方案的App挂掉,原因是加固方案里hook了某些基础函数,并采用br x17
的方式返回,而不是ret
。调用后的返回地址并不会有”landing pad”,因此检测无法通过。
得知这些问题后,Google采用了以下三笔改动来关闭PAC和BTI。
- Disable pointer authentication in app processes(1)、Disable pointer authentication in app processes(2)
- Disable BTI for now
具体而言,PAC从运行时层面关闭,采用prctl
的方式,通过修改线程的SCTLR_EL1(System Control Register for EL1)来关闭针对IA
key的校验和检测。因此我们在ELF文件里仍然可以看到autiasp
指令,但是它们对关闭的线程而言相当于NOP。BTI从编译器层面关闭,通过修改-mbranch-protection
让编译器不再往ELF文件中插入bti
指令。
不过没多久,等到Google和App厂家广泛沟通后,这两个机制又都默认打开了。
除了这些三方App的兼容性问题外,这里再补充一些其他导致PAC、BTI错误的问题。
[PAC]
- Stack-buffer-overflow或栈上其他内存问题,将返回地址踩踏,因此检测无法通过。
IA
在函数执行过程中被修改,导致入栈和出栈时用到的key值不同,因此检测无法通过。IA
检测在执行过程中被关闭,导致出栈时高位PAC值无法被清除,后续使用时会发生SIGSEGV的错误。
[BTI]
- 一些库中有手写的汇编代码,譬如
libart.so
、libc.so
库。编译器并不会为手写的汇编插入bti
指令,因此当这些库打开BTI时,手写的汇编也需要及时更新。
说完PAC和BTI造成的稳定性问题后,最后再讲一下最近发现的AOSP问题,恰好和PAC机制有关。
libunwindstack
库主要用于Android的native调用栈回溯,对大部分库而言,它使用的是库中的eh_frame
段。eh_frame
本意是为C++异常处理设计的,因此通常不会为函数的prologue和epilogue生成CFI(Call Frame Info)信息,原因是prologue和epilogue里不会发生synchronous exception。不过autiasp
指令恰好位于epilogue,如果SIGILL从这条指令抛出,那么unwind时所使用的CFI信息将是失真的。好在Android中的系统库都打开了-fno-omit-frame-pointer
,分析下来这个问题只会让调用栈丢失一帧,不算严重。
跟Google沟通后,得知这个问题在最新的Android上已经被修复,具体的patch为Async unwind – function prologues。Android中的Clang版本落后于社区最新版本,因此21年10月份的社区改动在近期才出现在Android的Clang中,具体来说,suffix >= 457016的Clang版本将会包含这个修复。
[参考文献]