既然Rocket路径已经验证成功了,我们来检查第二个吧。理论上来说他就是我们一直没找到的Sparrow。

问题

这次漏洞是在xnu开源范围里的,直接读XNU diff就够了,非常好。

diff
--- xnu-10002.61.3/osfmk/vm/vm_resident.c
+++ xnu-10002.81.5/osfmk/vm/vm_resident.c
@@ -92,6 +92,7 @@
 #include <pexpert/pexpert.h>
 #include <pexpert/device_tree.h>
 #include <san/kasan.h>
+#include <os/log.h>
 
 #include <vm/vm_protos.h>
 #include <vm/memory_object.h>
@@ -153,7 +154,6 @@
 int             PERCPU_DATA(start_color);
 vm_page_t       PERCPU_DATA(free_pages);
 boolean_t       hibernate_cleaning_in_progress = FALSE;
-boolean_t       vm_page_free_verify = TRUE;
 
 uint32_t        vm_lopage_free_count = 0;
 uint32_t        vm_lopage_free_limit = 0;
@@ -3141,6 +3141,41 @@
 static inline void
 vm_page_grab_diags(void);
 
+/*
+ * vm_page_validate_no_references:
+ *
+ * Make sure the physical page has no refcounts.
+ *
+ */
+static inline void
+vm_page_validate_no_references(
+ __unused vm_page_t       mem)
+{
+#if !XNU_TARGET_OS_OSX
+ bool is_freed;
+
+ if (mem->vmp_fictitious) {
+   return;
+ }
+
+ pmap_paddr_t paddr = ptoa(VM_PAGE_GET_PHYS_PAGE(mem));
+
+ is_freed = pmap_verify_free(VM_PAGE_GET_PHYS_PAGE(mem));
+
+ if (!is_freed) {
+   /*
+    * There is a redundancy here, but we are going to panic anyways,
+    * and ASSERT_PMAP_FREE traces useful information. So, we keep this
+    * behavior.
+    */
+   ASSERT_PMAP_FREE(mem);
+   panic("%s: page 0x%llx is referenced", __func__, paddr);
+ }
+#else
+ ASSERT_PMAP_FREE(mem);
+#endif //!XNU_TARGET_OS_OSX
+}
+
 vm_page_t
 vm_page_grab(void)
 {
@@ -3184,13 +3219,14 @@
    assert(mem->vmp_tabled == FALSE);
    assert(mem->vmp_object == 0);
    assert(!mem->vmp_laundry);
-   ASSERT_PMAP_FREE(mem);
    assert(mem->vmp_busy);
    assert(!mem->vmp_pmapped);
    assert(!mem->vmp_wpmapped);
    assert(!pmap_is_noencrypt(VM_PAGE_GET_PHYS_PAGE(mem)));
    assert(!mem->vmp_realtime);
 
+   vm_page_validate_no_references(mem);
+
    task_t  cur_task = current_task_early();
    if (cur_task && cur_task != kernel_task) {
      if (cur_task->donates_own_pages) {
@@ -3371,12 +3407,13 @@
 
      mem->vmp_q_state = VM_PAGE_ON_FREE_LOCAL_Q;
 
-     ASSERT_PMAP_FREE(mem);
      assert(mem->vmp_busy);
      assert(!mem->vmp_pmapped);
      assert(!mem->vmp_wpmapped);
      assert(!pmap_is_noencrypt(VM_PAGE_GET_PHYS_PAGE(mem)));
      assert(!mem->vmp_realtime);
+
+     vm_page_validate_no_references(mem);
    }
 #if defined (__x86_64__) && (DEVELOPMENT || DEBUG)
    vm_clump_update_stats(sub_count);
@@ -3558,9 +3595,9 @@
  vm_page_free_prepare_object(mem, TRUE);
  vm_object_unlock(object);
  object = VM_OBJECT_NULL;
- if (vm_page_free_verify) {
-   ASSERT_PMAP_FREE(mem);
- }
+
+ vm_page_validate_no_references(mem);
+
  pmap_clear_noencrypt(VM_PAGE_GET_PHYS_PAGE(mem));
  vm_page_secluded.grab_success_other++;
 
@@ -3639,7 +3676,6 @@
 }
 #endif /* CONFIG_SECLUDED_MEMORY */
 
-
 static inline void
 vm_page_grab_diags()
 {
@@ -3679,9 +3715,9 @@
  }
 
  assert(!mem->vmp_private && !mem->vmp_fictitious);
- if (vm_page_free_verify) {
-   ASSERT_PMAP_FREE(mem);
- }
+
+ vm_page_validate_no_references(mem);
+
 // dbgLog(VM_PAGE_GET_PHYS_PAGE(mem), vm_page_free_count, vm_page_wire_count, 5);  /* (TEST/DEBUG) */
 
  pmap_clear_noencrypt(VM_PAGE_GET_PHYS_PAGE(mem));
@@ -4199,14 +4235,15 @@
    assert(mem->vmp_specialq.next == 0);
    assert(mem->vmp_specialq.prev == 0);
    assert(mem->vmp_next_m == 0);
-   ASSERT_PMAP_FREE(mem);
+
+   vm_page_validate_no_references(mem);
+
    {
      vm_page_init(mem, VM_PAGE_GET_PHYS_PAGE(mem), mem->vmp_lopage);
    }
  }
 }
 
