System Internals

Apple internals #9: SPTM, TXM and memory tagging

Page-table writes and code-signing decisions moved out of the kernel's privilege level. A kernel with arbitrary read and write has to ask two monitors instead, and memory tagging depends on them.

The previous post ended on a branch the attacker no longer controls. Suppose you get it anyway. You have arbitrary read and write at EL1, the exception level the kernel runs at, and a way to call kernel code with arguments you choose. On an iPhone 7 that was enough: patch the credentials, mark a page executable, load your own binary, done.

Two of those three steps are no longer performed by the kernel. Page-table writes and code-signature decisions were moved out of EL1 into separate signed binaries that run at a privilege XNU cannot enter except through one instruction, with a selector in a register. The kernel became a client of the thing that used to be part of it.

That switch is recent enough that both designs are still shipping, on different silicon, from the same source tree. It is also old enough that the laptop I am writing this on runs the new one, which is where the hands-on comes from.

Everything here is public: Apple’s own platform documentation, open-source XNU, and published research. The hands-on reads files that already ship on a stock Mac, with nothing downloaded, nothing patched and nothing disabled. No exploit and no bypass chain appear below.

Changing permissions without touching the page tables

Making a page read-only is not one write. It is a page-table entry update, a TLB (translation lookaside buffer) invalidation, barriers, and on a multi-core system a coordination with every other core that might hold the stale translation. For anything that flips permissions often, a JIT compiler above all, that cost is the whole problem.

Apple’s answer arrived with the A11 and S3, and Apple names it in its own platform documentation: Fast Permission Restrictions, a CPU register that restricts permissions per thread without a page-table walk or a flush. The registers behind it are the ones the research community had already reverse-engineered under the name APRR.

The M1 generation shipped a cleaner version of the same idea, SPRR, for Shadow Permission Remap Register, and Sven Peter documented how it works. Four bits of a page-table entry, AP[1], AP[0], UXN and PXN, stop encoding permissions directly. They are concatenated into a four-bit index, and the actual permissions live in a system register, sixteen entries of four bits each, one register per privilege level. The page-table entry names the row to read.

Two consequences fall out. Changing what an entire class of pages means costs one register write instead of a walk over every entry. And the same page-table entry means different things to different privilege levels, which is what makes PPL and SPTM possible. Sven Peter’s table from the M1 carries both halves. The first two permission columns are what those four bits would mean to a stock ARMv8 MMU; the last three are what SPRR actually grants. His kernel column reads EL2 rather than EL1 because the M1 runs XNU with Virtualization Host Extensions, which lets a kernel sit at EL2 and still behave as though it were at EL1.

SPRR index plain EL0 plain EL2 SPRR EL0 SPRR EL2 SPRR GL2 used for
1 --x rw- --- r-- rw- page tables
3 --- rw- --- rw- rw- kernel data
5 rw- rwx rw- or r-x r-- --- userland MAP_JIT
8 --x r-x --- r-- r-x PPL code
10 --- r-x --- r-x r-x kernel code

Read row 5 across. Those four bits, on a stock MMU, hand the kernel rwx on a MAP_JIT page: writable and executable at the same time. Under SPRR the same entry gives userland rw- or r-x depending on which way the per-thread toggle is flipped, gives the kernel read and nothing else, and gives GL2 no access at all. The bits sitting in the page table are identical in both halves of that row. Only the register changed.

Row 1 is the other one worth reading across. A page table is readable to the kernel and writable only from GL2, a privilege level introduced in the GXF section below. Two of the sixteen register values are special cases, where the GL bits change what the EL bits mean. 0111 would be EL2 rw- with GL2 r-x if the two halves were read independently; it decodes to EL2 --- instead, so no page is ever writable from the kernel and executable from GL2, and the kernel cannot modify the code that runs at GL2. 1001 is the smaller one: EL2 r-x becomes --x when the page is only readable from GL2.

PPL: page tables the kernel cannot write

