前言
本文总结了在Android上利用漏洞时遇到的一些新的保护机制以及在真机上的内核漏洞利用和调试技巧。虽然Android底层为Linux内核,但是相比较下Android内核更加难利用,主要体现在真机不能实时调试,可能开启了BTI保护、PAC保护和CFI保护,同时在近年新出的一些手机如Pixel 10开启了内存标记访问保护Memory Tagging Extension(MTE),本文还将介绍MTE保护在用户态时的一个特殊的绕过方法。
真机内核利用适配
对于一个真机内核,在编写漏洞利用程序期间可以编译一个版本一样的Linux内核用qemu模拟运行,便于掌握数据的处理过程。还可以使用Android模拟器,目前高版本的Android模拟器无法在x86/x64架构下模拟AARCH64的镜像,可以在AARCH64架构下的主机,如树莓派等下面运行模拟器。在模拟的内核中利用成功后,就是如何将其移植到真机上的问题。虽然真机不能实时调试,但是可以通过查看/sys/fs/pstore/目录下的日志文件以及dmesg来获取内核最后崩溃时的寄存器值。根据寄存器信息来定位漏洞利用程序中需要适配的位置。
1 | root@apalis-imx8:~$ cat /sys/fs/pstore/dmesg-ramoops-0 |
SELinux
SELinux是一个强制访问控制安全机制,它提供了一种灵活的、细粒度的访问控制策略,用于提高Linux系统的安全性。Android上默认开启了SeLinux,因此某些漏洞利用方法在编译的Linux内核中能够使用但是在Android上测试却失效了。
SELinux原理
SELinux实际上是对系统中所有的关键函数注册了HOOK
这些HOOK函数会在函数中被调用,它们一般以security_开头。
如果SELinux没有开启,这些security_函数默认返回0让程序继续程序,如果开启了则跳转到HOOK函数执行。
这些HOOK函数根据SELinux配置的规则对参数进行审计,以此来让一个函数执行或者拒绝。
SELinux绕过
当开启SELinux时,改写modprobe_path或者core_pattern后不能触发提权脚本的执行,这是因为我们指向的脚本不在SELinux规则中规定的可执行路径。为了绕过SELinux的检查,我们查看审计函数的代码,avc_has_perm函数的子调用链为avc_has_perm->avc_has_perm_noaudit
如果avc_has_perm_noaudit函数审计出当前的操作是被禁止的,那么调用avc_denied函数。
从avc_denied函数来看,如果selinux_enforcing全局变量为0,则仍然可以使得avc_denied返回0,进而让selinux_函数放行,因此可以利用漏洞改写selinux_enforcing这个全局变量来绕过SELinux。
在高版本Linux中,判断方式采用了函数,实际上判断的是state->enforce
而state指针指向的仍然是一个全局变量结构体。
因此可以修改selinux_state.enforce变量。
CFI保护
CFI保护是Android内核中引入的,目的是保护函数指针,如果函数指针被篡改为任意地址,会被检测出来然后终止执行。开启了CFI保护的内核如下所示,会有很多以.cfi结尾的函数
还存在着不带.cfi结尾的同名函数
不带.cfi的函数中只会有一条B跳转指令,不会再有其他任何人指令。实际上这些函数是一张类似于PLT跳转表的东西,我们可以把它命名为CFI表。
函数指针检查
CFI的检测实际上就是对每一个函数指针调用的位置进行了插桩,判断函数指针是否在CFI表中,如下是CFI的桩代码
如果函数指针发生了篡改,则将进入_cfi_slowpath函数,_cfi_slowpath函数调用_cfi_check进行检查
_cfi_check根据_cfi_slowpath函数的第一个参数传入的MAGIC值,会再一次的判断函数指针是否能够通过检查。
如果函数指针与预期值不等,则调用__cfi_check_fail函数让内核崩溃。
函数指针多值的处理
某些函数指针可能有多个指向的目标,因此不能对函数指针进行固定值比较,CFI采用了运算的方式将指针值限定在一个范围内
即只能在CFI表中的single_step_handler附近
显然,在编译时,生成的这张CFI表中函数的排列顺序是精心计算安排的,把一个函数指针所有可能的指向地址排列成相邻的。
CFI绕过的可能思路
对于ARM架构,目前无法绕过CFI,因为ARM架构的指令是对齐且定长的,不能在CFI表中跳转到错位的地址进而构造出ROP gadget。如果是在x86架构下,对于函数指针多值的CFI检查,由于指针值限定在CFI表的一个范围区间,可以在区间内寻找是否有合适的gadget能够控制执行流。
CFI例题
在GeekCon的ksocket pixel3题目中,实现了一个自定义的socket,我们可以通过UAF控制这个socket对象的结构。由于开启了CFI,我们不能去控制函数指针
我们观察到,在close时触发的avss_release函数中有以下的链表unlink操作
我们可以把unlink用来做任意地址写,由于两个数据都必须为合法的内存指针,因此不能直接写数据。但是可以用错位的思路,CPU为小端,因此指针的最低一个字节存放在最前面,我们每次只需要保证指针的最低一个字节被写入到目标地址即可。令*(v3 + 112) = addr,
*(v3 + 104) = bss | byte,则可以在addr处写上一个字节byte。其中bss为bss的地址,用于保证两个数据都为合法的内存指针不会崩溃。
在实现了任意地址写以后,改写selinux_enforcing为0关闭selinux,改写modprobe_path为提权脚本。然后触发modprobe_path的执行。
BTI保护
在 AArch64(ARMv8-A 架构的 64 位模式)中,BTI 指令用于验证间接跳转的目标是否有效。它的主要作用是确保程序控制流只能跳转到预期的代码位置(即合法的分支目标)。即BLR/BR Rn寄存器跳转指令跳转的目标位置的第一条指令必须为BTI否则函数无法继续向下执行。
PAC保护
PAC原理
PAC(Pointer Authentication) 技术,用于验证和保护返回地址及其他指针数据的完整性。ARMv8.3-A 新引入了三类指令:
- PAC* 类指令可以向指针中生成和插入 PAC。比如,PACIA X8,X9 可以在寄存器X8中以 X9 为上下文,APIAKey为密钥,为指针计算PAC,并且将结果写回到 X8 中。
- AUT* 类指令可以验证一个指针的 PAC。如果PAC是合法的,将会还原原始的指针。否则,将会在指针的扩展位中将会被写入错误码,在指针被简接引用时,会触发错误。比如,AUTIA X8,X9 可以以 X9 为上下文,验证 X8 寄存器中的指针。当验证成功时会将指针写回 X8,失败时则写回一个错误码。
- XPAC* 类指令可以移除一个指针的 PAC 并且在不验证指针有效性的前提下恢复指针的原始值。
PAC的加密生成算法不同的硬件有不同的实现。
在Android中,开启了PAC保护的函数如图所示,PACIASP指令会基于当前的栈指针(SP)、私有密钥(APIAKey)以及返回地址生成认证码,认证码被嵌入到给定的函数返回地址中,在函数返回时,使用对应的 AUTIASP 指令对返回地址进行验证。如果地址合法且未被篡改,验证成功;否则,程序会触发异常(SIGILL 或其他非法指令异常)。
PAC绕过
PAC绕过是困难的,PAC的密钥通过特定的系统寄存器存储和操作。内核态使用的密钥是APIXKey_EL1,用户态使用的密钥是APIXKey_EL0,因此在用户态计算出的PAC值不能给内核态使用。
内核态下可以操作访问APIXKey_EL1、APIXKey_EL0等寄存器修改或者读取密钥
因此有一种可能的情形就是在内核态中某个gadget可以将用户态的APIXKey_EL0修改成与内核态一样的数值,那么就可以在用户态执行PAC指令计算PAC值然后填入ROP链。
MTE保护
MTE原理
MTE (Memory Tagging Extension)是ARMv8.5-A 架构引入的一项硬件支持的内存安全技术,旨在检测和防止内存相关的错误和漏洞,例如越界访问和使用已释放内存(Use-After-Free, UAF)。
MTE 的基本原理
1 | IRG <Xd>, <Xn>, <Xm> |
- IRG (Insert Random Tag) 指令为指针Xn生成一个随机tag,使用Xm作为种子,将结果保存至Xd中。
- STG (Store Allocation Tag) 指令将tag应用至内存中,生效的长度取决于颗粒度,一般为16字节。
- LDR (Load Register) 使用带有tag的指针读取内存。
如图,IRG指令执行后,X0比X8在高位多了一个TAG值。
STG指令执行后,以后访问这段内存需要带上正确的TAG值的指针才能访问,否则指令会执行错误。
MTE应用
在堆分配器中,malloc后,通过对申请的堆地址打上标签返回,free后对堆地址重新打标签。这样就能阻止UAF这类的漏洞,因为free后指针重新打了标签,导致UAF残留的指针无效,通过UAF的指针访问内存时就会崩溃。不同的堆分配器在malloc和free时有着不同的处理内存标签的方式。
有关内存分配器处理MTE标签的分析可以参考文章GeekCon的文章填补盾牌的裂缝:堆分配器中的MTE。
MTE爆破
如果给系统调用直接传一个带有错误TAG的指针,会发生什么?如图,假设buf指向的内存已经被free导致重新打标签,现在传给Sys_write的是一个无效的指针
单步进入会触发内核的Error EL1h
错误会被el0t_64_sync函数捕捉处理
异常处理会调用el0_svc函数,并不会退出程序
异常处理完成后,调用ret_to_user返回到了用户态
可见,当一个不正确的MTE指针进入系统调用,系统调用执行不成功,同时进程不会崩溃;我们可以利用这种特性来对TAG值进行爆破。一般的,我们在用户态利用UAF漏洞时,在已知指针值但是不知道TAG,我们可以用这样的方法爆破
1 | #我们想泄漏leak_ptr_addr地址处的数据,但是这段内存的TAG不知道是多少? |
上述代码来源于我在GeekCon Shanghai 2024上解出的MTE题的EXP。
AARCH64 JOP
在AARCH64中,RET指令不会从栈里弹出返回地址进行返回,RET指令直接跳转到X30寄存器指向的地址;而BLR指令在跳入新函数时,会将返回地址赋值给X30寄存器。由于这个特性,我们在搜索一些gadgets指令时,无需考虑BLR后面的代码,
在做GeekCon的kSysRace赛题时,我们控制了一个地方的函数指针,能够调用任意一个函数,以及X0执行的内容可控
1 | .text:FFFFFF80080DB354 LDR X8, [X19,#0x98] |
让其先跳入下面的代码
1 | .kernel:FFFFFF8008707744 LDRB W8, [X0,#0x311] |
在这段代码中,我们的目的是控制X19指向X0,因为X0是我们可控的,我们不用担心BLR X8返回执行后面,因为我们可以再调用一次BLR来将X30覆盖。我们控制X8,让其先跳入下面的代码
1 | .kernel:FFFFFF80080DB3F8 LDR X8, [X19,#0xC8] |
在这段代码中,由于X19可控,我们可以调用3个参数的任意函数了,自始至终,我们的栈没有发生过调整,由于漏洞发生的位置栈尾部是这样的
1 | ............. |
栈尾部跟我们的gadgets一摸一样,这意味着我们的gadgets在执行到RET时可以直接返回到漏洞发生的函数的上层,栈平衡了。也就是我们能够执行任意的一个函数,控制3个参数,同时栈能够恢复,可以让程序继续保持正常的运行状态。这样我们就可以进行多次的任意函数调用。
总结
本文我们介绍了众多在Android AARCH64上所使用的保护机制以及特性,劫持程序流程变得越来越困难,在没有开启程序流保护的情况下,使用JOP去实现任意代码执行;当程序流保护机制开启时,可以转变思路,通过劫持一些数据结构体,利用程序中自带的link、unlink等操作去实现一个地址写或者读,本文还介绍了MTE保护机制的一种特殊情况下的爆破。