Looking at kernel MTE panics on apple silicon
Stefan EsserSecurity Researcher
Binary Gecko GmbH
May 31, 2026

Intro
At OffensiveCon 2026 Patrick Ventuzelo and Atlan Pinabel from FuzzingLabs gave an excellent talk called "Navigating the MTE Landscape: iOS Memory Protection Deep Dive". If you have not looked at it yet, you should. The video recording is available here, and the slides are available from the FuzzingLabs site as Navigating iOS MTE Landscape.
The talk gives a good overview of Apple's Memory Integrity Enforcement, how ARM MTE works, how it is integrated into XNU, and how the kernel and userland allocators start to use it. It also gives a good feeling for how much work Apple has put into making the old style of heap exploitation less comfortable.
However, after reading the slides there was one very practical question left open for me:
What does it actually look like when the kernel itself hits an MTE tag violation?
This blog post is about that small missing piece. It is not meant as a replacement for the FuzzingLabs material. It is more of a companion note showing what happens on a real MTE-capable MacBook when we intentionally trigger tag check failures in kernel land, and what kind of tooling makes the resulting panic output more useful.
The code used for this post consists of these pieces:
mtetoybox.zip- a small kernel extension exposing a few MTE operations through a MAC policy syscallmtetoyctl.c- the userland command line tool talking to that MAC policy syscallmtepanichelper.zip- the helper kernel extension that is hooked into the kernel panic pathmte_apply_directpanic_hook.pyandmacho_fileset_branch_v3.py- the scripts used to locate and apply the branch patchprepare.shandconfigure-boot.sh- the scripts I used to create the test kernel collection and configure booting it from recovery
All of this is research code. It intentionally crashes the kernel. Do not run it on a machine you care about.
MTE in one paragraph
ARM MTE associates a 4-bit memory tag with every 16-byte granule of memory. A pointer can carry a tag in the top byte. On a tagged memory access the CPU compares the tag in the pointer with the tag stored for the memory granule. If they do not match, the CPU raises a tag check fault. In userland this may become a signal or a soft failure depending on the mode. In the kernel, when we force a synchronous tag check failure, we get a kernel panic.
That is the high level view. The more interesting part, at least for debugging kernel memory safety problems, is what information survives into the panic report.
A normal kernel MTE panic
The first thing we want to know is what Apple gives us by default. For that we need a way to make the kernel perform a tagged load or store through a pointer whose tag does not match the memory tag.
The result is a normal panic. The machine tells us that something went wrong, but the output is not exactly what one would want while debugging a memory tagging failure.
![]()
The important detail here is that the CPU knows the faulting virtual address and the exception syndrome, but the panic output does not directly give us the information that we would really like to see:
- Was this a read or a write?
- How large was the access?
- What was the pointer tag?
- What was the memory tag?
- What do the neighbouring memory tags look like?
This is the kind of information one gets used to from KASAN-style reports. For MTE it is also exactly the information that helps to immediately understand whether we are looking at a use-after-free, an out-of-bounds access, a retagging issue, or just a bad experiment.
MTETOYBOX: a small kernel side MTE test bench
To trigger these panics in a controlled way I wrote a tiny kernel extension called mtetoybox. It registers a MAC policy named MTE and implements the policy syscall callback. This is a convenient old trick for exposing a small private kernel interface without adding a new syscall or an IOKit user client.
The kernel extension exports the following operations:
#define MTE_TOYBOX_GETTOYMEM 0
#define MTE_TOYBOX_MALLOC 1
#define MTE_TOYBOX_LDG 2
#define MTE_TOYBOX_STG 3
#define MTE_TOYBOX_IRG 4
#define MTE_TOYBOX_GMI 5
#define MTE_TOYBOX_READ 6
#define MTE_TOYBOX_WRITE 7
The names map almost directly to what the code does:
GETTOYMEMreturns the address of a static kernel buffer inside the kext.MALLOCallocates a kernel buffer with_MALLOC()and returns the kernel address.LDGreads the logical allocation tag for an address.STGstores a tag for an address.IRGinserts a random tag into a pointer.GMIupdates an exclude mask for tag generation.READperforms a 1, 2, 4, 8, or 16 byte kernel read through the supplied tagged pointer.WRITEperforms a 1, 2, 4, 8, or 16 byte kernel write through the supplied tagged pointer.
The small assembly file in the kext is intentionally boring. It only wraps the relevant AArch64 MTE instructions and a few normal loads and stores:
__ldg:
bti c
ldg x0, [x0]
ret
__stg:
bti c
stg x1, [x0]
ret
__irg:
bti c
irg x0, x0, x1
ret
__gmi:
bti c
gmi x0, x0, x1
ret
__read8:
bti c
ldr x0, [x0]
ret
__write8:
bti c
str x1, [x0]
ret
There is no exploit magic here. The whole point is to get a controlled tagged pointer and then deliberately access memory with a wrong tag.
mtetoyctl: driving the kernel toybox from userland
The userland side is mtetoyctl.c. It calls __mac_syscall() with the policy name MTE and passes small command-specific structs to the kernel extension.
It can be built with:
clang -O2 -Wall -Wextra -std=c11 -o mtetoyctl mtetoyctl.c
The tool exposes the same operations as the kernel extension:
sudo ./mtetoyctl gettoymem
sudo ./mtetoyctl malloc 0x1000
sudo ./mtetoyctl ldg 0xfffffe0000000000
sudo ./mtetoyctl stg 0xfffffe0000000000 0xabfffffe0000000000
sudo ./mtetoyctl irg 0xfffffe0000000000 0x0
sudo ./mtetoyctl gmi 0xabfffffe0000000000 0x0
sudo ./mtetoyctl read 8 0xabfffffe0000000000
sudo ./mtetoyctl read 16 0xabfffffe0000000000
sudo ./mtetoyctl write 8 0xabfffffe0000000000 0x1122334455667788
sudo ./mtetoyctl write 16 0xabfffffe0000000000 0x1122334455667788 0x99aabbccddeeff00
The typical workflow is:
- load the
mtetoyboxkext, - ask it for a kernel buffer,
- inspect or change the tag of that buffer,
- manufacture a pointer with a different tag,
- perform a load or store through that pointer,
- enjoy the panic.
The nice thing about doing it this way is that the crash is deterministic. We are not waiting for a real kernel bug to happen. We can generate different access sizes, different tags, and different faulting addresses whenever we want.
For the final test I used a freshly allocated kernel buffer and then changed only the pointer tag for the write:
% ./mtetoyctl malloc 128
rc=0
size = 0x0000000000000080
addr = 0xf2fffe1e466a9080
tag = 0xf2, untagged = 0x00fffe1e466a9080
% ./mtetoyctl write 4 0xf7fffe1e466a9080 0x33445566
The allocation returned a pointer tagged with 0xf2. The second command writes through a pointer to the same untagged address, but with tag 0xf7, which is the point of the test.
The problem with the default panic
The default panic path is not useless. It was just not designed to be an MTE debugging UI.
For a normal data abort, the panic report usually gives us enough information to start looking at the fault. For an MTE tag check fault this is a bit unsatisfying. The interesting part is not only the address. The interesting part is the mismatch between the tag in the pointer and the tag associated with the memory granule.
When looking at allocator hardening this matters a lot. Imagine the faulting address points into a freed zone element. If the tag map around the address shows alternating tags, or a freshly retagged object next to the faulting object, this immediately tells a different story than a single isolated bad pointer. Without the tag context we are left reconstructing this information manually.
Therefore the next obvious step was to replace the panic output with something closer to what KASAN would print.
mtepanichelper: a KASAN-style MTE panic report
The mtepanichelper kext is not another trigger mechanism. Its purpose is to make the panic nicer.
The current version does not try to return into XNU's original strlen() / snprintf() formatter block. That was the important lesson from debugging this. The compiler-generated formatter code uses and overwrites registers that still contain useful fault state at the first strlen(MSG_FMT) call. By the time execution reaches the later _snprintf() call, it is already too late for the helper I wanted to write.
The working design is therefore:
- include
mtepanichelperin the rebuilt kernel collection, - locate the fatal MTE panic formatter block in
com.apple.kernel, - replace the first
strlen(MSG_FMT)BLwith aBLto the helper blob, - let the helper build a replacement message in its own static buffer,
- let the helper find the later
panic_with_thread_kernel_state()call target in the original code, - let the helper call
panic_with_thread_kernel_state(msg, state)directly, - never return into XNU's original formatter block.
The helper first disables tag checking for its own accesses by setting TCO:
_mte_set_tco1:
bti c
msr tco, #1
ret
This is necessary because the panic code itself must be able to inspect memory tags without immediately tripping over the same kind of tag check that caused the panic in the first place.
The helper then extracts the faulting PC from the saved state, reads the instruction at that PC, and makes a small best-effort guess for the access size. It also looks at the ESR to decide whether the fault came from a read or a write.
The most useful part is this:
uint64_t untag = untag_ptr(fault_addr);
uint8_t ptag = ptr_tag4(fault_addr);
uint8_t mtag = ldg_mem_tag4(untag);
This gives the report the two values we actually care about: the pointer tag and the memory tag. Afterwards the helper prints a window of memory tags around the faulting granule. The output is intentionally similar in spirit to KASAN shadow memory dumps. Each printed value is the tag for one 16-byte granule, and the faulting granule is highlighted.
The resulting panic report is much more useful than the stock one. In the tested run below, the report identifies the bad 4-byte write, shows the pointer tag and memory tag, prints the fault PC and ESR, confirms that the helper found the final panic_with_thread_kernel_state() call, and then dumps the surrounding 16-byte memory tags.
![]()
Finding the XNU hook point
The XNU source code for the fatal case looks like this:
/*
* Everything past this point doesn't have a recovery handler and is a fatal violation.
* Package up and report as much useful information as possible.
*/
#define MSG_FMT "Kernel tag check fault (expected tagged address: 0x%016llx)"
char msg[strlen(MSG_FMT)
- strlen("0x%016llx") + strlen("0xFFFFFFFFFFFFFFFF")
+ 1];
vm_address_t expected_tagged_address = vm_memtag_load_tag(fault_addr);
snprintf(msg, sizeof(msg), MSG_FMT, (uint64_t)expected_tagged_address);
panic_with_thread_kernel_state(msg, state);
#undef MSG_FMT
The source string is the natural anchor:
Kernel tag check fault (expected tagged address: 0x%016llx)
On the build I tested, the start of the function stores the original arguments into callee-saved registers like this:
PACIBSP
STP X28, X27, [SP,#-0x50]!
STP X26, X25, [SP,#0x10]
STP X24, X23, [SP,#0x20]
STP X22, X21, [SP,#0x30]
STP X20, X19, [SP,#0x40]
STP X29, X30, [SP,#0x50]
ADD X29, SP, #0x50
SUB SP, SP, #0x10
MOV X22, X4 ; recover
MOV X23, X3 ; thread
MOV X21, X2 ; fault_addr
MOV X24, X1 ; esr
MOV X19, X0 ; state
That register allocation is why the early hook point works. At the first strlen(MSG_FMT) call the values we care about are still available:
x19 = arm_saved_state_t *state
x21 = fault_addr
x24 = esr
The fatal formatter block looked like this after compilation:
ADRL X22, aKernelTagCheck ; "Kernel tag check fault (expected tagged"...
MOV X0, X22
BL _strlen ; this is the hook point
MOV X23, X0
ADRL X0, a0x016llx ; "0x%016llx"
BL _strlen
MOV X24, X0
ADRL X0, a0xffffffffffff ; "0xFFFFFFFFFFFFFFFF"
BL _strlen
SUB X8, X23, X24
ADD X24, X8, X0
...
LDG X21, [X21]
...
MOV X0, X23 ; __str
MOV X2, X22 ; __format
BL _snprintf
ADD SP, SP, #0x10
MOV X0, X23
MOV X1, X19
BL panic_with_thread_kernel_state
The helper is entered through a BL, not a plain B, because it needs the link register. The link register points to the instruction after the replaced _strlen(MSG_FMT) call. The helper scans forward from there until it finds the final panic call sequence:
MOV X0, X23
MOV X1, X19
BL panic_with_thread_kernel_state
It then decodes the BL target and calls the function directly with its own message buffer and the saved state pointer.
The hook blob starts with two 64-bit magic constants followed by the actual bti c entry point:
.section __TEXT_EXEC,__mtehook,regular,pure_instructions
/*
* The final kernel collection builder may merge this custom executable section
* into __TEXT_EXEC,__text. That is fine: the patching tool locates the blob by
* the two magic qwords below and branches to magic+0x10.
*/
.p2align 14
.globl _mte_tagcheck_hook_blob, _mte_tagcheck_hook_main
_mte_tagcheck_hook_blob:
.quad 0x2222D5D21111F5F2
.quad 0x2222D5D21111F5F2
bti c
...
Do not rely on the custom section name surviving in the final kernel collection. In my test output the helper target ended up in __TEXT_EXEC:__text+0x10, not in a visible __mtehook section. This is fine. The script finds the two magic qwords inside com.binarygecko.mtepanichelper and branches to magic+0x10.
Building and patching the kernel collection
The script I used to create the test kernel collection was:
#!/bin/sh
kmutil create -a arm64e -z -V release -n boot \
-B kcwithbinarygeckokexts.kc \
-k /System/Library/Kernels/kernel.release.t8142 \
-r Extensions -r /System/Library/Extensions \
-r /System/Library/DriverExtensions \
-b com.binarygecko.mtetoybox -b com.binarygecko.mtepanichelper -x \
$(kmutil inspect -V release --no-header \
| awk '{print " -b "$1; }')
This is also included as prepare.sh. It expects the Binary Gecko kexts to be available under the local Extensions directory.
After building the collection, the hook is applied with:
python3 mte_apply_directpanic_hook.py patch kcwithbinarygeckokexts.kc \
--output KC_WITH_MTEPANICHELPER_HOOKED
The tested run found one candidate and patched the first strlen(MSG_FMT) call:
Helper hook target:
entry: com.binarygecko.mtepanichelper
magic VA: 0xfffffe000b758000
magic loc: __TEXT_EXEC:__text+0x0
target VA: 0xfffffe000b758010
target loc: __TEXT_EXEC:__text+0x10
Found 1 candidate hook site(s).
Candidate #1
string VA: 0xfffffe000706d6e3
string loc: __TEXT:__cstring+0x27d23
xref kind: ADRP+ADD
xref VA: 0xfffffe000b969948
xref loc: __TEXT_EXEC:__text+0x20b948
MSG_FMT register: x22
MOV X0,MSG reg: yes
patch BL VA: 0xfffffe000b969954
patch BL loc: __TEXT_EXEC:__text+0x20b954
original BL tgt: 0xfffffe000b7648d0 ; should be strlen(MSG_FMT)
helper scan test: ok
pattern loc: __TEXT_EXEC:__text+0x20b9d0
panic BL VA: 0xfffffe000b9699d8
panic target: 0xfffffe000c0f4c10
The important part is not the concrete offsets. They are build-specific. The important part is that the source patch site is the first strlen(MSG_FMT) BL, the target is the helper blob at magic+0x10, and the helper scan test sees the later panic call sequence.
The script prints the raw branch patch command too. For the tested collection it was:
python3 macho_fileset_branch_v3.py patch 'kcwithbinarygeckokexts.kc' \
--link \
--patch 'com.apple.kernel __TEXT_EXEC:__text+0x20b954 com.binarygecko.mtepanichelper __TEXT_EXEC:__text+0x10' \
--output KC_HOOKED
The wrapper then wrote the final patched collection:
Wrote KC_WITH_MTEPANICHELPER_HOOKED
Applied patch:
source entry: com.apple.kernel
source loc: __TEXT_EXEC:__text+0x20b954
source VA: 0xfffffe000b969954
source fileoff: 0x4965954
original insn: 0x97f7ebdf
target entry: com.binarygecko.mtepanichelper
target loc: __TEXT_EXEC:__text+0x10
target VA: 0xfffffe000b758010
new insn: 0x97f7b9af (BL)
The full run output is included as output.txt.
To configure the patched collection from recovery mode, I used:
#!/bin/sh
kmutil configure-boot -v /Volumes/Macintosh\ HD -c KC_WITH_MTEPANICHELPER_HOOKED
This is included as configure-boot.sh.
Things to verify before blaming the helper
There are a few checks worth doing before booting or debugging a broken panic path:
- The patched instruction must be the first
strlen(MSG_FMT)BL, not the later_snprintf()call. - The replacement instruction must be a
BLto the helper target atmagic+0x10. LRin the helper must equal the patchedBLaddress plus four.- The register allocation at the hook point must still be
x19 == state,x21 == fault_addr, andx24 == esr. - The helper scan must find
MOV X0, X23; MOV X1, X19; BL panic_with_thread_kernel_statelater in the same fatal block.
If Apple changes the compiler output, the basic idea still works, but the hook blob or the scanner has to be adjusted. This is why I prefer deriving the hook point from the MSG_FMT xref instead of hardcoding a raw offset in the blog post.
Why this matters
MTE changes the way kernel memory bugs fail. A traditional out-of-bounds access or use-after-free might become a synchronous fault before it turns into a useful primitive. That is obviously good for the platform. But for researchers this also means we need good tooling to understand these failures.
A panic that only says "data abort" is not enough. For MTE we want the panic to tell us the tag mismatch directly. We want to know whether the pointer was stale, whether the memory was retagged, whether the access crosses into a differently tagged granule, and whether the surrounding allocation pattern makes sense.
The small toolbox described here is my attempt to make that kind of experimentation less annoying:
mtetoyboxgives us controlled kernel MTE operations,mtetoyctllets us trigger them from userland,mtepanichelperturns the resulting panic into something closer to a useful bug report.
None of this is polished product code. It is intentionally small research tooling. But it answers the question I had after looking at the OffensiveCon slides: what does an MTE tag violation in the kernel actually look like, and can we make it look better?
The answer is yes. And once the panic contains the pointer tag, the memory tag, and a small tag map around the faulting address, debugging kernel MTE behaviour becomes a lot more pleasant.