The Page Protection Layer, PPL, is what Apple built on top of that primitive. Page tables and the structures backing code signing are mapped read-only to the kernel. A small body of code is allowed to write them, and it lives in its own segments, __PPLTEXT and __PPLDATA, reached through a trampoline that flips the permission register on entry and flips it back on exit. Everything else in the kernel, including a compromised everything else, sees read-only memory. That is the APRR-era mechanism. On the A14 and the base M1 the flip moved into hardware: the trampoline enters GL2 with GENTER, the instruction the next section takes apart, which is why the M1 kernel in the hands-on below carries six GENTER sites.

The attack surface that remains is the list of routines the trampoline will dispatch to, and XNU’s open source publishes it: ppl_handler_table in osfmk/arm/pmap/pmap.c for the mapping side, with the entry and exit helpers next door in pmap_ppl_interface.c, and the pmap_cs_* family for code signing. bsd/kern/code_signing/ppl.c is the shim above them.

PPL removed two specific steps from an exploit. A kernel read and write no longer lets you mark a page of your own executable, because the page-table entry that would say so is not writable from EL1. It no longer lets you register a code signature the kernel will trust, because the structure holding it is not writable either. Both of those became “find a PPL routine that can be made to do it for you”.

PPL shipped on the A11 and S3, and Apple’s compatibility table runs it through the A14 and the M1.

GXF, SPTM and TXM

The replacement takes that hardware further. GXF, the Guarded Execution Feature, adds a set of levels lateral to the architectural exception levels, called guarded levels: GL0, GL1, GL2. They are entered with an Apple-proprietary instruction, GENTER, opcode 0x00201420, and left with GEXIT, opcode 0x00201400. They have their own copies of the registers a privilege level needs, including the vector base and the exception link register, and their permissions come from the SPRR columns above.

SPTM, the Secure Page Table Monitor, runs in GL2. It is a separate binary, signed and loaded alongside the kernel, and it is the only software on the system that writes a page-table entry. XNU still computes the mapping it needs and still builds the entry; it then hands it over:

sptm_return_t sptm_map_page(pmap_paddr_t ttep, vm_address_t va, pt_entry_t new_pte)

ttep is the physical address of the root translation table of the address space being modified, va the virtual address, new_pte the entry XNU computed. In XNU’s own tree the call sits at the bottom of the ordinary mapping path, pmap_enter_options into pmap_enter_pte into sptm_map_page, in osfmk/arm64/sptm/pmap/pmap.c. That directory is the second pmap implementation in the tree; the PPL one is still there, at osfmk/arm/pmap/.

SPTM decides whether to honour the request using a type it keeps for every managed physical frame, named after what the frame is for: XNU_DEFAULT for ordinary kernel memory, XNU_PAGE_TABLE, XNU_USER_EXEC, XNU_USER_JIT, XNU_ROZONE for the read-only zones of the zone allocator post, SPTM_XNU_CODE for the kernel’s own text. Changing a frame’s type is a second call, sptm_retype(), taking the current type, the new type, and parameters specific to the transition.

Types belong to domains, and this is the part that does the real work. Steffin and Classen read them out of Apple’s own sptm_common.h, which ships in the macOS SDK: SPTM_DOMAIN, XNU_DOMAIN, TXM_DOMAIN, SK_DOMAIN, and XNU_HIB_DOMAIN for hibernation. Retyping is scoped to stay inside one domain apart from a small set of allowed transitions, and mapping rules are written per type, so a frame belonging to another domain is not something XNU can map, whatever it does to its own page tables.

TXM, the Trusted Execution Monitor, runs in GL0, and owns code signing, entitlements, trust caches and provisioning profiles. XNU calls it through SPTM, and the call looks like a system call rather than a function call:

txm_enter(parameters->selector, &txm_registers);

That is from bsd/kern/code_signing/txm.c, in txm_kernel_call_internal. The kernel takes one of a fixed set of per-CPU thread stacks, puts its physical address in x0, marshals the arguments into registers, and enters. Forty distinct selector names appear in that file, from kTXMKernelSelectorRegisterCodeSignature through kTXMKernelSelectorEnterLockdownMode. The return value comes back through a shared context page, and XNU panics if the monitor claims to return more words than the stack can hold.

