Alright, it’s certainly been a while, but iOS 27.2 beta 3 finally patched the last remaining trick used in Trigon’s arm64e implementation. With that, I’d like to welcome you back to the Trigon series. This time around, we will be discussing how I exploited all arm64e chipsets running iOS 16.5 and lower.

If you haven’t read the previous parts of this series, I would highly recommend you do. Part one covers A10 devices, and part two covers A9 and A11 devices. The source code is available here.

Recap

Okay, so in the last post, we talked about how we would gain code execution on the Always-On Processor to exploit A9 devices. By using that primitive to find the kernel base, the subsequent exploit flow would be the same as A10 and A11 devices: find the pv_head_table structure in kernel memory and use that to help us determine whether any physical page is a page table. Then, we would spray a large number of IOSurface objects in kernel memory and check every regular memory page for an IOSurface. Once we found one we could control, we would map it into our address space and control kernel pointers inside the structure to gain arbitrary kernel read/write primitives.

Let’s see how much of this works on arm64e! We will split the exploit into three main components and evaluate each one individually:

  • Finding the mapping base
  • Finding the kernel base
  • Achieving arbitrary kernel read/write

Prerequisite knowledge

arm64e SoCs have two unique mitigations that arm64 SoCs do not have: PAC and PPL. Together, these cause issues for the existing Trigon exploit on arm64e devices, so it’s important to understand what they are and how they work before continuing with the writeup.

PAC

Pointer Authentication Codes (PAC) are a way of ensuring pointers cannot be overwritten or tampered with by an attacker. Historically, many exploits relied on overwriting important pointers, be it pointers to fake objects in kernel memory or instruction pointers in the form of function return addresses. PAC aimed to stop important pointers from being overwritten by attackers by including a cryptographic signature in these pointers.

On modern ARM SoCs, the virtual address space doesn’t go beyond 48 bits in size. However, pointers are 64 bits in size, so there is bound to be a group of unused higher bits in the pointer. These were repurposed to hold the pointer’s signature. Then, special instructions were implemented to sign a pointer (add the signature), authenticate a pointer (check the signature) and strip a pointer (remove the signature and convert it back to a regular pointer). This signature cannot be faked or generated by an attacker, as it uses a proprietary variant of the QARMA algorithm, with keys that can’t be read even by the kernel.

As an example of PAC, you could have an unsigned kernel pointer such as 0xFFFFFFF041410000. Then, after the authentication code is added to the pointer, it may look something like 0x687D32F041410000. Before anything tries to access the memory pointed to by this pointer, the compiler will add an instruction to check the signature in this pointer. If it’s wrong, the kernel will panic, no second chances. If it’s correct, the signature will be removed and the original value of the pointer will be restored. It can then be accessed by the CPU as a regular memory address.

PPL

The Page Protection Layer (PPL) was introduced with the A12 SoC (iPhone XS/XR) in iOS 12. It serves as a “monitor” that runs above the XNU kernel in terms of privilege. PPL protects sensitive memory and data structures, such as page tables, credentials, entitlements and trustcaches.

PPL mode is entered via a “trampoline” that jumps up from regular XNU into PPL. On A12, they make use of a hardware feature called APRR (excellently documented by Siguza here) to switch the __PPLTEXT segment from read-only to readable and executable and switch __PPLDATA from read-only to readable and writeable. On A13 and above, they use GXF (also excellently documented by Sven Peter here) - PPL runs in GL1 on A13 and GL2 on A14+. The PPL trampoline instead uses the GENTER instruction to enter PPL mode, with the same effect of remapping all PPL-related segments to their required permissions.

What this means is that PPL-protected memory is read-only for the regular XNU kernel. If an attacker has kernel read/write primitives, they cannot modify their own page tables, the trustcaches, or anything else protected by PPL. Furthermore, if they also have a kernel PAC bypass, they still cannot call PPL functions, as these are not executable unless in PPL mode (which is only able to be entered via the trampoline).

