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.
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 + i64-bit i |
ADDPT | yes |
p - i |
SUBPT | yes |
p + i32-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.
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:
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.
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_, 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
- Arm, A64 Instruction Set Architecture, 2026-09: ADDPT, checked pointer arithmetic pseudocode,
AddressAdd - Arm,
SCTLR2_EL1andID_AA64ISAR3_EL1 - Apple Developer, Enabling enhanced security for your app, and the macOS 27 and Xcode 27 release notes
- Apple, Checked Pointer Arithmetic, the reference that ships with Xcode 27.0 (27A266a) at
Xcode.app/Contents/PlugIns/IDEIntelligenceChat.framework/Versions/A/Resources/audit-xcode-security-settings-ref-checked-pointer-arithmetic.md.packaged - LLVM, assembler support (#73777) and scalar code generation (#105669)
- Blake Crosley, arm64e.x1: Apple’s Checked Pointer Arithmetic Slice
- Tagging WebKit, part 1: MIE arrives in WebKit and part 2: The rules and reach of tagging