GL1 holds the Secure Kernel, an seL4-style microkernel that serves Exclaves: scoped groupings of resources the kernel can invoke but not map, with their own IPC mechanism, Tightbeam. They deserve a post of their own, and the paper in the sources covers them at length.

What EL1 no longer owns

The cleanest evidence for how much moved is XNU’s own directory listing. bsd/kern/code_signing/ contains three files:

file who enforces on what
xnu.c the kernel itself platforms with no monitor
ppl.c PPL, through pmap_cs_* A11 to A14, M1
txm.c TXM, through txm_enter A15 and later, M2 and later

Two of the three implement the same API. ppl.c and txm.c both provide register_code_signature, verify_code_signature, associate_jit_region, toggle_developer_mode and enter_lockdown_mode: the same operations, with the enforcement in a different place on each generation of hardware. Four of those five are declared inside #if CODE_SIGNING_MONITOR in bsd/sys/code_signing_internal.h, so xnu.c only implements toggle_developer_mode, next to the local signing key and a comment saying that without a monitor a kernel memory exploit will be able to corrupt code signing state.

So what does an arbitrary kernel write still reach? Everything typed XNU_DEFAULT, which is most of the kernel heap and therefore most of what the previous two posts were about. What it does not reach: page tables, the read-only zones holding credentials and MACF (Mandatory Access Control Framework) labels, TXM’s slabs holding trust caches and code signatures, SPTM’s frame table, and anything in the Secure Kernel’s domain. Retyping one of your own pages into something executable is a request, checked against a rule set that lives in a binary you cannot modify.

The practical consequence for a jailbreak is that the two classic endgames are closed by construction rather than by a check you can skip. You cannot add a cdhash, the hash of a binary’s code directory, to the trust cache, because the trust cache is in a TXM frame. You cannot map your own code executable, because the mapping is a call into GL2 and GL2 validates the frame type first.

Hands-on: finding the monitors on your own Mac

None of this needs a device, a debugger, or a download. On an Apple silicon Mac the monitors ship as their own Image4 payloads in the Preboot volume, next to the kernelcache, and sudo is enough to read them. The machine is a Mac14,9, an M2 Pro, running macOS 26.4.1 (build 25E253) with System Integrity Protection on.

sudo ls -1 /System/Volumes/Preboot/*/restore-staged/Firmware/ | grep -E 'sptm|txm'
sptm.t6000.release.im4p
sptm.t6020.release.im4p
sptm.t6030.release.im4p
sptm.t6031.release.im4p
sptm.t6041.release.im4p
sptm.t6050.release.im4p
sptm.t8112.release.im4p
sptm.t8122.release.im4p
sptm.t8132.release.im4p
sptm.t8140.release.im4p
sptm.t8142.release.im4p
txm.macosx.release.im4p

One SPTM per SoC, and t6020 is this machine’s. One TXM for macOS, because TXM is built once for the platform while SPTM is tied to the silicon it programs. Note t6000, which is the M1 Pro, in a set of monitors that Apple’s compatibility table gives to the A15 and later and the M2 and later. Hold on to that; the last block of this hands-on settles what it means.

Copy the two that apply to this machine somewhere writable, read the header of one, and unpack both. pyimg4 is the same tool the chain of trust post used on a kernelcache:

mkdir -p /tmp/mon && cd /tmp/mon
P=/System/Volumes/Preboot/*/restore-staged/Firmware
sudo cp $P/sptm.t6020.release.im4p $P/txm.macosx.release.im4p .
sudo chown "$(whoami)" sptm.t6020.release.im4p txm.macosx.release.im4p
pyimg4 im4p info -i sptm.t6020.release.im4p
pyimg4 im4p extract -i sptm.t6020.release.im4p -o sptm.raw
pyimg4 im4p extract -i txm.macosx.release.im4p -o txm.raw
file sptm.raw txm.raw
Reading sptm.t6020.release.im4p...
Image4 payload info:
  FourCC: sptm
  Description: 1
  Data size: 182.65KB
  Data compression type: LZFSE
  Data size (uncompressed): 1261.6KB
  Encrypted: False

  Properties (13): kcep, kclf, kclo, kclz, kcmf, kcmz, kcrf, kcrz, kcuu, kcwf, kcwz, kcxf, kcxz