PPL makes a variety of “routines” available to the kernel, such as adding a mapping into a process’ page tables, removing a mapping from a process’s page tables, changing a mapping’s permissions, and so on. Thus, when XNU wishes to make a modification to a PPL-protected data structure, it has to call the PPL trampoline with the correct routine number in X15, and PPL will handle it similarly to how XNU handles a system call from userspace.

The reason PPL is problematic for Trigon is because pmap_enter_options(), which maps memory into the page tables of process, will panic if the page belongs to the PPL, so we must avoid mapping any PPL memory as well.

In iOS 17, the PPL was replaced on A15 and newer SoCs. The page table portion was turned into the Secure Page Table Monitor (SPTM), which runs at the highest privilege level (IORVBAR now points into SPTM). The code-signing portion was put into the Trusted Execution Monitor (TXM) which runs under SPTM at a lower privilege level.

Evaluation of the existing exploit

Finding the mapping base

The method we used to find the base address of our mapping hinged on the fact that the iboot-handoff region was situated after VRAM, which meant we could just map each page ahead of our mapping base until we found it. iboot-handoff would contain its own physical address, which meant we could calculate the physical address of our mapping by subtracting the relative offset of iboot-handoff.

A quick look at a DeviceTree structure from any arm64e device shows that iboot-handoff is situated at the beginning of DRAM. This is a major problem, because the base of our mapping will always be after DRAM. This means that we cannot use iboot-handoff to find the base of our mapping.

Therefore, we will need a new way of retrieving the mapping base.

Finding the kernel base

On A10 & A11, we read IORVBAR to retrieve a physical address inside the kernelcache in memory. We then read backwards from this address until we find the kernel physical base, then read the beginning of the MachO header to find the kernel virtual base and calculate the KASLR slide.

Attempting to read IORVBAR with the Trigon primitive causes an Apple Memory Cache Controller (AMCC) panic - with the AMCC being the hardware memory controller. Through prior experience I was able to deduce that this was due to caching. MMIO like IORVBAR must be accessed completely uncached, but for some reason, the Trigon primitive was mapping the pages with slightly different caching attributes on arm64e than on arm64, which means we cannot access any MMIO.

I then considered a strategy similar to A9’s, which was overwriting a coprocessor’s firmware to gain code execution. Unfortunately, this is also no longer viable for two reasons. Firstly, coprocessor firmwares are now protected under KTRR’s successor, CTRR. This means we would not be able to overwrite any code executed by them.

Secondly, almost every coprocessor is behind a Device Address Resolution Table (DART). This is a component that sits in front of a coprocessor and acts like an MMU. It contains its own DART tables that are similar to page tables and control the physical addresses accessible by a coprocessor, regardless of the actual page tables used by the coprocessor. This means that even if we had code execution and page table control, we still couldn’t access DRAM and map in arbitrary memory.

If the kernel wants to share some memory with a coprocessor, it can ask the PPL or SPTM to map a page into the coprocessor’s DART. The physical address must be added to the DART’s page tables for it to be made accessible to the coprocessor. However, any physical pages related to DART configuration (e.g. MMIO, DART page tables) are protected by the PPL, thus we cannot map them via the Trigon primitive.

This means that we will either need to fix the caching issues with reading IORVBAR or find a new way of retrieving the kernel base.

Achieving arbitrary kernel read/write

On all previous versions of Trigon, we would first read the pv_head_table to map all safe physical pages in DRAM, then would use this to search for an IOSurface owned by our process. Once we found one, we could map it and modify kernel pointers in the structure to achieve controlled reads and writes from and to these addresses.

