揭秘Apple Silicon中的新硬件特性:SPRR与GXF保护机制(上)
2021-05-28 10:28:54 Author: www.4hou.com(查看原文) 阅读量:120 收藏

最近,苹果公司推出了自家研发的芯片M1 SoC,其中含有许多有趣且未公开的硬件新特性,例如SPRR机制可用于重新定义页表权限位的含义,而GXF机制则引入了横向执行级别。在这篇文章中,我们将为读者详细介绍这些新特性,以及苹果公司是如何利用它们来保护macOS系统的。

简介

一年多以前,siguza发表了一篇关于Apple的APRR机制的文章。实际上,APRR是一个定制的ARM扩展,用于重新定义页表权限,并保护内核的某些部分不受其影响。此后,苹果公司发布了M1芯片,该芯片不仅具有更新的APRR版本,而且在启动后不久就可以轻松地运行裸机代码。虽然关于新版本的传闻颇多,但苹果公司尚未公开其使用文档。

现在,是时候改变这种状况了!

在本文的第一部分中,我们将对aarch64架构的内存管理、页表和用户/内核模式进行简单介绍。同时,我们还将简要回顾APRR,与之前的Apple SoC功能相当。如果您已经熟悉这部分内容的话,读起来可能会比较无聊,所以不妨跳过这部分内容。 

之后,我们将进入本文的主题,即通过逆向分析,了解SPRR和GXF机制的工作原理及其作用。如果您想知道我是如何应对这一挑战的,那么这一部分会让您很感兴趣。如果您只想了解SPRR和GXF到底是什么东东的话,请直接访问Asahi Linux的维基页面。

MMU、页表与内核

对于ARM架构来说,CPU是在所谓的异常级别中运行的;在x86架构种,这些运行级别被称为环。其中,EL0是应用程序的运行级别,EL1(通常)是内核本身的运行级别,EL2是管理程序的运行级别。此外,EL3用于固件或Trust Zone的运行级别,但M1并不支持这个级别。

在具有虚拟化主机扩展特性的ARM64 CPU上,还提供了一种可以使EL2看起来像EL1的方法,这样一来,内核也可以很容易地在EL2级别运行。 

内核的任务之一,就是给运行在用户空间的每个应用程序制造一种假象,让它们觉得整个地址空间都是被它自己独占的。实际上,内核是通过内存管理单元来做到这一点的。利用MMU,可以创建从虚拟地址到实际物理地址(比如内存的物理地址)的别名。这种映射的最小粒度被称为页,页的长度通常为4KiB。并且,每个内存页都有一个虚拟地址和一个物理地址。当一个应用程序或内核本身的指令试图访问位置x处的内存时,MMU会在其页表中查找该页,并返回位于另一个地址y处的内存。这就是内核为每个用户空间中的应用程序“变出”一套独立的内存空间的把戏:只需为各个进程创建一组不同的页表即可。

除了进行上述映射之外,MMU还为每个内存页预留了四个比特位,用来编码某些访问标志。这些标志决定了是否允许用户态应用程序或内核本身对该内存页进行读写访问,或者从该内存页执行。在ARMv8-A CPU的每个页表项中可以找到以下四个位。 

·    UXN(Unprivileged Execute never,UXN):绝不允许用户态(即EL0运行级别)的代码从这一页执行。

·    PXN(Privileged execute never,PXN):绝不允许内核(即EL1运行级别)的代码从这一页执行

·    AP0和AP1:它们只是一种略显混乱的方式,用于编码内核和用户态的四种读写权限:rw/--、rw/rw、r-/-和r-/r-。 

在确定最终的访问标志时,还涉及其他一些复杂的机制(PAN,分层控制),本文就不多讲了。这里需要注意的是,用户态和内核态的权限是紧密耦合的,也就是说,我们不可能创建这样一个内存页:用户态代码对该内存页的访问权限为rw-,同时,内核态代码对该内存页的访问权限为r-。

APRR