Reading sptm.t6020.release.im4p...
[NOTE] Image4 payload data is LZFSE compressed, decompressing...
Extracted Image4 payload data to: sptm.raw
Reading txm.macosx.release.im4p...
[NOTE] Image4 payload data is LZFSE compressed, decompressing...
Extracted Image4 payload data to: txm.raw
sptm.raw: Mach-O 64-bit executable arm64e
txm.raw:  Mach-O 64-bit executable arm64e

An Image4 payload with its own four-character code, sptm, unencrypted and LZFSE-compressed. TXM’s header reads the same way with the code trxm, 168 KB compressed to 475 KB. SPTM runs before XNU does: the bootstrap arguments the kernel reads at startup come from SPTM, declared in osfmk/arm64/sptm/sptm.h as SPTMArgs.

Both are also ordinary arm64e Mach-O executables, and small: 1261 KB for SPTM, 475 KB for TXM. The kernelcache they arbitrate for unpacks to 118 MB, as the next block shows, ninety times the size of SPTM. Moving code out of XNU buys nothing if the destination is as large as XNU.

The kernelcache that actually booted sits a few directories away, under boot/<hash>/System/Library/Caches/com.apple.kernelcaches/, and unlike the monitors it is a full Image4 file rather than a bare payload, so it carries a manifest:

K=$(sudo ls -1t /System/Volumes/Preboot/*/boot/*/System/Library/Caches/com.apple.kernelcaches/kernelcache | head -1)
sudo cp "$K" kernelcache.boot && sudo chown "$(whoami)" kernelcache.boot
pyimg4 img4 info -i kernelcache.boot
Reading kernelcache.boot...
Image4 info:
  Image4 payload info:
    FourCC: krnl
    Description: KernelManagement_host-487.100.11
    Data size: 31820.48KB
    Data compression type: LZFSE
    Data size (uncompressed): 118833.15KB
    Encrypted: False

  Image4 manifest info:
    Device Processor: T6020
    ECID (hex): 0x<this machine's ECID>
    ApNonce (hex): <nonce>
    SepNonce (hex): <nonce>
    Manifest images (33): anef, aopf, avef, bstc, csys, dcp2, dtre, gfxf, ibdt,
      ibec, ibot, ipdf, ispf, isys, krnl, msys, mtfw, mtpf, pmpf, rdc2, rdsk,
      rdtr, rkrn, rlgo, rosi, rspt, rtrx, rtsc, siof, sptm, strc, trst, trxm

Read the last line. sptm and trxm are in the manifest, next to krnl, ibot and ibec, tied to this machine’s ECID and this boot’s nonce. The monitors are separate images in the personalised boot set, measured and approved by the same chain of trust this series opened with, now carrying two more objects.

That brings back the question the first block left open. macOS ships one kernel per SoC as a plain Mach-O in /System/Library/Kernels/, so both designs sit on the same disk. Segments first:

cd /System/Library/Kernels
for k in t8103 t6000 t6020 t8142; do
  printf '%-8s ' $k
  otool -l kernel.release.$k | awk '/segname/{print $2}' | sort -u | grep -E 'PPL|SPTM|BOOT_EXEC' | tr '\n' ' '
  echo
done
t8103    __PPLDATA __PPLDATA_CONST __PPLTEXT
t6000    __DATA_SPTM __TEXT_BOOT_EXEC
t6020    __DATA_SPTM __TEXT_BOOT_EXEC
t8142    __DATA_SPTM __TEXT_BOOT_EXEC

t8103 is the M1, t6000 the M1 Pro, t6020 the M2 Pro, t8142 the M5. The M1 kernel carries PPL’s own segments and no SPTM data segment. Every other kernel on this disk is the other build.

Counting GENTER says the same thing from the instruction side. The opcode is 0x00201420, so it is four bytes at a four-byte alignment:

