Back in February we published our first look at Apple's Secure Kernel. The experiment appeared to show that code in an ordinary Exclave could place a fake capability in writable memory, invoke it, and make the Secure Kernel branch through data beyond its object type table.
That general conclusion was wrong. The original GLx Runner was still emulating much of Apple's security machinery, and PAN had been disabled in that environment. Our treatment of the SPRR permission registers was also not faithful enough for this experiment. We had tested what happened after removing a protection, then described the result as though the protection were present.
Apple has never called us out on this, publicly or privately. We found the mistake ourselves, and correcting it is the right thing to do. The original post will remain online with a link to this article.
Repeating the test did not make the unchecked dispatch disappear. An ordinary Exclave gets error 4, while roottask can reach the dispatch if it already holds the stronger frame rights. That is the distinction we missed.
The test we should have performed
The Secure Kernel, or cL4, runs at guarded level GL1. Exclave programs run at GL0. A capability accepted by SK is not supposed to be an arbitrary piece of memory that an Exclave can write. SK must be able to read genuine capability storage without treating ordinary user memory as a kernel object.
For this boundary Apple uses Privileged Access Never, or PAN, together with its SPRR permission configuration. While it is set, PAN prevents privileged data accesses to ordinary user-accessible mappings unless the kernel deliberately permits such an access. Looking only at a normal page-table permission bit is not enough here: SPRR changes the effective access permissions, and SK uses a particular permission configuration for the guarded world.
We repeated the test on the supplied iPhone Duo 27.1 firmware, build 24A94425, using native guarded execution on an M5. The caller was again schedulerAddrSpace. The test wrote the fake header into a page writable by that caller, read it back at GL0, and executed SVC 0. The harness did not change PAN, SPRR, the page permissions, or SK's code.
There is also a small correction to the assembly in the original post: the printed MOVK immediate is not encodable. The bad PC recorded in that article corresponds to object type 36 in the old table. Our replay therefore constructs the actual header value 36 << 58, or 0x9000000000000000. We also tested types 60 and 63 so that the conclusion would not depend on guessing which typo the old listing contained.
All three requests returned 4. None reached the object type table.
The load that changes the result
We also checked the exact macOS 26.2 image used in the first article. Its Secure Kernel UUID is 0F7B4C4F-1289-34A1-9479-09451801E26F, matching the old LLDB transcript. The CL4 bundle has SHA-256 61d4e7345403551e09d99b66744958c2707cf16a437fe69616d40aadacfedc54. The original bytes already contain the pointer probe:
ffffff80000083d8: 927a7680 and x0, x20, #0xfffffffc0 ffffff80000083dc: aa0003f2 mov x18, x0 ffffff80000083e0: f9400252 ldr x18, [x18] ffffff80000083e4: d2800032 mov x18, #1 ffffff80000083e8: f100065f cmp x18, #1 ffffff80000083ec: fa400804 ccmp x0, #0, #4, eq ffffff80000083f0: 540006c1 b.ne 0xffffff80000084c8 ... ffffff80000083fc: 52800089 mov w9, #4 ffffff800000844c: f9000109 str x9, [x8]
The success branch goes directly to the type-table lookup printed in February, ending in blr x8 at 0xffffff80000084e8. The 26.2 data-abort handler has the matching recovery path too: it compares saved X18 with the fault address, clears saved X18 at 0xffffff8000007dfc, and advances the saved PC by eight bytes at 0xffffff8000007e04. We therefore know the check existed in the original firmware. The native execution recording below uses Duo 27.1; its addresses are different.
The old article concentrated on the later table lookup. It missed the importance of the pointer probe immediately before it. These are the corresponding instructions in the original Duo image:
fffffc0000008610: 927a7680 and x0, x20, #0xfffffffc0 fffffc0000008614: aa0003f2 mov x18, x0 fffffc0000008618: f9400252 ldr x18, [x18] fffffc000000861c: d2800032 mov x18, #1 fffffc0000008620: f100065f cmp x18, #1 fffffc0000008624: fa400804 ccmp x0, #0, #4, eq fffffc0000008628: 540006c1 b.ne 0xfffffc0000008700
The first instruction extracts the aligned capability address. SK then puts that address in X18 and tries to read it. If the read succeeds, the following instruction replaces X18 with 1. The later comparison distinguishes that successful probe from the recoverable failure path.
With PAN active, the load at 0xfffffc0000008618 cannot read the scheduler's writable GL0 page. Here are the relevant fields from the native event journal:
caller: schedulerAddrSpace fake header: 0x9000000000000000 faulting SK PC: 0xfffffc0000008618 ESR_GL1: 0x9600000f FAR_GL1: 0x0000000be719b600 PAN: 1 returned X0: 0x4 type dispatch: not reached
The processor detects the forbidden read while executing ldr x18, [x18]. It raises a synchronous data abort and records the faulting instruction in ELR_GL1, the accessed address in FAR_GL1, and the reason in ESR_GL1. The syndrome 0x9600000f has exception class 0x25 (data abort at the current exception level), WnR=0 (read), and fault status 0x0f (level 3 permission fault). The fake object's type has not been read yet.
Record 76 is the failing load at 0xfffffc0000008618. The next record enters SK's synchronous exception vector with the original fault registers intact.
How SK recognizes the failed probe
In this Duo recording, the exception vector is at 0xfffffc0000003a00. It saves the registers on the SK stack and passes a pointer to that frame to the common exception dispatcher. Saved X18 is at frame offset 0x90, and the saved return PC is at 0xf8. The dispatcher then reads the hardware's syndrome and fault address:
fffffc0000007e10: d53efaa8 mrs x8, S3_6_C15_C10_5 // ESR_GL1 fffffc0000007e14: f901a808 str x8, [x0, #0x350] fffffc0000007e18: d53efae8 mrs x8, S3_6_C15_C10_7 // FAR_GL1 fffffc0000007e1c: f901ac08 str x8, [x0, #0x358] fffffc0000007e20: f941a808 ldr x8, [x0, #0x350] fffffc0000007e24: 531a7d10 lsr w16, w8, #26
The register encodings above are LLVM's spelling for Apple's guarded ESR and FAR registers. The shift extracts exception class 0x25. SK's dispatch table selects the current-level data-abort path and enters the handler at 0xfffffc0000007fbc with W1=1 and W2=0. That handler checks the abort flags and masks the syndrome with 0x3f. Fault status 0x0f reaches its recoverable permission-fault case.
The test that identifies the failed pointer probe is the comparison with saved X18. SK had deliberately put the address being probed in that register. The abort handler requires it to equal FAR; it does not inspect the fake object or try the type-table lookup again. The relevant instructions are:
fffffc0000008010: 37000182 tbnz w2, #0, 0xfffffc0000008040 fffffc0000008014: 34000161 cbz w1, 0xfffffc0000008040 fffffc0000008018: f9404808 ldr x8, [x0, #0x90] // saved X18 fffffc000000801c: f941ac09 ldr x9, [x0, #0x358] // saved FAR_GL1 fffffc0000008020: eb09011f cmp x8, x9 fffffc0000008024: 540000e1 b.ne 0xfffffc0000008040 fffffc0000008028: f900481f str xzr, [x0, #0x90] // saved X18 = 0 fffffc000000802c: f9407c08 ldr x8, [x0, #0xf8] // saved PC fffffc0000008030: 91002108 add x8, x8, #8 fffffc0000008034: f9007c08 str x8, [x0, #0xf8] // save new PC fffffc0000008038: a8c17bfd ldp x29, x30, [sp], #0x10 fffffc000000803c: d65f0fff retab
That add is the eight-byte adjustment. It changes the saved PC from 0xfffffc0000008618 to 0xfffffc0000008620, skipping both the failed load and mov x18, #1. The restore path writes the new value to ELR_GL1 at 0xfffffc0000004508 and executes eret at 0xfffffc0000004568. The native trace shows each step: record 180 has X8 equal to ...8618 before the addition, record 181 has ...8620, and record 229 returns to ...8620.
The recorded add x8, x8, #8 at instruction 180. X8 contains the faulting PC before the instruction; moving forward once shows the adjusted return address.
The original macOS 26.2 kernel uses the same frame offsets for X18 and PC. Its FAR slot is 0x348 instead of 0x358. The equivalent adjustment is present in the original image:
ffffff8000007dfc: f900481f str xzr, [x0, #0x90] ffffff8000007e00: f9407c08 ldr x8, [x0, #0xf8] ffffff8000007e04: 91002108 add x8, x8, #8 ffffff8000007e08: f9007c08 str x8, [x0, #0xf8]
The 26.2 recovery code would resume at 0xffffff80000083e8. The native Duo trace resumes at 0xfffffc0000008620. Both paths clear saved X18, so the success check fails and the code selects error 4. In the Duo recording, the type lookup and indirect dispatch are never reached.
What error 4 means
The value is not a mysterious internal panic code. Apple's public XNU source contains the relevant L4 definitions in osfmk/mach/exclaves_l4.h. The beginning of the enum, with its implicit values made explicit, is:
Exclaves_L4_ErrorCodeSuccess = 0, Exclaves_L4_ErrorCodePreempted = 1, Exclaves_L4_ErrorCodeCanceled = 2, Exclaves_L4_ErrorCodeTruncated = 3, Exclaves_L4_ErrorCodeCapInvalid = 4,
The same header puts the error code in the low eight bits and optional supplementary information above them. A return of exactly 4 therefore says that the supplied capability is invalid. In this experiment that is the expected result: the purported capability lives in memory that SK's privileged probe is not allowed to read.
So the complete answer to the original experiment is rather less dramatic than the panic we first showed. SK takes a permission fault, recognizes the permitted recovery case, and returns CapInvalid to the caller. We were wrong to describe that path as trusting any writable Exclave pointer.
The narrower roottask case
The unchecked type-table lookup from the first article is still present. The question is whether a caller can put chosen bytes somewhere that passes the initial probe.
An ordinary page that remains readable or writable at GL0 does not do that. But roottask is involved in constructing the Exclave environment and, during the bootstrap stage we examined, already owns frame capabilities with stronger permissions. This gives us a different sequence to test: fill a writable page first, remove GL0 access using the real frame protection operation, then invoke the forged capability after the bytes have become inaccessible to GL0.
We ran the same permission request from both callers. The two results show exactly where the stronger authority matters:
| Caller | Effective frame rights | Protect(3) | Effective access | SK pointer probe | Final result |
|---|---|---|---|---|---|
| schedulerAddrSpace | 1 | Error 7 ArgumentInvalid |
GL0: read/write GL1 + PAN: denied SPRR index 7 unchanged |
Permission fault0x9600000f
|
Error 4 CapInvalid |
| roottask during bootstrap | 3 | Success | GL0: no access GL1 + PAN: read/write SPRR index 5 |
Succeeds | Type 36 reaches the dispatch; SK panics |
On the successful roottask path, the original SK changed the mapping from SPRR index 7 to index 5 while preserving the two observed header words. We did not manufacture a new capability or directly edit a PTE to obtain that result.
This time the native trace shows the initial probe succeeding. Type 36 reaches the original table calculation:
type table base: 0x13758 selected type: 36 entry stride: 0x58 invoke-field offset:0x28 selected word: *(uint64_t *)(0x13758 + 36 * 0x58 + 0x28) = *(uint64_t *)0x143e0 = 0x3730393a632e6b73
The indirect branch attempts to execute that word, which comes from the original firmware data beyond the table. The processor raises a PC alignment fault, ESR=0x8a000000, and SK forwards its normal exception.c:35 panic request. PAN and SPRR remained active throughout. Roottask's existing frame rights are what made the probe succeed.
The roottask recording reaches the original indirect branch at record 567. The following invalid instruction address is retained as a gap; the separately recorded exception establishes the PC alignment fault and SK panic.
The recordings are useful to inspect side by side. With the two saved trace sessions in a local captures directory, the normal viewer workflow is:
open -a "GLX Trace Explorer" ./captures/pan-rejection/session.json open -a "GLX Trace Explorer" ./captures/roottask-protect/session.json
In the first recording, jump to instruction 76, then follow the exception return at 229 and the selection of error 4 at 235. In the roottask recording, compare the successful probe at 554 with the table calculation at 564 and the indirect branch at 567. The Source context and System register views keep the original instructions and the recorded GL1 exception state available while moving through those steps.
We then checked who else had those rights. A census of the fully booted Duo found no currently mapped Exclave capability with the required effective frame rights and no Untyped capability that could create such a frame. The strong roottask capabilities observed during early bootstrap survived only as unmapped, inaccessible entries. We also followed all ten connected ConclaveLaunch endpoints and their 23 configured ExclaveKit address spaces. None of the newly launched tasks acquired the required authority.
This explains why placing the fake object on an ordinary Exclave's stack does not work. A future capability transfer or a separate vulnerability could change which rights a caller has, but we have not demonstrated such a path. The roottask replay uses GLx Runner's documented compatibility rewrites for unsupported CPA/PAuth_LR instructions. It reaches the permission transition and SK panic; we have not turned it into arbitrary SK code execution.
How GLx Runner changed
GLx Runner has grown considerably since the first article. It now has multicore bring-up and debugging, native guarded execution where the host and target support it, instruction traces tied to original code, code coverage, and more accurate permission and exception handling. For this replay, we checked PAN and SPRR with actual memory accesses as well as register readback.
The GLx Research Platform now includes SK Core Explorer and GLx Trace Explorer. We can keep the original instruction, executed instruction, saved exception registers, and captured memory together while debugging. We also record which instructions ran natively and which needed compatibility handling.
We have also used real exploits developed with GLx Runner to execute our own code inside Exclaves on actual devices and return the result to us. Those tests let us check SK from the Exclave side, independently of GLx Runner's model.
GLx Runner has since helped us find and exploit real Exclave vulnerabilities, including heap corruptions, lifetime bugs, and controlled read/write primitives. Each has its own version, permission, PAC, and MTE constraints. Leaving the old article uncorrected would not help anyone understand them.
A fake capability in ordinary writable GL0 memory is rejected with error 4. Roottask can reach the unchecked dispatch only after using its existing authority to change the page's effective permissions. We should have made that distinction in February.
Stefan Esser