-
 /*
  * vm_page_free:
  *
@@ -4294,8 +4331,8 @@
      mem->vmp_snext = NULL;
      assert(mem->vmp_pageq.prev == 0);
 
-     if (vm_page_free_verify && !mem->vmp_fictitious && !mem->vmp_private) {
-       ASSERT_PMAP_FREE(mem);
+     if (!mem->vmp_fictitious && !mem->vmp_private) {
+       vm_page_validate_no_references(mem);
      }
 
      if (__improbable(mem->vmp_realtime)) {

这里新加的vm_page_validate_no_references()修复了先前版本存在的问题:一张物理页准备进入free/reuse流程时,它先查询pmap里的PV引用状态。只要PVH里还挂着映射就panic。

旧版本只有ASSERT_PMAP_FREE()。Release kernel会编译掉这条assertion,于是VM层可以认为页面已经free,pmap层继续保留旧映射。后面同一张physical frame分给新的内核对象时,旧映射继续指着它。

结果就是同一张物理页同时拥有两种身份。用户态还留着旧映射,内核分配器已经把它当成空闲页,又在上面放了一个新的活对象。用户态读写旧映射时,碰到的其实已经是后面那个新对象。

这是一类physical UAF。悬空的是物理页映射,后面复用进来的对象类型由页面风水决定。

攻击

题外话:发现这里的利用逻辑和我写的第一个完整xnu攻击非常相似。不过内核UAF似乎都是这样

这里从一份已有的krw开始。先用它准备好物理页映射,而后进入下面的racing。

首先准备一个受害thread port。做一下页面风水,把对象放进一张新页里。前后多余的对象free掉。而后创建两个线程,一个用来判断成功一个用来进行racing。

First race

race开始以后,其中一个线程终止刚才那个victim thread,紧接着又创建64个新thread,并把这些新thread对应的Mach names全部留下来。主线程一直盯着task thread list里原来的位置,等待那个位置出现新的thread pointer。

看到替换还不够。我们还需要确认新指针有效、链表关系已经变化,而且刚才保存的Mach names里确实有一个指向这次复用出来的对象。找到以后,这个Mach name会被单独留下来,后面所有thread_set_state()和thread_resume()都通过它进入内核。

这一步完成以后,我们手里有:

  1. 一个正常有效的thread send right;
  2. 一个已经经历释放/复用的内核对象,这里来说是一个thread;
  3. 前置physmap dangling mapping提供的一张稳定物理页读写窗口,现在这张窗口里正好放着刚刚复用出来的thread。

另外需要把task thread list前后链修回去,否则后续操作过程中内核结构出问题会导致大量panic。

Second race

接下来第二层race围绕thread_set_state()展开。

先准备一份伪造的thread state。里面会放好新的continuation、wait link、message back-pointer、message link和policy bits。

然后两个线程一起动。一个线程通过刚才保留下来的thread port(喷射得到的重复thread)调用thread_set_state();另一个线程从悬挂映射(即,原始的victim thread)里反复修改policy和链表字段,中间还会切换那张物理页在physmap metadata里的状态。

当内核里出现了一个新的thread identity,policy、wait link和message link同时变成刚才准备的值之后才算成功。

完成以后,dangling page已经从“能改一张内核页”变成了“能改一个内核会主动执行的thread对象”。地址范围仍然是这一张16KB页,控制流价值已经完全不同了。

thread port到kcall

有了thread port以后还差一次提升。单独的thread port提供合法thread RPC;受控线程对象 + exception return提供内核控制流。

给这个thread安装一个exception port,再调用thread_resume()。内核恢复它时会消费我们写进去的continuation、saved state、wait link和message pointer。伪造现场最终触发exception,用户态再从刚才安装的exception port里接收内核发来的消息。

随后把最终fake thread object重新安装到一张长期映射页里。每次调用时,它先把前几项参数放进要恢复的寄存器,把更多参数写进提前准备好的内核内存,再拼出一份16项的notification消息。需要签名时,同一条exception路径会分别执行PACIA、PACDA或者PACGA,并把结果写回共享缓冲区。

PACIA/PACDA/PACGA分别指Instruction/Data/Generic Data

从而我们得到了一个可以反复使用的authenticated KCall,同时还可以把PACIA、PACDA和PACGA当成签名服务。它等价于一个升级版的krw,可以调用copyin/copyout批量读写普通内核内存;RO页面仍然需要后面的PPL promotion。

在实践中,这样一个thread object的使用是有点不太稳定的,所以我复用了fugu14的kcall模块,对它进行了升级(也许?)

Where's PPL bypass?

note: 这里后面的PPL bypass是AI逆向的结果,我没有设备来进行实测。

那对啊,问题来了,这里我们已经有了authenticated KCall和PAC signing能力,但是在CTIG的文章里说,Sparrow是一个PPL bypass啊?剩下的一段在哪呢?

第三次racing是在kcall之后对内核进行操作做到的racing。

首先,用Kcall准备并创建一个合法的UAT/IOMMU状态:调用搜索到的pmap_iommu_init_internal可以获得一个经过PPL校验,由PPL创建的UAT状态,伪造的AppleKeyStore 和 PAC gadgets负责构造可以被内核接受的调用现场。

然后调用pmap_iommu_ioctl_internal,把预先选好的普通内核页作为输出区域。这个入口会先校验UAT状态,再给输出页设置PP_ATTR_NO_MONITOR。这个标志的意思是:这张kernel-owned page正在充当PPL的输出参数,在调用结束之前不能转成PPL-owned page。

我们在普通内核侧读取pp_attr_table,看到0x8000后说明:

  • UAT状态校验已经走完
  • 输出页面已经被临时pin住
  • 第一条调用仍在pin和unpin之间

同时走另一条路线销毁UAT状态,发送0xBEE2

在 UAT 源码里,0xBEE2 对应 UAT_PPL_IOCTL_TASK_DIED。它会:

  • 使对应的 UAT context 失效;
  • 释放 L1 页表;
  • 释放相关 UAT 状态对象。

这个时候出现了racing:第一条调用仍在执行,第二条释放了状态和页表资源。

这里得到的是一个PPL内部的state-object UAF。第一条调用手里还保存着刚才那个UAT状态的地址,第二条调用已经把这个状态释放了。

这里需要把三块东西分清楚。被PP_ATTR_NO_MONITOR临时pin住的是输出页,它一直是普通内核页。BEE2真正释放的是UAT state slot和它的L1 root。state slot留在UAT内部,被清零以后直接挂回UAT自己的freelist。

接着连续进行八次合法的UAT初始化和分配,让刚释放的state slot以及相关页表资源尽快被新的UAT对象重新占用。重新占用完成以后,原来的地址里已经放进了一个刚初始化的新对象。

而后第一条pmap_iommu_ioctl_internal恢复执行。它进入PPL时已经完成过状态校验,手里的地址也没有变化,所以会继续从这个地址读取descriptor并执行ioctl。此时地址里装着的是刚刚重新占用进来的状态,于是PPL会沿着合法流程继续操作一个已经换了身份的对象。

前面vm_page_validate_no_references()修复的physical UAF覆盖普通VM页面的free/reuse流程。那个补丁检查的是整张普通VM页面进入free/reuse流程时还剩多少pmap/PV引用;这里释放和复用的是PPL页面里的一个UAT对象槽,悬挂的引用也是第一条调用保存在寄存器和调用现场里的普通内核指针。新的VM页面检查只观察pmap/PV状态。

这个补丁会先卡住Sparrow前面的physmap dangling page,让这条完整链停在取得KCall之前。我们从其他漏洞获得KCall以后,后面的UAT state racing仍然可以单独继续。

最终调用pmap_enter_options_internal,配合得到的能力建立两张相应的固定窗口映射:第一张访问第二张页面的L3 PTE(从而我们可以修改第二张页面映射的位置),第二张则是实际读写窗口。

后续每次操作时,先把目标内核虚拟地址walk成物理地址,再修改第二张窗口的PTE,让它指向目标物理页。这样我们就绕过了PTE保护,获得了可以反复重定向的受保护物理页读写窗口。