New chip, new catch: Checked pointers on Apple silicon

Here is a function that does nothing unusual:

#include <stddef.h>

int *add_index(int *base, size_t index)
{
    return base + index;
}

Call it with an index inside the array and it returns a pointer to that element. Here is what Xcode 27’s compiler emits for it in two architecture slices, the per-processor builds a Mac binary can carry side by side:

// -arch arm64e
add   x0, x0, x1, lsl #2
ret

// -arch arm64e.x1
addpt x0, x0, x1, lsl #2
ret

Apple clang 21.0.0 (Xcode 27.0, 27A266a), -O2 -mmacosx-version-min=27.0, disassembled with otool -tV. -Os gives the same pair, and -O0 uses the same instructions among its stack loads and stores.

Both shift index left by two, which multiplies it by 4, the size of an int, and add it to base. The second is ADDPT, “add checked pointer”, from Apple’s new arm64e.x1 slice (x1 for short) for M6 Macs and A20 Pro iPhones. When a process has the check switched on, the CPU makes sure the addition leaves the pointer’s top byte alone, because that’s where memory tagging keeps the pointer’s tag. Without it, ADDPT computes the same sum as ADD. This is checked pointer arithmetic (CPA). It protects the tag, and knows nothing about how big an allocation is.

Every listing and result here comes from one M6 Mac mini running macOS 27.0.1 and Xcode 27.0. Everything else comes from Arm’s A64 reference and Apple’s documentation, as of October 2026. We first learned about the slice from Blake Crosley’s write-up, and set out to peel back its layers.

The examples and tests in this post are in apple-cpa.

The address is only part of the pointer

A pointer on 64-bit Arm is 64 bits wide, but a macOS process doesn’t need 64 bits of address. User addresses on arm64 macOS fit in 47 bits (the SDK’s MACH_VM_ADDRESS_MASK is 0x00007FFFFFFFFFFF). The spare bits at the top can carry other information, as long as the CPU knows to look past them when it translates the address.

Tags in the top byte

Arm’s Top Byte Ignore tells the CPU to ignore bits 63 to 56 during address translation. The Memory Tagging Extension (MTE), which Apple builds on for Memory Integrity Enforcement, uses four of those bits, 59 to 56, as the pointer’s tag. Memory carries a 4-bit tag for every 16 bytes. With tag checking on, a load or store compares the pointer’s tag with the memory’s, and a mismatch is a tag check fault. Part 1 of Tagging WebKit covers MTE in more depth.

Pointer authentication (PAC) also stores a signature in a pointer’s upper bits. This post mostly follows tagged pointers without a signature.

When arithmetic changes the tag

Adding an index changes the low bits, and normally that is all it changes. But a 64-bit addition doesn’t stop at bit 55. A carry from bit 55 into bit 56, a borrow the other way, or an offset that itself has upper bits set changes the top byte, and with it the tag. Arithmetic would then give a pointer a new tag without asking the allocator. Apple’s checked pointer arithmetic reference, which ships with Xcode 27 as audit-xcode-security-settings-ref-checked-pointer-arithmetic.md.packaged, says that without the check this overflow “is how an attacker would defeat tagging”.

CPA closes that path with one comparison. After a checked addition, the CPU compares the result’s top byte, bits 63 to 56, with the base pointer’s. If they differ, it doesn’t return the sum. It returns a poisoned pointer, a value deliberately changed so that it can’t be used as an address. Nothing faults until something uses the value as an address. When the fault arrives shows Arm’s pseudocode for it, and exactly which bits change.

Three cases of pointer arithmetic on an int array a of 48 bytes tagged 3, next to a neighbour tagged 7. A: a + 5 stays inside. The top byte stays 03, no poison, and the access passes the tag check. B: a + 12, one past the end, lands in the neighbour. The top byte is still 03, so CPA does nothing, and only the tag check on access catches 3 against 7. C: a pointer f plus an integer d, the distance between two differently tagged allocations, would change the top byte from 03 to 07. The result is poisoned instead, unusable as an address.