The first issue on arm64e stems from the introduction of a new table called the pp_attr_table (physical page attribute table). The pp_attr_table stores attributes about each page, similarly to the pv_head_table. One of the attributes that can be declared for a page is the PPL page attribute. This is the attribute that is checked by pmap_enter_options(), which will panic if the bit is set. In addition to this, iOS 14 added a new PV head flag called PVH_FLAG_LOCKDOWN. This signifies to the kernel that the page should never be mapped or unmapped - once it is locked down, all necessary mappings that will ever be needed are already setup. The reason these two issues cause problems for Trigon is that every page inside the CTRR region is locked down and under PPL memory. This means we cannot read any of the in-memory kernel for address translation or locating and reading structures.

The second issue on arm64e is that the pv_head_table and pp_attr_table are stored inside PPL memory. Therefore, we cannot map them with the Trigon primitive, meaning our method of determining whether or not a page is a page table is out of reach too.

The third issue is that my arm64e test device for this exploit was running iOS 16.0. It turned out the IOSurface technique was fixed in iOS 16, meaning we could no longer control pointers in the object for controlled reads and writes.

Apple don’t add PAC protection to every kernel pointer, simply because the overhead from signing and authenticating every single pointer would cause huge performance issues. Therefore, they only add the protection to important pointers that could be beneficial to an attacker if controlled. In iOS 16, they added PAC protection specifically to the pointers we used in the IOSurface object, which means they cannot be overwritten for arbitrary kernel read/write.

This means that to exploit Trigon on arm64e, we will also need an entirely new read/write primitive. On top of that, we will have to pull off an entire exploit without reading the kernelcache, knowing the kernel slide or knowing the address of any important structures. I also didn’t want to burn any unpatched techniques, so it will require finding a technique that is already patched.

Conclusion

So, let’s look back at the three stages of the exploit and recap what’s working on arm64e.

  • Finding the mapping base - not working
  • Finding the kernel base - not working
  • Achieving arbitrary kernel read/write - not working

Great, we have zero of the three stages required for the exploit. I guess we should just give up and end the blog post here, right?

Of course not - we just need to get creative!

Finding the mapping base

Okay, so we can’t use iboot-handoff to find the mapping base. I checked, and found no more recognisable structures after our mapping. There was no way to accurately guess the mapping base either - a wrong guess would lead to a kernel panic, but I wanted 100% reliability.

I then remembered something that I had discovered during the development of the first edition of Trigon - the mapping’s base address is static across different boots! This means that if you find the base address of the mapping once, you can continue to use this base address and it will be the same after a reboot or re-run of the exploit.

I decided to use this to my advantage, and as such the arm64e version of Trigon will require this address to be hardcoded for your device (this won’t be the only one!). There are a couple of ways to find this address: through kernel read/write or by guessing the base address, reading a static address assuming this guessed base address and using the panic log to calculate the correct base address (based on the physical address that was actually read).

Thankfully, I created an easy to use command line tool for any device running the Dopamine jailbreak that will find all the necessary hardcoded values for your device/version. Since Dopamine’s device support covers all Trigon-supported versions above iOS 15, this should be enough for most people. You can find a link to it in Trigon’s README.

Finding the kernel base

Attempting to read MMIO

Okay, we now have a full physical mapping primitive. We can also read iboot-handoff, because its address is also static on arm64e - it’s at the DRAM base, which is 0x800000000 for most devices. This gives us information about the memory layout of the device, which may come in handy later (in particular, DRAM bounds).

We cannot read the kernelcache, because the pages are locked down. However, we’ll never get anywhere unless we know something about the location of the kernel in memory. To find out some information about this, I wanted to read the MMIO holding IORVBAR and the CTRR region bounds. I mapped them with the Trigon primitive and got a… kernel panic.

Kernel panic after trying to read MMIO improperly

While this panic may not tell you much, as a testament to my (painful) past experiences with similar things AMCC-related, I knew this was a caching issue. MMIO has to be mapped and accessed completely uncached, but clearly this was not the case. This confused me, because it worked just fine on arm64 devices - it mapped the MMIO uncached as required.

After speaking to @TheRealClarity about this issue, he told me that you can actually control the caching via the IOSurface properties. The solution is simple - when constructing the properties dictionary for the PurpleGfxMem IOSurface, I just add this line:

CFDictionarySetValue(dict, CFSTR("IOSurfaceCacheMode"), CFNUM(0x100));

This is also done in Clarity’s Trigon-Legacy - check it out!

With this, we are able to map and read IORVBAR and the CTRR region bounds. Does this help us find the kernel base? We still can’t read any page inside the CTRR region. However, what we do know is that all executable code is physically contiguous because of CTRR, which means that if we knew the offset of IORVBAR from the kernel base, we could calculate the kernel physical base. Luckily, we can do exactly this! All kernels for different devices/versions are actually available to download from Apple’s servers - jailbreaks like Dopamine use this to patchfind the kernel before starting the jailbreak. So, all this requires is simply some static patchfinding and then, again, hardcoding a value (the IORVBAR offset).

With this done, we now know the physical address of the kernel base. Unfortunately, this doesn’t give us the kernel slide, since the maximum physical slide of the kernel is different to the maximum KASLR (virtual) slide. We don’t have any other physical addresses of important structures and objects, so we can’t actually use this to our advantage without any more information.

Achieving arbitrary kernel read/write

So, we’re in a really tricky position. We have the kernel’s physical base address, but not really anything more. We still cannot map anything within the CTRR region, so having the base address may as well be useless.

After a while, I had an idea. We can now map MMIO, as shown above. But what are MMIO used for, again? Communication between the AP and coprocessors & peripherals. Now, this isn’t exactly a breakthrough on its own - of course we can in theory talk to other peripherals on the device, but that doesn’t really help us here.

But I thought about Operation Triangulation, the original in-the-wild chain that exploited the Trigon vulnerability. That also had a PPL bypass through the AGX (GPU coprocessor). This bypass was done entirely through MMIO!

AGX cache poisoning

I wanted to understand how this bypass worked. After some investigation, I believe I can explain it well enough. However, the truth is, it’s impossible to truly know without information that is likely restricted only to Apple.

Essentially, certain coprocessors in the iPhone seem to have a debug feature that allows you to overwrite an L2 cacheline for some backing physical memory. Then, when the cacheline is evicted, the overwritten data will be written back to the physical memory it corresponded to. Normally, this wouldn’t be a security issue, because the coprocessor’s DART would prevent any protected memory from being written to. However, a unique feature of the AGX is that, for performance reasons, it has no DART. This meant that if you poisoned a cacheline for a PPL address, it would be written back with no problem.

A cacheline is 64 bytes on modern SoCs. What this means is that the primitive essentially gives us a 64-byte arbitrary write. This can be to kernel or PPL memory, but CTRR still applies, because that is enforced by the AMCC, which sits in front of the DRAM controller itself and intercepts every write from every processor. In iOS 16.6, Apple added the physical address ranges that cover these MMIO to pmap-io-ranges, which are address ranges that the PPL should prevent any mappings being made for. This creates a chicken-and-egg problem, because you’d need a PPL bypass in the first place to map the registers on newer versions of iOS.

Using the arbitrary write

Having the arbitrary PPL write primitive was a big step in the right direction, but we still have a slight problem: we don’t know where to write! Additionally, because we don’t have a read primitive, we must perform the entire absolute write either without knowing the data at the source address or by predicting it (if the source data contains kernel pointers, for example, we are most likely unable to overwrite it safely).

I wanted to investigate whether it was possible to predict the physical addresses of certain data structures based on knowing the kernel’s physical base address. Specifically, I looked at whether the pv_head_table and the pp_attr_table were at predictable locations in memory. Looking at the XNU source, you will find that they are allocated differently to most other data structures. Because they are allocated so early on in boot, the infrastructure for making proper heap allocations is not yet initialised. Therefore, they take the first available physical page of writable memory from iBoot’s boot arguments (BootArgs->topOfKernelData) and make subsequent early allocations from that. avail_start is the current next writable physical address, and they simply increment it by the size of every new early allocation each time.

