Looking at kernel MTE panics on apple silicon

Stefan Esser
Security 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:

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.

Original macOS problem report for an MTE kernel tag check fault

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:

  • GETTOYMEM returns the address of a static kernel buffer inside the kext.
  • MALLOC allocates a kernel buffer with _MALLOC() and returns the kernel address.
  • LDG reads the logical allocation tag for an address.
  • STG stores a tag for an address.
  • IRG inserts a random tag into a pointer.
  • GMI updates an exclude mask for tag generation.
  • READ performs a 1, 2, 4, 8, or 16 byte kernel read through the supplied tagged pointer.
  • WRITE performs 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:

  1. load the mtetoybox kext,
  2. ask it for a kernel buffer,
  3. inspect or change the tag of that buffer,
  4. manufacture a pointer with a different tag,
  5. perform a load or store through that pointer,
  6. 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:

  1. include mtepanichelper in the rebuilt kernel collection,
  2. locate the fatal MTE panic formatter block in com.apple.kernel,
  3. replace the first strlen(MSG_FMT) BL with a BL to the helper blob,
  4. let the helper build a replacement message in its own static buffer,
  5. let the helper find the later panic_with_thread_kernel_state() call target in the original code,
  6. let the helper call panic_with_thread_kernel_state(msg, state) directly,
  7. 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.

Improved macOS problem report with the mtepanichelper tag-check output

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:

  1. The patched instruction must be the first strlen(MSG_FMT) BL, not the later _snprintf() call.
  2. The replacement instruction must be a BL to the helper target at magic+0x10.
  3. LR in the helper must equal the patched BL address plus four.
  4. The register allocation at the hook point must still be x19 == state, x21 == fault_addr, and x24 == esr.
  5. The helper scan must find MOV X0, X23; MOV X1, X19; BL panic_with_thread_kernel_state later 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:

  • mtetoybox gives us controlled kernel MTE operations,
  • mtetoyctl lets us trigger them from userland,
  • mtepanichelper turns 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.

Back to overview