The pointer values are examples, worked out with Arm’s specification.

In case B, a + 12 points one past the end, which C allows as long as nobody dereferences it, and it lands in the neighbour. Its top byte is still 03, so CPA does nothing. If the program reads through it, MTE compares tag 3 with the neighbour’s 7 and faults the access. Apple’s reference says that walking into a differently tagged neighbour “poisons the result”. Arm’s specification doesn’t support that for an ordinary offset, because the neighbour’s tag lives in memory, not in the arithmetic.

Case C is what CPA is built to catch. Apple’s reference lists it among the issues CPA mitigates, as “the classic post-realloc rebase of internal object pointers”. Code that keeps pointers into its own buffer moves them after a realloc by adding the distance between the old and new blocks. Subtracting pointers into two different allocations is undefined in C, so such code works out the distance as an integer, as in this function from our compiler tests:

char *bad_move(char *field, char *old_block, char *new_block)
{
    intptr_t delta = (intptr_t)new_block - (intptr_t)old_block;
    return field + delta;
}

The final addition is undefined in C too (N1570 §6.5.6, paragraph 8), but compilers still emit it, so it shows what the hardware does with such values.

The two blocks have different tags, so the distance has a non-zero top byte, 0x04…, and adding it to field, the f in the figure, would give a correctly tagged pointer into the new block, made by arithmetic alone. To the hardware, that is indistinguishable from forging a tag. With CPA on, the sum is poisoned instead.

CPA compares the whole top byte, not only the four tag bits, and it catches a borrow as well as a carry. Apple’s reference gives the example of a load at a negative offset from a NULL pointer, such as NULL minus 8, 0xffff_ffff_ffff_fff8, which wraps the top byte from 00 to ff. So “overflow” here means any change in the top byte, not the C notion of going out of bounds, which case B shows CPA doesn’t track.

From C to checked instructions

Apple’s macOS 27 release notes describe arm64e.x1 as adding “additional hardware security and performance instructions, including CPA2”, the version of CPA that can actually check.

The checked instructions themselves are few (ADDPT):

  • ADDPT and SUBPT add or subtract a register, optionally shifted left by 0 to 7. That covers power-of-two element sizes up to 128 bytes. They can’t take a constant.
  • MADDPT and MSUBPT compute base ± a × b, for element sizes the shift can’t reach. They can also catch a product that doesn’t fit in 64 bits, if the kernel turns on a second switch for it, CPTM0.