pp_attr_table = (pp_attr_t *)phystokv(avail_start);
avail_start = PMAP_ALIGN(avail_start + pp_attr_table_size, __alignof(pmap_io_range_t));
...
pv_head_table = (pv_entry_t **)phystokv(avail_start);
avail_start = round_page(avail_start + pv_head_size);

In theory, this means they should be allocated at the same physical offset initially on every boot. However, I was concerned they could get remapped at some point and this wouldn’t be the case on a booted system. Therefore, I checked on a booted system and sure enough, the offset of both tables from the kernel’s physical base was always the same! This meant that we could locate them once (with an existing kernel exploit, for example) and then on subsequent runs can determine the physical addresses of both the pv_head_table and the pp_attr_table. If you think back to these tables, they control whether pages are marked as CTRR pages, PPL pages, page tables, and so on. Being able to overwrite these with our PPL write primitive essentially lets us map any physical page we want to.

At least, this is what I presumed. But after looking further into how the pv_head_table worked, I noticed that the table entries seemed to be kernel pointers with some of the top bits replaced with flags. This would point to some second-level structure depending on the type of page (page table, single mapping, multiple mappings, etc.). However, for pages with the PVH_FLAG_LOCKDOWN bit set, it’s even better for us. The lockdown bit essentially means that the page will never be mapped again, its current mappings will stay the same and it will never be unmapped either. What this also means is that the second-level structure will never be accessed by the kernel again, in theory.

The reason this is beneficial to us is that we can then replace this kernel pointer with a null pointer and still avoid any kernel data aborts. This means we can overwrite pv_head_table entries for locked down pages with just the flags we want and don’t want, we don’t have to worry about the encoded pointer. Thus, by using our PPL write primitive, we can “unlock” any locked down page and map it into our process by modifying the pv_head_table and pp_attr_table.

Building full kernel read/write

Once we have control of the pv_head_table and pp_attr_table, it’s as good as game over. First of all, I calculate the offset of the entries for both structures in each one and use the AGX arbitrary write primitive to make both tables mappable. Then, I map them into my process using the standard Trigon primitive and I can read/write to them as I please. That’s the last time I need to use the AGX arbitrary write.

Then, I can unlock the CTRR region by removing the lockdown bit and the PPL bit for each page. After that, I can read the in-memory kernelcache to find the kernel base, slide and its root level page table. Once that has been located, I can make it mappable and then read it in order to let me translate any kernel address to its respective physical address. At this point, we have achieved the ability to read from and write to any virtual kernel address, which is the point of a kernel exploit. Finally, it is complete! Or… is it?

Building better kernel read/write primitives

Observed issues

Soon after I began trying to implement my exploit into Dopamine, I ran into an issue - it couldn’t build stable PPL read/write primitives without panicking (invalid PV head panic message).

The way Dopamine builds stable primitives is by using the PPL bypass to write arbitrary page table entries to its own page tables, which gives it access to whatever physical addresses it likes. To try and track down the issue, I began to implement this standalone inside the Trigon exploit. I found that when I tried to map and access my own page table several times, it would panic with the invalid PV head message. Soon after, I realised that pages that are accessed by userspace more than once cannot be mapped without triggering this panic.

While I never solved the issue, the root cause seemed to be that when I modified the PV head table before mapping a protected page, I would somehow corrupt some other data structure. This is likely due to the fact that I set the pv_head_table entry to NULL before mapping the page, which causes the kernel to create a new entry for it when I map and access the page. Even though I restore the original entry after accessing the page, it seems that on subsequent accesses, some structures are out of sync in kernel memory and it panics as a result. Nevertheless, if you can’t solve an issue, you can always work around it.

Plan

