Skip to main content
Blog

A Second Look at Apple’s Secure Kernel

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.

GLx Trace Explorer showing the original SK pointer probe and its recorded PAN permission fault.

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.

GLx Trace Explorer showing the actual saved-PC increment in SK's data-abort recovery handler.

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.

The two forged-capability paths A writable GL0 page fails the privileged pointer probe and returns CapInvalid. Roottask can use an existing stronger frame capability to remove GL0 access, after which the probe succeeds and the unchecked type dispatch is reached.ORDINARY EXCLAVEWrite fake headerPage remains accessible at GL0PAN rejects SK's loadRecoverable permission faultReturn error 4No type table dispatchROOTTASK WITH EXISTING FRAME RIGHTS 3Write, then Protect(3)GL0 access is removedSK's probe succeedsGL1 can still read the pageUnchecked dispatchObserved SK panic
The difference is the effective permission of the page at the time SK reads it, and the caller's authority to change that permission.

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:

Results from the paired Duo 27.1 native permission-transition tests. Access permissions are those after the attempted Protect call, with PAN still set.
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 fault
0x9600000f
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.

GLx Trace Explorer showing the privileged roottask replay reaching the original indirect type-table dispatch.

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