如前所述,每个内存页都有四个标志位,用来控制EL0/1(用户模式/内核模式)的访问权限(读/写/执行)。但是,APRR完全改变了这种做法:不是将这四个标志位存储到页表项中,而是将其用作一个单独的表的索引(即,不是直接对访问权限进行编码,而是将这些位合并为一个4位的索引,即[AP1][AP0][PXN][UXN])。然后,通过这个单独的表对内存页的实际权限进行编码。此外,还可以通过某些寄存器进一步限制用户空间的这些权限。这些寄存器对于内核和用户空间也是独立的,因此,使得创建内存页的权限时具有很大的灵活性。 

APRR通过这种方式为页表权限引入了一个间接层,以便于通过单个寄存器写操作来非常高效地一次翻转多个页面的访问权限。通常情况下,这需要一个开销非常巨大的页面遍历过程来逐个修改页表项。

此外,这方面的更多的细节请参阅siguza的相关文章。

JIT编译器

通常,应用程序需要先从高级语言编译为机器代码,然后再进行分发。这种情况下,由于该代码是固定的,也就是说,代码在运行时通常不会再进行修改了,因此可以将其权限映射为r-x。

与上面不同的是,JIT编译器是动态生成机器代码的。在传统上,这需要将内存区域映射为rwx权限,这样才能把生成的机器代码写入内存并执行。

不过,苹果公司打心底里不喜欢这种映射,因为在他们的心目中,最好对iPhone的CPU上执行的每一条指令都进行签名。如果任何应用程序都可以请求rwx映射,那么这个设想就变得毫无意义,因为应用程序可以直接运行它想要的任何指令。即使只有一些应用程序有权进行这种映射,这些应用程序也会成为被攻击的目标。一旦某个内存区域存在rwx映射,攻击者所需要做的事情就是在那里编写shellcode并跳转执行这些代码。当然,找到这样的内存区域并获得任意写入和跳转gadget仍然是一个挑战。 

但是,苹果公司仍然希望使用JIT编译器。或者说,好吧,他们真的是别无选择:之所以需要JIT编译器,是因为Javascript已经无处不在。 

那么,如何解决这个难题呢?当然是借助于APRR。某些用户态应用程序(比如,iOS上的Safari,macOS上的每个应用程序)能够请求一个特殊的内存区域(使用mmap、MAP_JIT和pthread_jit_write_protect_np——根据saagarjha的说法,Safari实际上使用了os_thread_self_restrict_rwx_to_r{w,x},这可能具有同样的效果),该内存区域可以在rw-和r-x之间快速切换。实际上,这种切换是通过翻转APRR寄存器中的两个位来实现的,从而“盖住”JIT内存页的w权限并“露出”x权限,从而立即将所有这些内存页的权限从rw-变为r-x,反之亦然。

内存页保护层

如前所述,如果可能的话,苹果希望在所有可执行内存页上强制执行代码签名。在iOS上,这些签名必须来自苹果本身,而在macOS上,使用本地创建的临时签名就足够了。通常情况下,代码签名是由内核强制执行的。但是,内核也有很多不相关的代码,比如设备驱动程序,这就形成了一个巨大的攻击面:任何驱动程序中的任何错误都足以绕过代码签名(这种说法并不完全正确,因为攻击者还需要借助于信息泄露漏洞,才能以ROP的方式对页表进行写操作)。不过,这个问题在很久以前就已经被视频游戏机解决了:微软的Xbox 360管理程序只是一小段代码,基本上只执行代码签名和同样重要的任务。与其确保所有内核代码中没有可利用的安全漏洞,不如确保管理程序本身没有严重的安全漏洞就够了。但是,只要在管理程序中发现一个严重的安全漏洞,也可能导致整个防线崩溃。 

同样地,苹果公司使用APRR在内核本身内部实现了一个对性能影响很低的管理程序。首先,将页表(和其他存有重要数据结构的内存)重新映射为只有内核本身才能进行只读操作。此外,一小部分特权代码,称为PPL,也被映射为只读权限。然后,使用一个小型的蹦床函数,通过APRR将页表重新映射为rw-,将PPL代码重新映射为r-x,然后跳到那里。由于这个小蹦床函数是PPL代码的唯一入口,所以,它类似于一个hypercall指令,而PPL本身就像一个开销非常低的管理程序。更多的细节,请参阅Jonathan的相关文章。