What we do know is that the pages we need to map for translation (AKA the kernel’s page tables) will never be mapped by userspace (as the PPL would normally forbid it, because they are PPL/CTRR protected). Kernel heap pages, or anything in the kernelcache, for example, are also safe to map. I initially thought kernel heap pages might be risky, as they could get unmapped and re-mapped to userspace later on, but I realised the pv_head_table entry would be regenerated in this event anyway. However, shared memory, userspace page tables, or anything similar to this is off limits and unsafe to map.

Knowing this, I came up with a plan. We just need to build a new kernel read primitive - we can still use the AGX cache injection bug as a write primitive. Then, the kernel read primitive can be used to safely read PPL- and CTRR-protected pages without having to mess with the pv_head_table or pp_attr_table (since this memory is all mapped read-only to XNU).

Socket-based read primitive

With the release of DarkSword, we were given a very simple kernel read/write primitive via sockets.

The primitive works by overwriting a pointer in the inpcb structure, held at socket->so_pcb->inp6_icmp6filt. Then, by calling getsockopt() with the ICMP6_FILTER option, it will perform a copyout() to userspace using this pointer, which will behave like a memcpy() from kernel memory to userspace memory.

To set this up in Trigon, we have to create a new socket by calling socket(), then find its kernel address using our initial kernel read/write primitives. From there, we can find the kernel address of its inpcb structure and thus the inp6_icmp6filt pointer.

int driver = socket(AF_INET6, SOCK_DGRAM, IPPROTO_ICMPV6);

// Find the address of our new socket
uint64_t ofiles = kread_ptr(proc + koffsetof(proc, ofiles));
uint64_t fileproc = kread_ptr(ofiles + (driver * sizeof(uint64_t)));
uint64_t fileglob = kread_ptr(fileproc + koffsetof(fileproc, fileglob));
uint64_t socketKaddr = kread_ptr(fileglob + koffsetof(fileglob, data));
uint64_t inpcb = kread_ptr(socketKaddr + koffsetof(socket, pcb));

Once we have that, we use the early read/write primitives once again to translate this kernel address to a physical address, before mapping it with our mapping primitive. Once that’s done, we can perform an arbitrary kernel read like so.

socklen_t len = SOCKET_ICMP6_FILTER_SIZE;
*icmp6filter_mapped = va;
getsockopt(gSocket, IPPROTO_ICMPV6, ICMP6_FILTER, outBuffer, &len);

Now that we have a new read primitive setup, we are finally done (for real this time!). We can safely read any PPL- or CTRR-protected page without modifying the pv_head_table or pp_attr_table, and we can write to any PPL-protected page using the AGX cache injection primitive. This gives us a full, stable, deterministic kernel exploit and PPL bypass!

This read/write primitive was finally patched in iOS 27.2 beta 3 by adding data PAC to the icmp6filter pointer, which means that Trigon64e is officially completely patched.

Conclusion

I was always annoyed that I couldn’t figure out how to pop arm64e with this bug, back when I originally released my A10 exploit. With this being such a powerful bug, there was bound to be a nice way to exploit it, and I think this implementation is pretty cool. Now, from iOS 13 to iOS 16.5, on A9 - A16 devices, that now gives us a very wide (and, if I may say so myself, pretty impressive) range of device and version combinations supported by Trigon! Two coprocessors, three different strategies and five read/write primitives have all gone into this exploit across all of these combinations.

Looking at Trigon’s in-the-wild implementation would be an interesting bit of research, now that we finally have the samples of the exploit from Coruna (which was an updated version of Operation Triangulation). It’s on my ever-growing list of rainy day projects.

As with every iteration of Trigon, I could never have done this alone. A few people I would like to thank:

  • staturnz, TheRealClarity - continued collaboration and work on Trigon throughout its development and discussing ideas/strategies with me
  • Google’s Threat Intelligence Group - discovery and disclosure of DarkSword
  • DarkSword’s developers - socket read primitive

If you have any questions, or notice any mistakes, please email me at [email protected] and I will try and get back to you ASAP. I’m always happy to discuss ideas and answer any questions, no matter how simple or complex they may be!