程序里的攻防战每天都在演出。黑客们利用漏洞发动进犯,开发者们在代码里刺进检测。每一种新的进犯方法的呈现,就会催生出一种新的防卫机制。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则利用brblr指令完成跳转。它们都是直接跳转,所谓直接,指的是跳转地址存储在其他方位(寄存器或栈)。由于需求从其他方位读取跳转地址,因而进犯者只需想办法篡改这些方位存储的跳转地址,便能够操控履行逻辑。

针对这两种进犯方法,防卫的核心思路就是校验跳转的合法性,因而问题变为:什么是合法跳转?

首要来看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而言,它的KeyIA(key A for instruction pointers)Modifiersp寄存器的值。之所以用sp,一是由于入栈和出栈的sp相同,这样才不会干扰检测;二是由于屡次进入同一个函数的sp不同,这样才干保证屡次进入的校验码不同。对Android而言,其选用的校验码生成算法为QARMA。最终生成的校验码会做个截断,截断后的数字存在回来地址的高位(Android中64位的地址中只需低39位用于寻址,因而高位能够用来干一些其他事)。这样后续入栈的回来地址就现已包含了校验码。

Android Security |  PAC和BTI机制杂谈

在函数需求回来时,编译器刺进的autiasp会对回来地址做一个校验。指令内部会依据回来地址重新计算校验码,并和回来地址高位的校验码进行比对。假如相同,则校验成功;反之则校验失利。现在当进犯者篡改回来地址时,他还需求构造一个能够经过校验的校验码。不过这很困难,原因在于Key在EL0态是无法获悉的,只能由指令内部访问,另外咱们也无法经过现有的校验码反推Key值。

Android Security |  PAC和BTI机制杂谈

那么假如校验失利,咱们会看到什么过错呢?

在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指令后,咱们再来看看brblr指令。

这两个指令理论上能够跳转到恣意方位,但正常函数调用时只会跳转到函数入口或是分支跳转的入口(比如switch-case),而JOP进犯寻觅的那些gadgets根本都是非正常入口。因而咱们能够给正常入口添加一个标签,拥有这个标签的跳转才是合法跳转,而这正是BTI所做的事。

当一个ELF文件敞开BTI后,编译器会在一切正常入口处刺进一条bti cbti j指令,称为”landing pad”,着陆点。当brblr试图跳转到这个ELF文件时,指令内部便会做检测:假如跳转到的地址刚好为”landing pad”,那么检测经过;反之则抛出反常,结束进程。

以上就是我对两个机制的根本了解,下面会评论一些更加细节和实践的内容。

首要评论兼容性。这儿的兼容性分为几个维度:

  1. 编译器刺进的pacautbti指令在低版别(比如Armv8.1-A)的ARM硬件上会不会有问题?
  2. 一个进程中,有些库敞开了PAC、BTI,有些库没有敞开,跳转交互时会不会有问题?
  3. 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)来封闭针对IAkey的校验和检测。因而咱们在ELF文件里依然能够看到autiasp指令,可是它们对封闭的线程而言相当于NOP。BTI从编译器层面封闭,经过修正-mbranch-protection让编译器不再往ELF文件中刺进bti指令。

不过没多久,等到Google和App厂家广泛交流后,这两个机制又都默许翻开了。

除了这些三方App的兼容性问题外,这儿再弥补一些其他导致PAC、BTI过错的问题。

[PAC]

  • Stack-buffer-overflow或栈上其他内存问题,将回来地址踩踏,因而检测无法经过。
  • IA在函数履行进程中被修正,导致入栈和出栈时用到的key值不同,因而检测无法经过。
  • IA检测在履行进程中被封闭,导致出栈时高位PAC值无法被清除,后续运用时会发生SIGSEGV的过错。

[BTI]

  • 一些库中有手写的汇编代码,比如libart.solibc.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版别将会包含这个修正。

[参考文献]

  1. Learn the architecture – Providing protection for complex software
  2. Arm A-profile A64 Instruction Set Architecture
  3. Developments in the Arm A-Profile Architecture: Armv8.6-A