SPRR

用户态JIT

如前所述,Apple Silicon上的JIT可以分配一个特殊内存区域,其权限可以在rw-和r-x之间快速切换。在以前的SoC中,这是用APRR来实现的,这为研究SPRR提供了一个好的起点。

根据苹果公司关于JIT编译器的官方文档中的介绍,_pthread_jit_write_protect_np函数在M1上仍然执行上述权限切换。实际上,我们可以先用otool -xv /usr/lib/system/libsystem_pthread.dylib来弄清楚幕后发生了什么。下面展示的是来自这个函数的相关指令:

_pthread_jit_write_protect_np:
[...]
0000000000007fdc        movk    x0, #0xc118
0000000000007fe0        movk    x0, #0xffff, lsl #16
0000000000007fe4        movk    x0, #0xf, lsl #32
0000000000007fe8        movk    x0, #0x0, lsl #48
0000000000007fec        ldr     x0, [x0]                ; Latency: 4
0000000000007ff0        msr     S3_6_C15_C1_5, x0
0000000000007ff4        isb
[...]

首先,上面的代码从常量地址0xfffffc118处加载一个64位整数,然后,把它写到系统寄存器S3_6_C15_C1_5中。其后的代码功能与之类似,区别在于这次是从0xfffc110处加载新的系统寄存器值。这些地址属于一个被称为commpage的内存区域。实际上,这个页面将被映射到每个用户态进程中,其中包含了由内核暴露给用户空间的各种变量。

不出所料,在commpage中设置这些变量的代码并没有公开在开源的XNU代码中。然而,在XNU代码中,有对前一代APR代码所使用的cp_aprr_shadow_jit_rw的引用。 

下面,我们用一个小巧的c程序来转储这些代码:

#include
#include
 
int main(int argc, char *argv[])
{
  uint64_t *sprr = (uint64_t *)0xfffffc110;
  printf("%llx %llx\n", sprr[0], sprr[1]);
}

这里生成了两个值,即0x2010000030300000和0x2010000030100000,它们用于实现JIT页面的r-x和rw-权限之间切换。到目前为止,看起来一切都还不错。这与APRR过去的工作方式非常类似,但这里使用了不同的寄存器,其中包含不同的幻数,所以,我们必须搞清楚其中的奥妙。

有了这个关于SPRR的粗略概念,我们现在就可以反汇编内核的代码,以寻找使用这些或附近寄存器的函数。不过,我已经不像以前那样喜欢折腾反汇编了。(但话说回来,我确实喜欢低级硬件逆向工程,所以当涉及到乐趣时,我的话您也不必全信)。不过,内核也不一定能够帮助我们了解各个位的确切含义:寄存器可能只是用一个神奇的常数初始化了一次,然后就再也没有碰过了。

幸运的是,我们还有另一种选择:暴力测试!不断尝试翻转我们找到的寄存器中的位,看看有何反应。我们甚至可以从一个在M1上运行的普通用户空间程序开始下手。

我们可以做的第一件事是尝试将每一个位设置为0和1。 

#include
#include
#include
#include
 
 
void write_sppr(uint64_t v)
{
    __asm__ __volatile__("msr S3_6_c15_c1_5, %0\n"
                         "isb sy\n" ::"r"(v)
                         :);
}
 
uint64_t read_sppr(void)
{
    uint64_t v;
    __asm__ __volatile__("isb sy\n"
                         "mrs %0, S3_6_c15_c1_5\n"
                         : "=r"(v)::"memory");
    return v;
}
 
 
int main(int argc, char *argv[])
{
    for (int i = 0; i < 64; ++i) {
        write_sppr(1ULL<<i);
        printf("bit %02d: %016llx\n", i, read_sppr());
    }
}

