Now that we have confirmed the Rocket path, let’s look at the other one. In theory, this should be the Sparrow path we have been trying to find.

The Bug

This vulnerability lives in the open-source part of XNU, so the XNU diff gives us pretty much everything we need. Nice and easy.

代码
--- 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)) {

The new vm_page_validate_no_references() fixes a bug in the earlier version. Whenever a physical page is about to enter the free-and-reuse path, XNU now checks its PV references in pmap. If the PVH still contains a mapping, the kernel panics.

The old version only had ASSERT_PMAP_FREE(). Release kernels compile that assertion away, which lets the VM layer consider the page free while pmap still keeps the old mapping around. Once the same physical frame is handed to a new kernel object, the stale mapping keeps pointing at it.

That gives one physical page two identities at once. Userspace still owns the stale mapping, while the kernel allocator sees a free page and places a fresh live object on top of it. Reading or writing through the stale mapping now hits the new object.

This is a physical UAF. The dangling piece is the physical-page mapping, and page grooming decides what kind of object gets reclaimed into that frame.

Exploitation

As an aside, this looks a lot like the exploit flow in my first complete XNU exploit. Kernel UAFs tend to rhyme.

We start with an existing kernel read/write primitive. We use it to prepare the physical-page mapping, then move into the races below.

First, we prepare a victim thread port. We do some page grooming so the object lands on a fresh page, then free the extra objects around it. After that, we create two threads: one drives the race, and the other checks whether it worked.

First Race

Once the race starts, one thread terminates the victim thread and immediately creates 64 new threads. It keeps every Mach name associated with those new threads. Meanwhile, the main thread watches the old slot in the task’s thread list and waits for a new thread pointer to appear there.

Seeing a replacement pointer is only the first check. We also confirm that the new pointer is valid, that the list links have changed, and that one of the saved Mach names really points to the reclaimed object. Once we find that name, we keep it aside. Every later thread_set_state() and thread_resume() call enters the kernel through this port.

At this point, we have:

  1. A normal, valid thread send right;
  2. A kernel object that has gone through free and reclaim, in this case a thread;
  3. A stable physical-page read/write window supplied by the earlier physmap dangling mapping, with the reclaimed thread now sitting inside that window.

We also repair the forward and backward links in the task’s thread list. Leaving those links corrupted would cause plenty of kernel panics later.

Second Race

The second race revolves around thread_set_state().

We first prepare a fake thread state containing the new continuation, wait link, message back-pointer, message link, and policy bits.

Then two threads run together. One calls thread_set_state() through the thread port we kept from the spray. The other repeatedly edits the policy and list-link fields through the dangling mapping, which still exposes the original victim thread’s page. During the same loop, it also switches the physical page’s state in the physmap metadata.

Success means the kernel sees a fresh thread identity while the policy, wait link, and message link all match the values we prepared.

The dangling page has now gone from “a writable kernel page” to “a writable thread object that the kernel will actively execute.” The writable range is still one 16 KB page, yet its control-flow value is completely different.

From a Thread Port to KCall

The thread port still needs one more upgrade. A plain thread port gives us legitimate thread RPC. A controlled thread object plus the exception-return path gives us kernel control flow.

We install an exception port on the thread and call thread_resume(). As the kernel resumes it, it consumes the continuation, saved state, wait link, and message pointer that we wrote into the object. The fake context eventually raises an exception, and userspace receives the resulting kernel message from the exception port we installed.

Next, we install the final fake thread object on a long-lived mapped page. For each call, it places the first few arguments into the restored registers, writes additional arguments into kernel memory prepared ahead of time, and builds a 16-entry notification message. Whenever we need a signature, the same exception path executes PACIA, PACDA, or PACGA and writes the result back into the shared buffer.

PACIA, PACDA, and PACGA cover instruction-address, data-address, and generic-data authentication.

This gives us a reusable authenticated KCall together with PACIA, PACDA, and PACGA as signing services. It works like an upgraded kernel read/write primitive and can call copyin() or copyout() for bulk access to ordinary kernel memory. Read-only pages still require the PPL promotion described below.

In practice, driving this thread object is a little unstable, so I reused the Fugu14 KCall module and upgraded it—at least, I think that counts as an upgrade.

Where Is the PPL Bypass?

So here is the obvious question. We already have authenticated KCall and PAC-signing capabilities, while the CTIG write-up calls Sparrow a PPL bypass. Where is the rest of it?

The third race happens after KCall and uses it to manipulate the kernel.

First, KCall creates a legitimate UAT/IOMMU state. Calling the resolved pmap_iommu_init_internal() returns a UAT state that PPL created and validated. The fake AppleKeyStore object and the PAC gadgets build a call context that the kernel accepts.

Next, we call pmap_iommu_ioctl_internal() with a preselected ordinary kernel page as the output region. This entry point validates the UAT state and then sets PP_ATTR_NO_MONITOR on the output page. The flag means that this kernel-owned page is currently serving as a PPL output parameter and cannot become PPL-owned until the call finishes.

We read pp_attr_table from the normal kernel side. Seeing 0x8000 tells us:

  • UAT state validation has finished;
  • The output page is temporarily pinned;
  • The first call is still somewhere between pin and unpin.

At the same time, another path sends 0xBEE2 and tears down the UAT state.

In the UAT source, 0xBEE2 is UAT_PPL_IOCTL_TASK_DIED. It:

  • Invalidates the corresponding UAT context;
  • Frees the L1 page table;
  • Frees the associated UAT state object.

This is the race: the first call is still running while the second call frees its state and page-table resources.

The result is a state-object UAF inside PPL. The first call still holds the address of the UAT state, while the second call has already freed that state.

Three pieces matter here. The page temporarily pinned with PP_ATTR_NO_MONITOR is the output page, and it remains an ordinary kernel page. BEE2 frees the UAT state slot and its L1 root. The state slot stays inside UAT: it gets zeroed and goes straight back onto UAT’s private freelist.

We then perform eight legitimate UAT initialization and allocation operations. This pushes the allocator to reclaim the recently freed state slot and related page-table resources with a new UAT object. Once reclaim succeeds, the same address contains a freshly initialized object.

The first pmap_iommu_ioctl_internal() call then resumes. It had already passed state validation when it entered PPL, and the address in its call context has stayed the same. It continues by reading the descriptor from that address and dispatching the ioctl. The address now contains the reclaimed state, so PPL follows its legitimate path while operating on an object whose identity has changed underneath it.

The earlier physical-UAF fix in vm_page_validate_no_references() covers the free-and-reuse path for ordinary VM pages. That patch checks the pmap/PV references on a whole VM page. This race frees and reuses one UAT object slot inside a PPL page, and the dangling reference is a regular kernel pointer kept in the first call’s registers and call context. The new VM-page check only observes pmap/PV state.

The patch stops Sparrow’s earlier physmap dangling-page stage, so the complete chain stalls before it obtains KCall. Once another vulnerability supplies KCall, the UAT state race can still run as a separate second stage.

Finally, we call pmap_enter_options_internal() and use the promoted capability to establish two fixed mapping windows. The first window exposes the second page’s L3 PTE, letting us change the physical page mapped by that second window. The second window is the actual read/write aperture.

For every later access, we walk the target kernel virtual address to its physical address, update the second window’s PTE to point at that physical page, and access the page through the window. This bypasses PTE protection and leaves us with a reusable read/write aperture for protected physical pages.