python3 - <<'EOF'
for k in ('t8103', 't6000', 't6020', 't8142'):
    d = open(f'/System/Library/Kernels/kernel.release.{k}', 'rb').read()
    g = sum(1 for i in range(0, len(d) - 3, 4) if d[i:i + 4] == b'\x20\x14\x20\x00')
    print(f'{k}: genter={g}  size={len(d)}')
EOF
t8103: genter=6  size=16908904
t6000: genter=151  size=16676856
t6020: genter=152  size=16579928
t8142: genter=151  size=17095896

Six is a set of trampolines, which is what PPL is. A hundred and fifty is an interface. So the M1 Pro is on the SPTM side, and Apple’s table, which lists the M1 under PPL, is accurate for the chip it names and for nothing above it: on macOS 26.4.1 the only PPL kernel Apple ships for Apple silicon is the one built for t8103.

Those sites are not scattered through the kernel either. Disassemble the kernel and sort every gate by what it writes into x16 before the transition:

otool -xv /System/Library/Kernels/kernel.release.t6020 > /tmp/kern.dis

awk '$0 ~ /(mov|movk|movz)[ \t]+[wx]16,/ {last=$0}
     $0 ~ /\.long[ \t]+0x00201420/ {print last}' /tmp/kern.dis > /tmp/x16.txt

awk '{ if ($0 ~ /lsl #48/) k="domain, bits 48-55";
       else if ($0 ~ /lsl #32/) k="dispatch table, bits 32-39";
       else k="selector only";
       c[k]++ } END { for (i in c) printf "%-28s %3d\n", i, c[i] }' /tmp/x16.txt
dispatch table, bits 32-39    98
selector only                 50
domain, bits 48-55             2

Two gates name a domain, ninety-eight name a dispatch table, and fifty carry a selector and nothing else. That is 150 against the 152 the script counted, because two of the byte matches are data rather than code. The three groups are the three fields of sptm_common.h, read off the binary instead of off the header. The fifty are not scattered either:

grep -v lsl /tmp/x16.txt | sort | head -5
fffffe0007c11394    mov    x16, #0x0
fffffe0007c113bc    mov    x16, #0x1
fffffe0007c113e4    mov    x16, #0x2
fffffe0007c1140c    mov    x16, #0x3
fffffe0007c11434    mov    x16, #0x4

Forty bytes apart, one selector at a time: a generated table of entry points. Apple’s own disassembler will show you one of the gates, and will stop at the instruction it does not know:

grep -B8 -A1 'fffffe0007c1092c' /tmp/kern.dis
fffffe0007c1090c    pacibsp
fffffe0007c10910    mov    w16, w0
fffffe0007c10914    movk    x16, #0x3, lsl #48
fffffe0007c10918    mov    x10, x1
fffffe0007c1091c    ldp    x0, x1, [x10]
fffffe0007c10920    ldp    x2, x3, [x10, #0x10]
fffffe0007c10924    ldp    x4, x5, [x10, #0x20]
fffffe0007c10928    ldp    x6, x7, [x10, #0x30]
fffffe0007c1092c    .long    0x00201420
fffffe0007c10930    retab

That is the whole interface in ten instructions. w0 is the selector the caller asked for and it goes into x16; movk puts 3 into bits 48 to 55 of the same register, which Apple’s sptm_common.h gives to the domain field, and domain 3 is SK_DOMAIN; eight arguments are loaded from a structure the caller passed in x1; and then the transition, which otool prints as .long 0x00201420 because genter has no mnemonic in Apple’s own tooling. The retab on the way out is the return-address authentication from the previous post: a gate into the most privileged software on the machine is still an ordinary arm64e function.

The minimal form is 0xa78 bytes further on:

grep -B5 -A2 'fffffe0007c11398' /tmp/kern.dis
fffffe0007c11384    pacibsp
fffffe0007c11388    stp    x29, x30, [sp, #-0x10]!
fffffe0007c1138c    mov    x29, sp
fffffe0007c11390    bl    0xfffffe00072ddf38
fffffe0007c11394    mov    x16, #0x0
fffffe0007c11398    .long    0x00201420
fffffe0007c1139c    bl    0xfffffe00072ddfa4
fffffe0007c113a0    mov    sp, x29

A call value of zero, no arguments, and a bl on each side of the transition. XNU’s open source brackets its monitor entries the same way, with recount_enter_secure() before and recount_leave_secure() after, so that time spent inside the monitor is accounted separately from time spent in the kernel.

Ghidra makes the same point more bluntly:

Ghidra disassembling the same gate: pacibsp, the frame setup, the bl and the x16 load decode normally, then the bytes 20 14 20 00 are marked as undefined and every byte after them stays undefined to the end of the function

It decodes the prologue and the selector, stops on the same four bytes, and never recovers its alignment, so the rest of the gate is a column of undefined bytes. The epilogue is still in that column if you decode it by hand: 02 33 db 97 is the second bl, bf 03 00 91 is mov sp, x29, fd 7b c1 a8 is ldp x29, x30, [sp], #0x10, and ff 0f 5f d6 is retab. The instruction sitting between them is the one that moves this machine into its most privileged execution level.

The kernel also reports what it is not running. Exclaves have two sysctls, and on this Mac they both say the same thing:

sysctl -a 2>/dev/null | grep -iE 'sptm|txm|exclave'
kern.exclaves_status: 255
kern.exclaves_boot_stage: -1

Both values are named in osfmk/mach/exclaves.h: EXCLAVES_STATUS_NOT_SUPPORTED is 0xFF, and EXCLAVES_BOOT_STAGE_NONE is ~0u, which the sysctl prints as a signed -1. So this kernel reports exclaves as unsupported and never booted. That pair does not say whether the code is in the build at all: both sysctls are registered in bsd/kern/kern_sysctl.c outside every #if CONFIG_EXCLAVES guard, and the #else arm of osfmk/kern/exclaves_boot.c returns exactly those two values. kern.exclaves_relaxed_requirements sits inside the guard and is missing from the same output, which points at a kernel built without CONFIG_EXCLAVES. Nothing else matches, because txm.c exposes TXM’s allocator metrics only under #if DEVELOPMENT || DEBUG, and this is a release kernel.

What is left to attack

The interface is the attack surface, and both interfaces are small enough to enumerate. On the SPTM side that means the arguments of every dispatched function, coming from a caller the design assumes may be compromised: a physical address that must be inside the managed range, a type that must be a real type, a transition that must be in the allowed set, a frame that must not already be mid-retype. Steffin and Classen walk that validation chain in the binary, and the interesting reading is where a check is per-type rather than global.

On the TXM side, XNU passes the physical address of a thread stack it owns and reads results back from a shared context page. Both are memory the kernel allocated, which makes the boundary between “argument the monitor validates” and “structure the monitor trusts” the thing worth mapping. Forty-six selectors is the whole API: forty in txm.c, and six more in bsd/kern/kern_trustcache.c for loading and querying trust caches.

XNU’s own domain is untouched by all of it: the data-only techniques of the zone allocator post still work on anything typed XNU_DEFAULT, and the difference is that the chain ends there instead of continuing into the page tables.

Two limits on the public record are worth stating. Steffin and Classen call their own paper an architectural overview rather than a security evaluation, with significant parts of the remapping logic left unread. And the monitors keep changing, so anything written about a specific dispatch table or a selector number is a snapshot of one firmware.

Memory tagging, always on

Memory Integrity Enforcement, MIE, announced in September 2025 with the A19 and A19 Pro in the iPhone 17 and iPhone Air, and shipping on the M5, is three things at once in Apple’s own description. Typed allocators, met earlier in the series as kalloc_type in iOS 15, with a userland counterpart, xzone malloc, since iOS 17. The Enhanced Memory Tagging Extension, EMTE, in synchronous mode. And a set of policies Apple calls tag confidentiality enforcement.

Allocators can only protect at page granularity, so they separate type buckets from one another, and tagging covers what happens inside a bucket. Neighbouring allocations get different tags, so a linear overflow faults at the instruction that commits it. Freed memory is retagged before reuse, so a stale pointer faults on first access. Apple’s blog says asynchronous reporting leaves a race window open for an attacker, and that they would not ship it.

Tags are a secret, and a large part of the design goes into keeping them one. Tag values are specified not to influence speculative execution, which is exactly what TikTag and StickyTags exploit on Pixel hardware, both named in Apple’s post. The generator that picks tags is frequently reseeded. And Spectre V1, which would otherwise leak tags through conditional branches, gets a mitigation that Apple describes as forcing an attacker to chain 25 or more V1 sequences for a high exploitability rate.

One sentence in Apple’s announcement ties memory tagging back to SPTM: the kernel allocator’s backing store and the tag storage itself are protected by the Secure Page Table Monitor. The paper’s frame-type table has the matching entry, XNU_TAG_STORAGE, owned by the SPTM domain rather than XNU’s. Memory tagging on this platform is built on the monitor, and a kernel compromise does not reach the tags.

The Mac used above is a generation too early to show any of it:

sysctl hw.optional.arm | grep -iE 'mte|tag'
hw.optional.arm.FEAT_MTE: 0
hw.optional.arm.FEAT_MTE2: 0
hw.optional.arm.FEAT_MTE3: 0
hw.optional.arm.FEAT_MTE4: 0
hw.optional.arm.FEAT_MTE_ASYNC: 0
hw.optional.arm.FEAT_MTE_STORE_ONLY: 0
hw.optional.arm.FEAT_MTE_CANONICAL_TAGS: 0
hw.optional.arm.FEAT_MTE_NO_ADDRESS_TAGS: 0

Apple names the class that survives, and calls it rare: corruption that stays inside one correctly tagged allocation. Add the classes tagging was never aimed at, races and uninitialised reads and confusion between two objects sharing a bucket, and that is what is left. Calif.io’s May 2026 write-up is the public data point so far: a local privilege escalation on M5 hardware with kernel MIE on, put together in five days once the bugs were in hand.

State in 2026

Fast Permission Restrictions (A11 and S3 and later, from iOS 11). The primitive under everything above. Apple’s documentation names it; the research community’s name for the registers is APRR, and its successor is SPRR.

PPL (A11 and S3 through A14, and the base M1). Page tables writable only from a trampolined context on every platform that runs it, and the code-signing structures too on iOS, iPadOS, visionOS and watchOS. Apple leaves macOS out of that second half, because macOS is designed to run arbitrary code. Still shipping: on macOS 26.4.1 the kernel built for t8103 is the only PPL build left in /System/Library/Kernels/.

SPTM and TXM (A15 upwards, M2 upwards, and every Apple silicon Mac except the base M1, since iOS 17 and macOS 14). Page tables in GL2, code signing in GL0, both in binaries outside the kernel. A kernel read and write reaches neither.

MIE (A19 and A19 Pro from iOS 26, the M5 from macOS 26 and iPadOS 26). Synchronous EMTE over the typed allocators, covering the kernel and more than seventy userland processes, with the tag storage itself protected by SPTM.

Where this leaves us

Every mitigation in the last three posts has the same shape once you line them up. None of them fixes a bug. kalloc_type removed the attacker’s choice of neighbour, pointer authentication removed the value of the pointer, and SPTM removes the two structures the write was aimed at. All three raise the number of separate, working primitives a chain needs before it produces anything. That is the whole strategy, and it shows in the two public chains this series has walked: kfd, which keeps a physical page rather than competing for a virtual one, and Calif.io’s on M5, which its authors describe as data-only.

Everything above describes how the monitors work and what they refuse. None of it says what a bug in one would be worth, and for the moment there is no public body of work to measure that against. The next post goes back down to userland, where the objects a reverser actually meets live: the Objective-C runtime, the isa pointer, and the dyld shared cache, which is why the system libraries are no longer on the disk as separate files.

Notes and sources

Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.

Work like this is what we do under engagement.

[email protected] Vulnerability Research

No spam, one click to unsubscribe. Privacy.