我们很快就发现,除了我们在commpage中发现的两个位之外,几乎所有的位都被锁定为初始值。我们还发现,这些现象在某种程度上与JIT页面的权限有关。我们可以用mmap来映射这样的页面。由于从一个受读或写保护的页面中读出或写入会产生一个SIGBUS信号;跳转到一个不可执行的页面会产生一个SIGSEV信号,因此,我们可以通过设置信号处理程序来捕捉用户态应用程序中的这些信号。我们只需要这些工具就可以了解这些位如何映射到页面权限!

为了从访问受保护页面中恢复过来,我们设置了下面的信号处理程序,它将把x0设置为一个魔术常量,然后在返回之前递增程序计数器:

void bus_handler(int signo, siginfo_t *info, void *cx_)
{
    ucontext_t *cx = cx_;
    cx->uc_mcontext->__ss.__x[0] = 0xdeadbeef;
    cx->uc_mcontext->__ss.__pc += 4;
}
 
从执行不可执行的内存页中进行恢复的过程与之类似:将程序计数器设置为链接寄存器,并在x0中存储一个魔术值:
 
void sev_handler(int signo, siginfo_t *info, void *cx_)
{
    ucontext_t *cx = cx_;
    cx->uc_mcontext->__ss.__x[0] = 0xdeadbeef;
    cx->uc_mcontext->__ss.__pc = cx->uc_mcontext->__ss.__lr;
}

剩下的事情,就是用MAP_JIT映射一个内存页面,并尝试对该内存进行读、写或执行操作,看看它们对应于系统寄存器哪些值(共有四个可能的取值)。

SPRR JIT测试代码

下面,我们给出寄存器的值与页面权限之间的对应关系:

1.png

 这比APRR的工作方式简单多了:与APRR使用两个寄存器来设置权限并屏蔽其他的权限不同,这里只能将其改为上面的四个值中的一个。此外,对于经验老道的黑客来说,可以利用APRR在用户空间创建rwx映射;不过,该方法在这里就行不通了:因为没有办法对其进行编码。据推测,系统寄存器中的不同字节对应于页表项中编码的16种不同的可能权限。这使得一半的系统寄存器的含义完全未知!

我们现在可能已经从macOS用户空间中找到了所有我们能找到的东西,现在是时候拿出一些更强大的工具来真正理解这个新的硬件功能是如何工作的了。

本来,我还以为可以直接利用苹果的Hypervisor.framework在EL1级别中运行自己的代码,并研究SPRR在那里的表现。但是,不幸的是,每一次对可能与SPR有关的寄存器的访问总是出现问题。哦,好吧。幸运的是,我们有更强大的工具可以在“裸机”上运行EL2级别的代码。

m1n1

在此之前,iPhone黑客必须对XNU进行静态逆向分析,或者通过其他途径获得EL1级别的权限,然后通过实验来了解新硬件。这使得他们的所有成就更加令人印象深刻。然而,如今就不用这么麻烦了:苹果公司已经发布了M1,它不仅提供了新的硬件特性,还允许我们在引导过程的早期运行未签名的代码。

作为旨在为M1引入上游Linux支持的Asahi Linux项目的一部分,marcan曾领导开发了一个小型引导加载程序/硬件实验平台,名为m1n1。m1n1与XNU一样,都能在所有硬件保持原始状态的情况下获得控制权。虽然以下所有的工作也可以通过手动编写shellcode在EL2中运行来完成,但如果使用m1n1的话,则可以让这些工作变得很有趣(前提是您认同我对有趣的定义)。

小结

最近,苹果公司推出了自研芯片M1 SoC,其中含有许多有趣且未公开的硬件新特性,例如SPRR机制可用于重新定义页表权限位的含义,而GXF机制则引入了横向执行级别。在这篇文章中,我们将为读者详细介绍这些新特性,以及苹果公司是如何利用它们来保护macOS系统的。由于篇幅较长,我们将分为上下文进行发表,更多精彩内容,敬请期待! 

本文翻译自:https://blog.svenpeter.dev/posts/m1_sprr_gxf/如若转载,请注明原文地址


文章来源: https://www.4hou.com/posts/D6Oq
如有侵权请联系:admin#unsafe.sh