最近,苹果公司推出了自家研发的芯片M1 SoC,其中含有许多有趣且未公开的硬件新特性,例如SPRR机制可用于重新定义页表权限位的含义,而GXF机制则引入了横向执行级别。在这篇文章中,我们将为读者详细介绍这些新特性,以及苹果公司是如何利用它们来保护macOS系统的。
(接上文)
通过Python探索未知的系统寄存器
实际上,m1n1最大的特点就是:允许直接通过python shell来操作硬件,而不是重新编译和重新加载shellcode,以及通过python来处理数据提取和所有这些恼人的琐事。marcan最近还合并了我的USB gadget代码,因此,大家要想复现这些实验的话,只要一台M1 Mac和一根普通的USB线就能搞定了。
让我们从运行proxyclient/shell.py开始。不幸的是,访问用户态的SPRR寄存器只是触发了一个异常。(但我注意到,m1n1很快从这个异常中恢复过来了,根本不需要重启!)
>>> u.mrs((3, 6, 15, 1, 5)) TTY> Exception: SYNC TTY> Exception taken from EL2h TTY> Running in EL2 TTY> MPIDR: 0x80000000 TTY> Registers: (@0x8046b3db0) TTY> x0-x3: 0000000000000000 0000000000000000 0000000000000000 0000000000000000 TTY> x4-x7: 0000000810cb8000 0000000000007a69 0000000804630004 0000000804630000 TTY> x8-x11: 0000000000000000 00000000ffffffc8 00000008046b3eb0 000000000000002c TTY> x12-x15: 0000000000000003 0000000000000001 0000000000000000 00000008046b3b20 TTY> x16-x19: 00000008045caa80 0000000000000000 0000000000000000 000000080462b000 TTY> x20-x23: 00000008046b3f78 00000008046b3fa0 0000000000000002 00000008046b3f98 TTY> x24-x27: 00000008046b3f70 0000000000000000 0000000000000001 0000000000000001 TTY> x28-x30: 00000008046b3fa0 00000008046b3eb0 00000008045bad90 TTY> PC: 0x810cb8000 (rel: 0xc70c000) TTY> SP: 0x8046b3eb0 TTY> SPSR_EL1: 0x60000009 TTY> FAR_EL1: 0x0 TTY> ESR_EL1: 0x2000000 (unknown) TTY> L2C_ERR_STS: 0x11000ffc00000000 TTY> L2C_ERR_ADR: 0x0 TTY> L2C_ERR_INF: 0x0 TTY> SYS_APL_E_LSU_ERR_STS: 0x0 TTY> SYS_APL_E_FED_ERR_STS: 0x0 TTY> SYS_APL_E_MMU_ERR_STS: 0x0 TTY> Recovering from exception (ELR=0x810cb8004) Traceback (most recent call last): File "/opt/homebrew/Cellar/[email protected]/3.9.4/Frameworks/Python.framework/Versions/3.9/lib/python3.9/code.py", line 90, in runcode exec(code, self.locals) File " File "/Users/speter/asahi/git/m1n1/proxyclient/utils.py", line 80, in mrs raise ProxyError("Exception occurred") proxy.ProxyError: Exception occurred >>>
但是,内核必须能够在上下文切换期间修改这个寄存器。这就意味着可能存在一些使能位。幸运的是,m1n1存储库中已经提供了一个现成的python工具,可用于查找所有可用的系统寄存器。该工具的内部运作方式为:为所有寄存器生成相应的mrs指令,并从未定义寄存器引起的异常中恢复。下面,让我们运行它并寻找附近的寄存器:
$ python3 proxyclient/find_all_regs.py | grep s3_6_c15_c1_ s3_6_c15_c1_0 (3, 6, 15, 1, 0) = 0x0 s3_6_c15_c1_2 (3, 6, 15, 1, 2) = 0x0 s3_6_c15_c1_4 (3, 6, 15, 1, 4) = 0x0
这里给出了三个候选寄存器。如果将0x1写入第一个候选寄存器,似乎会导致m1n1无法正常工作——很明显,这说明m1n1是从具有rwx权限的页面运行的。SPRR寄存器最初的值为0x0,这意味着没有任何访问权限。如果SPRR突然启动,使CPU认为rwx实际上是---,会发生什么?一切都完了,因为没有可以读取或执行的内存了。
现在,让我们禁用MMU,并再次将0x1这些寄存器(并很快注意到第三个寄存器似乎只是引发了故障并忽略了该故障),然后,查找所有寄存器,最后确定新的寄存器。这一切都可以通过几行python代码来完成:
with u.mmu_disabled(): for reg in [(3, 6, 15, 1, 0), (3, 6, 15, 1, 2)]: old_regs = find_regs() u.msr(reg, 1) new_regs = find_regs() diff_regs = new_regs - old_regs print(reg) for r in sorted(diff_regs): print(" %s" % list(r)) u.msr((3, 6, 15, 1, 2), 0) u.msr((3, 6, 15, 1, 0), 0)
哦,天哪,有很多新的寄存器被启用了!
启用的系统寄存器
接下来,让我们把S3_6_C15_C1_0改名为SPRR_CONFIG_EL1。这里的第1位启用了SPRR,设置所有的位似乎可以锁定所有的SPRR寄存器,以作进一步的修改。S3_6_C15_1_2和它所启用的寄存器对于第二部分内容来说是非常重要的。
而我们现在确实可以翻转S3_6_C15_C1_5的所有位了:
>>> p.mmu_shutdown() TTY> MMU: shutting down... TTY> MMU: shutdown successful, clearing cache >>> u.msr((3, 6, 15, 1, 0), 1) >>> u.mrs((3, 6, 15, 1, 5)) 0x0 >>> u.msr((3, 6, 15, 1, 5), 0xffffffffffffffff) >>> u.mrs((3, 6, 15, 1, 5)) 0xffffffffffffffff >>>
虽然这个寄存器可能适用于EL0,但我们在这里的运行级别为EL2。我们可以做一个有根据的猜测,假设新启用的寄存器S3_6_C15_C1_6可能是用于EL1级别的,S3_6_C15_C1_7用于EL2。M1总是与HCR_EL2.E2H一起运行,它(除其他外)将对EL1寄存器的访问重定向到其EL2对应的寄存器。我们可以用下面的实验来验证我们的猜测:
>>> u.msr((3, 6, 15, 1, 6), 0xdead0000) >>> u.mrs((3, 6, 15, 1, 7)) 0xdead0000 >>>
到目前为止,看起来还很顺利:现在,我们可以启用SPRR机制了,而且发现了一个可疑的寄存器——它很可能与EL2权限有关。下面,我们将重复在用户态下面的实验了,以了解这些寄存器中上面介绍的四位以外的内容了。
逆向分析SPRR寄存器
我们可以编写一些python代码,来建立一个简单的页表,然后重复我们在用户态下面所做的那些实验。首先,映射一个页面,使其具备S3_6_C15_C1_6对应的权限,然后,尝试对其中的内存进行读/写/执行操作。
为了通过python代码完成上述操作,必须对m1n1本身进行一些侵入性的修改,使其从r-x内存页运行,并将其堆栈分配到具有rw-权限的内存页中。我们要力争通过Python完成尽可能多的设置工作,然后,编写一些shellcode并在其他CPU核上运行,这能够让事情容易得多:即使其中一个挂掉了,在重启之前,至少还有其他的可用。
pagetable = ARMPageTable(heap.memalign, heap.free) pagetable.map(0x800000000, 0x800000000, 0xc00000000, 0) # normal memory, we run from here pagetable.map(0xf800000000, 0x800000000, 0xc00000000, 1) # probe memory, we'll try to read/write/execute this # ... code_page = build_and_write_code(heap, """ // [...] // prepare and enable MMU ldr x0, =0x0400ff msr MAIR_EL1, x0 ldr x0, =0x27510b510 // borrowed from m1n1's MMU code msr TCR_EL1, x0 ldr x0, =0x{ttbr:x} msr TTBR0_EL1, x0 mrs x0, SCTLR_EL1 orr x1, x0, #5 msr SCTLR_EL1, x1 isb // [...] """.format(ttbr=pagetable.l0) # ... ret = p.smp_call_sync(1, code_page, sprr_val) # ...
这样一来,过去的信号处理程序现在变成了一个小型的异常向量。我们在这里所做的只是修改一个寄存器来指示故障,然后在返回之前让程序计数器再跳过两条指令即可。第一条指令是发生故障的那条,我们不想再运行它了。第二条指令是mov x10, 0x80,表示访问成功,如果我们遇到了异常,访问肯定就不成功了。
_fault_handler: # store that we failed mov x10, 0xf1 mrs x12, ELR_GL2 # get the PC that faulted add x12, x12, 8 # skip two instructions msr ELR_GL2, x12 # store the updated PC isb # eret restores the state from before the exception was taken eret _sprr_test: # ... # test read access, x1 contains an address to a page for which we modify the SPRR register values mov x10, 0 # x10 is our success/failure indicator ldr x1, [x1] # this instruction will fault if we can't read from [x1] mov x10, 0x80 # this instruction will be skipped if the previous one faulted
通过上面的工作,我们最终得到了所有16种可能配置的含义:
很明显,这里有些地方很奇怪。在大多数情况下,较低的两个比特指定了权限。但有两个例外情况,较高的位也会以某种方式改变权限。0111似乎不允许访问一个本应是rw-权限的页面,而1001通常应该是可读和可执行的,但这里只有可执行权限。
按理说,根本就没有必要再浪费两个比特来编码这个权限。乍一看,这可能是用于处理用户与内核的write-or-execute权限。但我们知道,EL0使用的是一个完全不同的寄存器。那么,这还能是什么作用呢?
受保护的异常级别/GXF
从上一节我们知道,在PPR寄存器中编码了一些奇怪的东西。此外,之前我们也曾提到了受保护的异常级别,它与常规的异常级别是不同的。显然,这些是由0x00201420和0x00201400这两条被称为genter和gexit的定制指令触发的。
让我们把XNU附加到反汇编程序中,看看能否通过otool -xv /System/Library/Kernels/kernel.release.t8101找到一些可疑的东西。在查找这些指令时,发现了如下所示的可疑指令,它也恰好在初始化早期被调用:
fffffe00071f80f0 mov x0, #0x1 fffffe00071f80f4 msr S3_6_C15_C1_2, x0 fffffe00071f80f8 adrp x0, 2025 ; 0xfffffe00079e1000 fffffe00071f80fc add x0, x0, #0x9d8 fffffe00071f8100 msr S3_6_C15_C8_2, x0 fffffe00071f8104 adrp x0, 2025 ; 0xfffffe00079e1000 fffffe00071f8108 add x0, x0, #0x9dc fffffe00071f810c msr S3_6_C15_C8_1, x0 fffffe00071f8110 isb fffffe00071f8114 mov x0, #0x0 fffffe00071f8118 msr ELR_EL1, x0 fffffe00071f811c isb fffffe00071f8120 .long 0x00201420 fffffe00071f8124 ret
还记得S3_6_C15_C1_2吗?(我当时印象深刻,因为对我来说,所有这些数字看起来都一样。)这是我们前面找到的第二个使能寄存器,也是这里使用的第一个寄存器。然后,将两个指针写入未知系统寄存器,最后执行未定义的指令0x00201420。第一个指针只是指向一个无限循环,但第二个指针指向一个函数,它似乎也使用了我们前面找到的SPRR寄存器。
因此,S3_6_C15_C8_1中可能是一个指针,一旦0x00201420被执行,处理器就会跳转到这个指针。第二条未知指令0x00201420似乎在那时恢复执行。这一切听起来与管理程序调用的工作方式非常相似。0x00201420对应于smc,以捕获到EL3权限,0x00201400是eret,将我们带回EL2级别。不同的是,对于这种新的执行模式,没有找到不同的页表。还记得SPRR寄存器中未知的两个位吗?如果这些对应于GL2的页面权限呢?
我们可以通过使用与之前相同的方法,再次用m1n1快速验证这一点。我们在保护性执行模式下设置异常向量,并重复同样的实验。
但是,我们如何在这种新模式下设置异常向量呢?通常有一个叫VBAR的寄存器来实现这个目的。让我们简单看看S3_6_C15_C10_2所指向的代码,它是XNU在genter之后首先设置的寄存器之一。
fffffe00079e0000 b 0xfffffe00079e15d0 fffffe00079e0004 nop fffffe00079e0008 nop fffffe00079e000c nop [...] fffffe00079e007c nop fffffe00079e0080 b 0xfffffe00079e1000 fffffe00079e0084 nop [...] fffffe00079e00fc nop fffffe00079e0100 b 0xfffffe00079e11f0 fffffe00079e0104 nop [...]
唷,这看起来像是一个异常向量表,这意味着S3_6_C15_C10_2是VBAR_GL1。
这样的话,我们就得到了一个完整的权限表,并破解了SPRR寄存器的所有位的秘密。
这个完整的权限表看起来正是我们要找的:SPRR寄存器的值为0100、0110或1111时,如果从EL2跳转到代码时,内核似乎就会崩溃。所有这些值都对应于这样一个内存页:该页显然不能从EL2执行,但能从GL2执行。如果这些故障由于某种原因转移到不同的地址,结果会怎样?不要再拐弯抹角了,这正是现在所发生的情况。这三个特殊的故障使用了XNU指向无限循环的系统寄存器,即
· 当EL2试图跳转到只能在GL2中执行的代码时,引发的异常终止将转到S3_6_C15_C8_2(我称之为GXF_ABORT_EL2);
· 任何其他来自EL2的异常终止都转到VBAR_EL2;
· 任何其他来自GL2的异常终止都转到VBAR_GL2。
这样的话,我们再次就得到了一个完整的权限表,从而破解了SPRR寄存器的所有位的秘密。
下面,让我们详细了解一下通过GL权限位修改EL权限位含义的两种特殊情况:
· 第一种情况(0111)确保无法创建在GL中可执行、在EL中可写入的内存页。这为防止软件漏洞提供了额外的硬件层保护。如果能够从EL中改变在GL中运行的代码的话,将使整个横向保护变得毫无意义。
· 第二种情况(1001)将r-x EL权限替换为--x权限,如果该内存页只能从GL中读取的话。我不知道为什么要强制执行这个保护。也许是为了能够对EL隐藏一些秘密代码,或者作为对我不熟悉的安全漏洞的一些额外缓解措施?我很想知道为什么这样的映射会有帮助,如果有人能够给出一个很好的解释的话。
利用Python探测GL2
掌握了这些知识,我们现在旧可以很轻松地在GL2和m1n1中添加对运行自定义payload的支持了。我们所要做的,就是利用已经存在的框架将运行级别降到EL1/EL0。为此,我们只需要禁用MMU(因为m1n1假设它是从具有rwx权限的内存页运行的,而我们在启用SPRR的情况下无法做到这一点),然后跳转到payload,最后,在返回之前再次启用MMU。
这使得我们可以很轻松地探测GL2,例如,看看S3_6_C15_C10_3是否就是SPSR_GL2:
>>> u.mrs((3, 6, 15, 10, 3), call=p.gl_call) 0x60000009 >>> u.mrs(SPSR_EL2) 0x60000009
或者,我们可以直接重新运行MSR查找器,但这次是在GL2中:
gxf_regs = find_regs(call=p.gl_call) print("GXF") for r in sorted(gxf_regs - all_regs): print(" %s" % list(r))
这样,我们就发现一大堆神秘的新系统寄存器,它们只有在该上下文中才能找到:
GXF [3, 6, 15, 0, 1] [3, 6, 15, 0, 2] [3, 6, 15, 1, 1] [3, 6, 15, 2, 6] [3, 6, 15, 8, 5] [3, 6, 15, 8, 7] [3, 6, 15, 10, 2] [3, 6, 15, 10, 3] [3, 6, 15, 10, 4] [3, 6, 15, 10, 5] [3, 6, 15, 10, 6] [3, 6, 15, 10, 7] [3, 6, 15, 11, 1] [3, 6, 15, 11, 2] [3, 6, 15, 11, 3] [3, 6, 15, 11, 4] [3, 6, 15, 11, 5] [3, 6, 15, 11, 6] [3, 6, 15, 11, 7]
也许以3、6、15、10开头的是GL1,以3、6、15、11开头的是GL2,或者反过来?这很容易搞清楚:只要在EL2中启用SPRR和GXF后,将运行级别下降到EL1,并重新进行同样的实验即可。这一次我们只得到如下所示的新寄存器:
[3, 6, 15, 0, 1] [3, 6, 15, 8, 7] [3, 6, 15, 10, 1] [3, 6, 15, 10, 2] [3, 6, 15, 10, 3] [3, 6, 15, 10, 4] [3, 6, 15, 10, 5] [3, 6, 15, 10, 6] [3, 6, 15, 10, 7]
这意味着3、6、15、10组确实代表EL1寄存器。这一点并不重要,因为M1总是以HCR_EL2.E2H运行,这意味着_EL1寄存器在EL2级别运行时被重定向到_EL2。同样的情况似乎也适用于GL1和GL2寄存器。
我们能不能也搞清楚它们的确切含义呢?幸运的是,一个早期的开源XNU版本含有如下所示的一些名称:
#define KERNEL_MODE_ELR ELR_GL11 #define KERNEL_MODE_FAR FAR_GL11 #define KERNEL_MODE_ESR ESR_GL11 #define KERNEL_MODE_SPSR SPSR_GL11 #define KERNEL_MODE_ASPSR ASPSR_GL11 #define KERNEL_MODE_VBAR VBAR_GL11 #define KERNEL_MODE_TPIDR TPIDR_GL11
我不清楚为什么这些寄存器带有GL11的后缀,但除此之外,它们可以很容易地与上面发现的未知寄存器匹配起来。ASPSR至少包含一个位,它决定了gexit是否应该返回到保护性执行或正常执行。
即使只是这两个扩展,也还有很多未知的寄存器和谜团。如果你想一起玩,就拿起最新的m1n1,看看到底能搞清楚什么:-)
XNU中的SPRR和GXF
最后,我们来考察一下XNU是如何使用这些新功能的。实际上,这里并没有什么可考察的,因为Jonathan已经在一篇文章中介绍了SPR和GXF的使用情况。SPRR只是取代了过去APRR的功能:禁止内核对页面执行写操作,以及禁止它执行PPL代码。
最大的不同在于GXF:不需要精心设计一个修改APRR寄存器的小蹦床函数,只需要设置GXF入口向量:页表权限就会自动翻转,并且,genter就可以直接指向PPL了。
让我们通过查看XNU如何初始化SPR来确认这一点:启动函数通过SPRR将EL1 SPR权限寄存器初始化为0x2020A505F020F0F0。这个代码序列与所有的CPU chicken位纠缠在一起。marcan甚至正确地猜到了写的是什么,并将它们从实际的chicken位序列中剥离出来。
稍后,初始的GL引导代码会将EL1的权限更新为0x2020A506F020F0E0,然后锁定所有内容,以防止进一步的修改。
然后,受保护的执行模式入口点被设置为一个来自正常内核的text区段的函数,并且该函数必须迅速跳转到PPLTEXT的开头位置。PPL入口函数首先会验证SPRR权限的设置是否正确,之后的行为,具体如Jonathan的文章所述。
最后,我们来看看XNU使用的各种SPRR页权限(这里没有显示的条目对所有级别都没有访问权限。在chicken位序列中设置的原始值也将GL权限设为EL级别):
这一切看起来都很合理。GL的权限可能会被进一步锁定,例如,不允许GL执行常规的内核代码(参见第10项),不允许它访问任何用户数据(参见第7项)。
除此以外,感觉这就像以前的APRR硬件的一个增强版。这些变化不仅使整个系统不容易出错(寄存器可以被锁定,内核->PPL的转换完全通过硬件实现,内核和PPL的异常向量现在被明确的分隔开来),而且更加灵活。APRR过去只能剥离权限,但SPRR现在允许任意重新映射权限——只要不需要rwx页的话。遗憾的是,在Linux中还没有将它用起来。
小结
Apple Silicon通过了两个“秘密”功能,两者可以通过密切协作,来提供额外的防御机制。GXF引入了横向异常级别,称为GL1和GL2,它们使用的页表与相应的EL使用的相同,但具有不同的页面权限。SPRR允许重新定义EL和GL的页表项中的权限位。苹果公司利用这一点在GL中隐藏所有的页表操作代码,并且禁止EL修改任何页表。这有效地引入了一个具有较小攻击面的低开销管理程序,即使在内核模式下运行的代码也可以很好地保护页表。对于本文来说,其中大部分任务可以借助Python和m1n1进行逆向分析。
这对于将Linux移植到M1上并没有什么用处,但是一旦我们将XNU虚拟化,以便追踪其MMIO访问,我们可能就会遇到这种情况。
开放性问题
在EL1中使用Hypervisor.framework时,是否可以启用SPRR和GXF?
是的! Longhorn指出,存在一个com.apple.private.hypervisor.vmapple权限,允许macOS下的EL1虚拟机也使用这些系统寄存器。
SPRR_CONFIG和GXF_CONFIG中的其他位是做什么用的?
启用SPR和GXF有什么其他影响?至少HCR_EL2不再可以从EL2写入,而是需要GL2权限了,我很肯定这绝对不是唯一的区别。
当我们处于保护性执行模式时,中断会被送到哪里?我认为它们会被送到VBAR_GLx,但我还没有确认这一点。
当在EL2中运行时,应该有一个不同的寄存器用于管理EL0的权限,例如HCR_EL2.TGE = 0和HCR_EL2.TGE = 1。也应该有一种方法可以从EL2访问EL1寄存器(类似于常见的._EL12的寄存器)。哪些寄存器可以做到这一点?
marcan已经在一则视频中把它们讲清楚了;现在它们已经被记录在m1n1中。
siguza发现了涉及PAN和WXN的一些晦涩的硬件bugundefined行为——这个问题仍然存在,还是SPRR也有类似的问题?
SPR和GXF代表什么?可能是“shadow permission remap register(影子权限重映射寄存器)”和“guarded execution feature(受保护的执行功能)”。
本文翻译自:https://blog.svenpeter.dev/posts/m1_sprr_gxf/如若转载,请注明原文地址