Upstream LLVM emits them only behind an option that’s off by default (#105669). Apple’s clang uses them for x1, which it compiles for an apple-m6 CPU with the cpa and mte features, while arm64e targets apple-m1. Apple’s otool and LLVM’s llvm-objdump already decode them, but IDA 9.4 still shows ADDPT and most of its relatives as raw bytes. We expect that to change soon.

What Xcode emits

add_index is the simplest case. To see how far ADDPT goes, we compiled a small set of functions for both slices at -O0, -Os and -O2, with default flags. These are the arm64e.x1 results at -Os and -O2, which were identical:

Source x1 emits Checked
p + i
64-bit i
ADDPT yes
p - i SUBPT yes
p + i
32-bit i
ADD sxtw/uxtw no
&s[i].second ADDPT, ADD #8 index only
48-byte
elements
ADD, ADDPT yes
256-byte
elements
LSL, ADDPT yes
p[i] read LDR in load
p[i] = v STR in store
p ± 4 ADD/SUB #imm no
p + 64 MiB MOV, ADD no
uintptr_t sum ADD no
f + d SUB, ADDPT the add

Apple clang 21.0.0, -arch arm64e.x1 -Os and -O2, default flags. p is an int *, except in the 48-byte and 256-byte rows, where it points to elements of that size. In the last row, d = new − old is computed on intptr_t. “Checked” means the addition passes through the CPA check when a process has it on.

A typed pointer moved by an amount known only at run time, a 64-bit index or an integer distance, got ADDPT or SUBPT at every optimization level we tried. That held even when the element size wasn’t a power of two (48 bytes) or was too big for ADDPT’s shift (256 bytes). The compiler computed the offset first and still finished with ADDPT, and we never saw MADDPT. In bad_move, the last row, the subtraction that computes the distance stays an ordinary sub, and the check lands on the addpt that adds the distance to the pointer:

// bad_move, -O2
sub   x8, x2, x1
addpt x0, x0, x8
ret

arm64e.x1, Apple clang 21.0.0.

The rows marked no are in CPA can’t catch ’em all.

Reading or writing p[i] didn’t need ADDPT at all. The compiler emits a single load, ldr w0, [x0, x1, lsl #2], that adds the index to the pointer itself. That instruction is the same in both slices, but when a process has the check on, the CPU checks the addition inside it too. So loads and stores are the second place the check happens, whichever slice the code was built for.

Two columns. In A, explicit arithmetic, int *q = base + i compiles to an ADDPT, only in the x1 slice, checked through Arm's PointerAddCheck. On a mismatch q is poisoned, and nothing faults until q is used as an address. In B, inside the access, int x = base[i] can compile to one load with a scaled register offset, the same instruction an arm64e build uses. The load's own address addition is checked through AddressAdd and PointerAddCheckAtEL, and on a mismatch that access faults. Beneath both, the checks act only when the CPU implements FEAT_CPA2 and the OS turns on the CPTA0 control for app code.

ADDPT and the load’s own addition are checked the same way, and one kernel switch turns both on. Both instructions are as Apple clang emits them for the x1 slice.

In Arm’s pseudocode, a load or store that adds an offset to its base register computes the address with AddressAdd, whose body is just the addition and the check:

func AddressAdd(base : bits(64), offset : bits(64), accdesc : AccessDescriptor) => bits(64)
begin
    var result : bits(64) = base + offset;
    result = PointerAddCheckAtEL(accdesc.el, result, base);
    return result;
end;

Arm A64 shared pseudocode, 2026-09.

PointerAddCheckAtEL makes the same comparison as ADDPT, on the address the load or store is about to use. We saw it fire on the M6 in a small test app of ours, on a single-byte load with the offset in a register. The pointer in x0 was an ordinary address, and x1 held an offset that changes the top byte, yet the fault address, far, was already poisoned:

stop reason = EXC_ARM_CPA_FAIL (code=264, address=0x40000104918000)
->  ldrb   w0, [x0, x1]
      x0 = 0x0000000104918000
      x1 = 0x0100000000000000
     esr = 0x92000004
     far = 0x0040000104918000

LLDB, our test app on the M6 with the check on (trimmed). Testing on the M6 has the full set of tests.

Stores behaved the same way. Apple’s reference says this covers every load and store that adds to form its address, “whatever the addressing mode”. The one exception is a single instruction that touches several consecutive locations, such as ld1 {v0.16b-v3.16b}, [x0], which loads 64 bytes from x0 into four vector registers (Arm’s specification). Arm steps its address from one location to the next with AddressIncrement, which checks a step only if the CPU chooses to:

func AddressIncrement(base : bits(64), increment : bits(64), accdesc : AccessDescriptor) => bits(64)
begin
    var result : bits(64) = base + increment;
    // Checking the Pointer Arithmetic on an increment is equivalent to checking the
    // bytes in a sequential access crossing the 0xXXFF_FFFF_FFFF_FFFF boundary.
    if ConstrainUnpredictableBool(Unpredictable_CPACHECK) then
        result = PointerAddCheckAtEL(accdesc.el, result, base);
    end;
    return result;
end;

Arm A64 shared pseudocode, 2026-09.

ConstrainUnpredictableBool is how the specification leaves a choice to each CPU, which it calls CONSTRAINED UNPREDICTABLE. As Arm’s comment says, a step can only change the top byte if one access runs across an address ending in FF_FFFF_FFFF_FFFF, and a user address, at most 47 bits, never gets near one. So in practice the exception doesn’t matter.

When the fault arrives

Here is the check itself, from Arm’s specification of PointerCheckAtEL:

func PointerCheckAtEL(el : bits(2), result : bits(64), base : bits(64),
                      cptm_detected : boolean) => bits(64)
begin
    var rv : bits(64)  = result;

    let previous_detection : boolean = (base[55] != base[54]);
    let cpta_detected : boolean = (result[63:56] != base[63:56] || previous_detection);

    if ((cpta_detected && EffectiveCPTA(el) == '1') ||
          (cptm_detected && EffectiveCPTM(el) == '1')) then
        rv[63:55] = base[63:55];
        rv[54] = NOT(rv[55]);
    end;

    return rv;
end;

Arm A64 shared pseudocode, 2026-09.

The if is the switch. EffectiveCPTA is '1' only when the CPU supports the check (FEAT_CPA2, below) and the operating system has turned it on for the running code, and EffectiveCPTM does the same for MADDPT’s product check. Without them, rv stays the sum. When the check fires, it builds the result like this:

Four rows of pointer bits for case C. The base f has top byte 00000011, the offset d has 00000100, and the sum f + d has 00000111, so the base and the sum have different top bytes and the result is poisoned. The result's top byte and bit 55 are the same as the base, bit 54 is the opposite of bit 55, and bits 53 to 0 are the same as the sum.

Case C from the first figure, worked out with Arm’s specification.

The tag doesn’t change, and neither does bit 55, which says whether a pointer belongs to the user or the kernel half of the address space. Setting bit 54 to the opposite puts the value outside anything that half can map, so using it as an address faults. Apple’s reference says that on its platforms, this is a level-0 translation fault.

The previous_detection line matters as much as the bit changes. A base that is already poisoned (bit 55 ≠ bit 54) counts as a mismatch, so adding to a poisoned pointer keeps it poisoned. More checked arithmetic can’t undo the poison.

Poisoned here, faults later

With explicit arithmetic there are two separate moments, the instruction that makes the poison and the later instruction that uses it as an address. Between them, the value is just data. It can be stored in a struct, returned from a function or passed to a library.

Function A makes a poisoned value with ADDPT, 0x0340_0001_02b0_0010, and nothing faults. The value is stored, returned or passed on. Later, function B uses it as the base of a load, and that load faults with EXC_ARM_CPA_FAIL.

The crash points at the load, but the cause is the earlier ADDPT.

Inside a load or store, the two moments collapse into one. The instruction adds and checks its address, then uses it straight away, as Arm’s specification of LDRB (register), the load in our test, shows:

address = AddressAdd(address, offset, accdesc);  // add, then check: poisoned on a mismatch
let data : bits(8) = Mem{8}(address, accdesc);   // read at once: a poisoned address faults here

LDRB (register), Arm A64 pseudocode, 2026-09 (trimmed, comments ours).

So a poisoned address faults on the instruction that made it, with no chance to store or pass it on.

On the M6, such a fault is reported as EXC_ARM_CPA_FAIL (0x108), and ESR, the register that describes the fault, reads 0x92000004 for a read and 0x92000044 for a write, as Apple’s reference says. Arm’s ESR_EL1 definition decodes both as a level-0 translation fault. The reference adds that a poisoned value used as a length or offset “may present as” an MTE tag check failure instead. We didn’t test that.

So if you’re debugging a CPA crash, you may be looking at the wrong instruction. Trace the faulting register back to the arithmetic that poisoned it.

What has to be switched on

A process is checked only when four things line up. The CPU has CPA2, the build has the x1 slice, the signature asks for the check, and the kernel turns it on. Nothing an app can see shows the kernel’s part, so the only way to be sure the check is on is to trigger it and watch it fault.

To trigger it, we built a small macOS app in three variants that ran the same x1 code. The full variant is signed with the four entitlement keys shown below, MTE-only drops the CPA key, and none has no Enhanced Security keys at all. Valid arithmetic and loads worked in all three.

  Needed How to check On our M6
CPU FEAT_CPA2 sysctl 1
Build the x1 slice lipo loaded
Signature the CPA entitlement codesign present
Kernel the check switched on make it fault faulted, only with the entitlement

CPU. Arm defines two levels of the feature, which a CPU reports in one of its ID registers (ID_AA64ISAR3_EL1). With FEAT_CPA, the instructions exist. With FEAT_CPA2, they exist and the checks “can be enabled”. Apple’s release notes list “Mac computers with M6 or later chips” for arm64e.x1, though Apple’s reference still names only A20 Pro iPhones and S11 Apple Watches. On our M6, sysctl reports hw.optional.arm.FEAT_CPA, FEAT_CPA2 and FEAT_MTE as 1.

Build. In Xcode, one build setting, ENABLE_HARDWARE_CHECKED_POINTER_ARITHMETIC_SLICE, adds the slice. Our app’s binary carried arm64, arm64e and arm64e.x1, and lipo -archs lists them for any binary.

Signature. The check is switched on by one more entitlement in the signed app, and Apple requires the three above it. From Enabling enhanced security for your app and the entitlement reference:

<key>com.apple.security.hardened-process</key>
<true/>
<key>com.apple.security.hardened-process.enhanced-security-version-string</key>
<string>2</string>
<key>com.apple.security.hardened-process.checked-allocations</key>
<true/>
<key>com.apple.security.hardened-process.checked-allocations.enforce-checked-pointer-arithmetic-overflow</key>
<true/>

checked-allocations, the third key, turns on memory tagging. Apple enforces CPA only when it’s on, since without tags the top byte has nothing to protect.

With memory tagging, Xcode also adds a soft mode entitlement, which turns tag check failures into crash reports without stopping the app. It doesn’t cover CPA, because using a poisoned pointer is a translation fault, not a tag check failure, and Apple’s reference says CPA has no soft mode of its own. Our app, signed with both, was killed when it read through a poisoned pointer.

Kernel. On Arm, the switch is a bit called CPTA0 in SCTLR2, a control register only the kernel can write (SCTLR2_EL1). It turns the check on for app code. Apple doesn’t document how an entitlement becomes that bit. Apple’s reference says entitlements “are granted per process from the main executable”, so most likely the kernel sets the bit whenever it switches to an entitled process. In our app, even the process flags that proc_pidinfo reports were the same, 0x84404010, with and without the CPA entitlement.

Libraries. A library or framework builds the slice but takes no entitlement, so whether its code is checked depends on the app that loads it. Apple’s reference says that an arm64e library without the x1 slice, loaded into an app with the check on, still gets checked loads and stores, though none of its additions are checked. We tested one load, ldrb w0, [x0, x1], in such a library, and it was checked (Testing on the M6). However, that doesn’t cover every instruction a library might use.

A slice doesn’t switch anything on. On our M6, Safari’s executable carries an arm64e.x1 slice, and WebKit’s own processes are built only for x1. Safari’s signature has the memory-tagging entitlements, but none of them has the CPA entitlement. So their code is built for the check, but nothing in their signatures asks for it.

Testing on the M6

Our test app runs one test at a time on a page it maps for itself, in each of the three variants above. Every test uses the same offset, 0x0100_0000_0000_0000. It has only bit 56 set, so adding it to the page address changes the top byte from 00 to 01 and nothing else.

The tests call three functions, written in assembly because out-of-bounds pointer arithmetic is undefined behaviour in C, and an optimizer may delete or reshape it:

_add:
    addpt x0, x0, x1
    ret
_load:
    ldrb w0, [x0, x1]
    ret
_store:
    strb w2, [x0, x1]
    ret

checks.S, directives omitted.

add calls _add with the page address and the offset, and prints the result without using it. read and write use that result as the address, through _load and _store with offset 0. load and store pass the page address and the offset instead, so the addition happens inside the instruction. library repeats load with the same ldrb, in a library built only for arm64e.

Test Full MTE-only None
add poisoned sum sum
read CPA fault tag fault reads
load CPA fault tag fault reads
write CPA fault tag fault writes
store CPA fault tag fault writes
library CPA fault tag fault reads

Our app on the M6, x1 slice in every variant. A CPA fault is code 0x108, with ESR 0x92000004 for a read and 0x92000044 for a write. A tag fault is code 0x107, with ESR 0x92000011 for a read and 0x92000051 for a write.

Only the full variant poisoned. ADDPT turned the page address 0x0000_0001_0249_8000 plus the offset into 0x0040_0001_0249_8000, keeping the top byte 00 and setting bit 54, and the program exited normally because nothing used the result. The other two variants got the sum itself, with top byte 01.

In the full variant, read and write faulted on an address ADDPT had already poisoned. load, store and library started from the page address, so the poison came from the instruction’s own check, even in the library built without x1.

Without the CPA entitlement, MTE caught every access instead, because the sum carries tag 1 and the page holds tag 0. With neither, every access reached the page.

One thing nearly misled us. LLDB runs a universal binary’s arm64e slice by default, which gave us different results, so select the x1 slice with -a arm64e.x1.

CPA can’t catch ’em all

Neither CPA nor MTE knows where an object ends. Case B, a pointer that walks into a neighbour without changing its top byte, is caught only by MTE, and only if the neighbour has a different tag. Tag 0 is a tag like any other, so two neighbours both left at 0 pass. WebKit’s allocator gives neighbours different tags only in the processes it treats as hardened (Tagging WebKit, part 2). An access that isn’t tag-checked compares nothing.

The other gap is the additions the compiler leaves unchecked, the rows marked no in the compiler table. The first two are a 32-bit index and a large constant:

int *add_index_i32(int *base, int index)
{
    return base + index;
}

int *add_large_const(int *p)
{
    return p + 0x1000000;
}
// add_index_i32, -O0 (stack loads and stores omitted)
ldrsw x9, [sp, #0x4]
addpt x0, x8, x9, lsl #2

// add_index_i32, -O2
add   x0, x0, w1, sxtw #2
ret

// add_large_const, -O2
mov   w8, #0x4000000
add   x0, x0, x8
ret

arm64e.x1, Apple clang 21.0.0. In add_large_const, 0x1000000 four-byte ints are 0x4000000 bytes, or 64 MiB, the constant in the mov.

The int index is checked only at -O0, where the compiler first widens it into a 64-bit register. At -O2 it folds the widening into the addition, and ADDPT has no form that widens its operand. An unsigned index does the same, with uxtw. Constants weren’t checked at any level, whether p + 4, p - 4, a struct field’s offset or the 64 MiB above. ADDPT can’t take a constant, but that isn’t what stopped it. The compiler moved the 64 MiB constant into a register and still added it with ADD.

Arithmetic on a uintptr_t stays integer arithmetic too. The sum from add_index, routed through an integer, gets an ADD:

int *add_index_uintptr(int *p, size_t index)
{
    uintptr_t raw = (uintptr_t)p;
    raw += index * sizeof(int);
    return (int *)raw;
}
// add_index_uintptr, arm64e.x1 -O2
add   x0, x0, x1, lsl #2
ret

The optimizer can also make pointers of its own, as in this summing loop from our compiler tests:

long sum_array(const int *begin, const int *end)
{
    long total = 0;
    for (const int *p = begin; p != end; ++p)
        total += *p;
    return total;
}
// sum_array, -O2 (each loop's start and step only)
add   x8, x0, #0x20            // start of the 16-at-a-time loop, unchecked
ldp   q16, q18, [x8], #0x40    // step 64 bytes, checked

add   x8, x0, x10, lsl #2      // start of the 4-at-a-time loop, unchecked
ldr   q2, [x8], #0x10          // step 16 bytes, checked

add   x11, x0, x10, lsl #2     // start of the one-at-a-time loop, unchecked
ldrsw x9, [x11], #0x4          // step 4 bytes, checked

arm64e.x1, Apple clang 21.0.0.

At -O2 the compiler unrolls and vectorizes the loop into 80 instructions, with three loops for 16, 4 and 1 elements at a time, and none of them has an ADDPT. Each loop’s starting pointer comes from an ADD, which isn’t checked, and each moves its pointer inside a load, which is. ldrsw x9, [x11], #0x4, for example, reads an element and then adds 4 to x11, and that addition goes through the same check as ADDPT (Arm’s specification). If a step changed the top byte, the pointer would come back poisoned and the next load would fault. At -Os there’s only the last loop, starting from begin itself, so every step is checked. So how much of a loop’s pointer arithmetic is checked depends on the build. The assembly never lies.

Pointers kept in signed fields add one more case. A field declared with __ptrauth holds its pointer signed with pointer authentication, and the code authenticates it before using it. Moving such a pointer and storing it straight back got an ADD between authenticating and re-signing, at -Os and -O2. Returning it as well kept ADDPT:

#include <ptrauth.h>
#include <stddef.h>

struct list {
    int *__ptrauth(ptrauth_key_process_dependent_data, 1, 0x1234) items;
};

void move_signed_field(struct list *l, size_t n)
{
    l->items += n;
}

int *move_signed_field_and_return(struct list *l, size_t n)
{
    int *p = l->items + n;
    l->items = p;
    return p;
}
// move_signed_field, -O2 (trimmed)
ldr   x16, [x0]               // load the signed field
autdb x16, x17                // check and remove the signature
add   x17, x16, x1, lsl #2    // move the pointer, unchecked
pacdb x17, x16                // sign it again
str   x17, [x0]               // store it back

// move_signed_field_and_return, -O2 (trimmed)
ldr   x16, [x0]               // load the signed field
autdb x16, x17                // check and remove the signature
addpt x8, x16, x1, lsl #2     // move the pointer, checked
mov   x17, x8
pacdb x17, x16                // sign a copy
str   x17, [x0]               // store it back
mov   x0, x8                  // return the checked result
ret

arm64e.x1, Apple clang 21.0.0, trimmed to the field’s load, the move and the store. -Os keeps the same ADD and ADDPT.

Signing works on integers, so when the moved pointer is only signed and stored, the optimizer turned the step into integer addition. When the function also returns it as a pointer, the step stays ADDPT.

And code built for arm64 or arm64e has no ADDPT at all, as add_index’s arm64e build at the top shows.

A later load can’t catch what these ADDs did. Reading through such a pointer compiles to one load, as in this function from our tests:

int read_index(const int *base, size_t index)
{
    return base[index];
}
// read_index, arm64e.x1 -O2
ldr   w0, [x0, x1, lsl #2]
ret

That load checks its own addition against x0, which already holds the result of the ADD. If the ADD had turned tag 3 into 7, x0 would carry tag 7, the load’s check would see no change, and only MTE would be left. MTE passes if the memory there is tagged 7.

Appendix: the software check

The compiler flag -fchecked-pointer-arithmetic has three values. The default, compatible, gives the table in What Xcode emits, and disabled turns ADDPT back into ADD. checked changes nothing for x1 but adds a software version of the check to arm64 and arm64e builds, with a different rule. In our tests its extra instructions appeared in exactly the functions where the x1 build used ADDPT or SUBPT. This is add_index built that way:

add   x8, x0, x1, lsl #2
eor   x16, x0, x8
tst   x16, #0xffc0000000000000
b.eq  0x14
movk  x8, #0xc8a2, lsl #48
mov   x0, x8
ret

-arch arm64e -O2 -fchecked-pointer-arithmetic=checked, Apple clang 21.0.0.

eor and tst compare bits 63 to 54 of the base and the sum. If any differ, movk overwrites bits 63 to 48 with 0xc8a2. The hardware check, shown in When the fault arrives, compares bits 63 to 56, keeps the base’s bits 63 to 55 and sets bit 54 to the inverse of bit 55, so the two disagree on both the test and the poisoned value.

Further reading