Remember where you came from: Return addresses on Apple silicon
Here is a function that calls another and adds one to the result:
int g(int);
int f(int x)
{
return g(x) + 1;
}
Xcode 27’s compiler builds it differently for the two architecture slices an M6 Mac can run. Only two instructions change, the one that signs the return address and the one that checks it:
// -arch arm64e
pacibsp
stp x29, x30, [sp, #-0x10]!
mov x29, sp
bl _g
add w0, w0, #0x1
ldp x29, x30, [sp], #0x10
retab
// -arch arm64e.x1
pacibsppc
stp x29, x30, [sp, #-0x10]!
mov x29, sp
bl _g
add w0, w0, #0x1
ldp x29, x30, [sp], #0x10
retabsppc #-0x18
Apple clang 21.0.0 (Xcode 27.0, 27A266a), -O2 -mmacosx-version-min=27.0, disassembled with otool -tV, addresses trimmed. otool leaves the call’s target unresolved in an object file, so it’s written here as _g. -Os and -O0 sign and return the same way.
Both versions save the return address on the stack, call g, add one, reload the return address and return. The arm64e version signs the return address with pacibsp on the way in and checks it with retab on the way out. The version in Apple’s new arm64e.x1 slice (x1 for short) uses pacibsppc and retabsppc, and the return carries an offset, #-0x18, that leads 24 bytes back to the instruction that did the signing.
Those two are Arm’s PAuth_LR instructions, from Armv9.5. A return address already says where execution goes back to. These add one more input to its signature, the address where it was signed. Two return addresses signed in different places no longer match just because the stack pointer happened to be the same. We will go over how that works, which attack it blocks, and which of Apple’s code doesn’t use it (yet).
Every count and capture here comes from one M6 Mac mini running macOS 27.0.1, and every listing from Xcode 27.0. The appendix has the setup and the tools.
A signed return address
Where a function returns
The bl instruction calls a function and puts the address to come back to, the return address, in the link register, LR (x30). A function that calls others has to keep that address somewhere while LR is reused, so it saves it on the stack. In the listing, stp saves it and ldp loads it back just before the return.
That saved copy is a classic target of stack corruption. A bug that lets an attacker write to the stack can replace the saved return address, and the return then goes wherever the attacker chose.
Pointer authentication on arm64e
Since the A12 chip, Apple has protected it with pointer authentication (PAC). The CPU computes a short signature from a pointer, a 64-bit modifier and a secret key, and stores it in the pointer’s unused upper bits. Checking it later recomputes the signature, and a pointer someone changed no longer matches. The arm64e architecture uses this for return addresses, among other pointers. LLVM’s pointer authentication documentation, which calls the modifier a discriminator, says that “on arm64e it is signed with the IB key using the stack pointer on entry as the discriminator.”
In instructions:
pacibspsigns LR with key B, using the stack pointer (SP) as the modifier.retabchecks LR the same way and returns if it matches.autibspchecks LR without returning. A function uses it when it ends by jumping straight into another function instead of returning.
Signing and checking a return address. The bit positions come from the LLDB read below.
Here is a real signed return address. We stopped a small test program just after a function’s pacibsp, read LR, and did that three times:
lr = 0xfc528001000005ac (0x00000001000005ac)
lr = 0x83198001000005ac (0x00000001000005ac)
lr = 0x9f710001000005ac (0x00000001000005ac)
LLDB’s register read -f x lr on our M6, in three runs of the same arm64e program. The first number is what LR holds, signature included. Next to it, in parentheses, LLDB shows the plain address with the signature removed. We cut the function name it prints after that.
The low bits hold the same address each time, 0x1000005ac. The signature fills bits 63 to 56 and 54 to 47, 16 bits in all. Bit 55 stays 0. Arm’s InsertPAC keeps that bit as it was, because the CPU uses it to tell user addresses from kernel ones. LLDB turns off address randomization, so the address and SP were the same in all three runs, and the signature still changed every time. So the key B behind it was different in each process. The same program built for x1 puts its signature in the same bits.
When a check fails, the value is no longer a usable address. Depending on the chip, the CPU faults at the check itself or as soon as it tries to run code at that address. An attacker who can write to the stack can’t compute a valid signature without the key. With 16 bits, a guess is right about once in 65,536 tries, and a wrong one stops the program.
What the stack pointer adds
Because SP is part of the signature, a signed return address passes the check only where SP has the same numeric value as when it was signed. Two frames at the same call depth on different stacks, such as two threads, have different values. So in this post, “the same SP” always means the same number.
Copied to a place where SP differs, a signed return address fails the check. Where SP is the same, it passes, and the same SP is common. A function C that calls A and then B usually gives both the same SP, because its own frame stays where it is between the two calls. In source code, it can be as plain as this:
#include <stdio.h>
int count;
__attribute__((noinline)) void D(void) { puts("D"); count++; }
__attribute__((noinline)) void A(void) { D(); count++; }
__attribute__((noinline)) void B(void) { puts("B"); count++; }
void C(void)
{
A();
B();
count++;
}
Our own example. noinline keeps each call a real call.
Here is what the compiler makes of C, with and without the last count++:
// C as listed above
pacibsp
stp x29, x30, [sp, #-0x10]!
mov x29, sp
bl _A
bl _B
…
ldp x29, x30, [sp], #0x10
retab
// C without the last count++
pacibsp
stp x29, x30, [sp, #-0x10]!
mov x29, sp
bl _A
ldp x29, x30, [sp], #0x10
autibsp
…
b _B
Apple clang 21.0.0 (Xcode 27.0, 27A266a), -arch arm64e -O2, disassembled with otool -tV, addresses trimmed. … marks cut lines, the count++ in the first and a check of the autibsp result in the second.
In the first version, nothing between bl _A and bl _B changes SP, so A and B start with the same SP. Without the last count++, C puts SP back before it jumps to B, so B starts 16 bytes higher than A.
On the stack, the calls happen in this order:
The addresses are examples, and the figure leaves out the 1 in 65,536 chance that a wrong signature matches anyway.
So in arm64e, a return address signed in A also passes the check in B.
(Ab)using reused signatures
Suppose an attacker can read and write the stack. That’s the attacker PAC is designed against, as LLVM’s threat model describes it. They can’t compute a signature, but they can copy one they’ve seen. Copied into B’s frame, A’s signed return address passes B’s check and sends B back to the instruction after the call to A, so the code between the two calls runs a second time.
Here’s why that matters. Say C looks like this:
if (A(request)) // is the request allowed?
B(request); // carry it out
count++;
The if checks the value A returns. When B returns there instead, the if checks whatever value B left behind. If that isn’t zero, C carries out the request again, without A ever approving it. PAC stops an attacker from returning to any address they want, but a real return address, used at the wrong time, still works.
Skipping a check is the simplest example. The code after every call expects the values that call leaves behind. When a different call returns there, it runs with that call’s values instead, plus anything the attacker wrote to the stack. Attackers can leverage this in at least two ways, often together. It can kickstart a jump-oriented programming (JOP) chain, which strings together bits of the program’s existing code to do what they want. Or, if the code there signs a pointer, they can get it to sign one of their choosing (a signing primitive), which is exactly what PAC is supposed to prevent.
This isn’t always easy. First, the attacker has to trigger a path that saves (spills) the signed return address they want on the stack, and that value has to stay there, not overwritten, long enough for them to read it. Then the attacker has to trigger a second path, the one whose return they redirect, that runs with the same SP as the first (the two paths have to be stack aligned). While it runs, they have to write the copied value over its saved return address, after the function saves its own and before it loads it back. Because the SPs match, the copied value passes the check. The program also has to keep running after the redirected return. If the code there crashes on the values it finds, the attack ends.
This isn’t just a hypothetical scenario. It’s technically possible, especially in dynamic environments such as JavaScript engines.
The M6 slice adds a second input to stop this kind of reuse.
What the M6 slice adds
The signing address
Arm’s reference gives pacibsppc two modifiers. The first is SP, as before. The second is “the value of PC”, the address of the pacibsppc instruction itself. Its whole operation is one line (PACIBSPPC):
X{64}(d) = AddPACIB2(X{64}(d), SP{64}(), PC64());
Arm A64 pseudocode, 2026-09. d is 30, the link register.
pacibsp calls AddPACIB with SP alone, and AddPACIB2 is its version with two modifiers. The key is still B.
By the time the function returns, the program counter has moved on, so the check needs another way to find that address. retabsppc carries it as a displacement, which otool shows as #-0x18. Arm stores the distance without its sign and subtracts it from the return’s own address (RETABSPPC):
let offset : bits(64) = ZeroExtend{}(imm16::'00');
...
var target : bits(64) = X{64}(30);
let modifier : bits(64) = SP{64}();
let modifier2 : bits(64) = PC64() - offset;
...
target = AuthIB2(target, modifier, modifier2, auth_then_branch);
...
BranchTo{64}(target, BranchType_RET, branch_conditional);
Arm A64 pseudocode, 2026-09 (trimmed). The first line is from the instruction’s decode. The rest is its operation, without the key A case and the Guarded Control Stack check.
imm16::'00' is the 16-bit field from the instruction with two zero bits added, so the distance counts whole 4-byte instructions, up to 262,140 bytes back. Here it’s 0x18. autibsppc works the same way for a check without a return.
The same function in both slices. The x1 signature has one more input, and the return points back to it.
What the M6 implements
macOS reports the CPU’s features through sysctl. On our M6:
hw.optional.arm. |
M6 |
|---|---|
FEAT_PAuth, FEAT_PAuth2, FEAT_FPAC, FEAT_PACIMP |
1 |
FEAT_PAuth_LR |
1 |
FEAT_FPACCOMBINE |
1 |
An M2 Pro on the same macOS reports 0 for the last two.
FEAT_PAuth_LR means the CPU has the new instructions. FEAT_PACIMP means the signature comes from an algorithm the chip maker chose, instead of the QARMA cipher Arm specifies (ComputePAC2). The M6 reports it, so Apple’s own algorithm computes the old signatures and the new ones.
FEAT_FPAC makes a failed check-only instruction such as autibsp fault on the spot. FEAT_FPACCOMBINE extends that to instructions that check and then branch, such as retab and retabsppc. Every check ends in Arm’s Authenticate. It XORs the signature it recomputed into the upper bits. If the two signatures match, those bits are all 0 again, which is how retab removes the signature. Then it decides whether to fault:
result = ptr;
...
if bottom_PAC_bit < 55 then
result[54:bottom_PAC_bit] = result[54:bottom_PAC_bit] XOR PAC[54:bottom_PAC_bit];
end;
result[63:56] = result[63:56] XOR PAC[63:56];
if (IsFeatureImplemented(FEAT_FPACCOMBINE) ||
(IsFeatureImplemented(FEAT_FPAC) && !is_combined)) then
if (EL3_using_lva3 && !IsZero(result[63:bottom_PAC_bit])) then
...
elsif (!EL3_using_lva3 &&
result[63:bottom_PAC_bit] !=
Replicate{64-bottom_PAC_bit}(result[55])) then
error_code = (if data then '1' else '0')::key_number;
AArch64_PACFailException(error_code);
end;
end;
Arm A64 shared pseudocode, 2026-09 (trimmed). Arm places the signature differently when a pointer carries tag bits. This is the case without them, the one our LLDB reads match, with the signature in bits 63 to 56 and from bit 54 down to bottom_PAC_bit, 47 here.
is_combined is true for retab and retabsppc, which check and branch in one instruction, and false for autibsp and autibsppc. With FEAT_FPAC alone, a failed retab skips this fault and branches anyway. The upper bits of the address are still not all 0, so it isn’t a valid address, and the CPU faults when it tries to run code there. With FEAT_FPACCOMBINE, as on the M6, a return whose check fails should stop at the return itself.
Defending against reused pacibsp signatures
Go back to C, which calls A and then B. Both calls start with the same SP. Say each saves its signed return address in the same place in its frame, as the opening function does. Both calls use the same key.
The addresses are examples, and the figure leaves out the 1 in 65,536 chance that a wrong signature matches anyway.
In arm64e, A’s signed return address, copied into B’s frame, passes the check. In the x1 slice, B checks with its own signing address. The copied value was signed at A’s, so the signatures differ, and the check fails.
Adding the signing address removes the guaranteed match between return addresses signed at different places with the same SP. In the usual case, one signing instruction at the top of each function, a return address taken from another function was signed at a different address, so it fails the check. Like any PAC check, it can still pass by chance, as often as a guessed signature would.
What it doesn’t stop
The new input is the address of the signing instruction, and a function usually has just one. So it tells A and B apart, but not two calls to the same function.
Say C calls A twice, with the same SP both times:
A(); // first call
count++;
A(); // second call
count++;
Both calls sign their return address with the same pacibsppc, so the SP and the signing address are the same. Only the return address differs, and each signed value is valid. An attacker who copies the first call’s signed return address into the second call’s frame sends the second call back to the place after the first call. The check passes, so the x1 slice doesn’t catch it. It’s the same trick as before, but the attacker can only pick among the places that call A.
The new instructions also protect only return addresses. Function pointers, C++ virtual tables and the other pointers arm64e signs are signed the same way as before. In ssh, the instructions that sign and check them appear equally often in both slices. And code that doesn’t use the new instructions still signs the old way, which on an M6 includes the kernel’s own code and JavaScriptCore’s JIT, as the next section shows.
How far the switch reaches
Almost all of Apple’s compiled code on our M6 signs the new way. The exceptions are hand-written stubs, JavaScriptCore’s JIT (the part that compiles JavaScript while it runs), and XNU, the kernel’s own code.
| Code | Old way | New way |
|---|---|---|
/usr/bin/ssh, x1 slice |
0 | 933 |
| Foundation | 37 | 45,771 |
| JavaScriptCore | 62 | 24,949 |
| libc++abi | 0 | 344 |
| libsystem_kernel | 472 | 660 |
| XNU itself | 18,184 | 56 |
| Rest of the kernelcache | 3,552 | 128,389 |
| JavaScriptCore’s JIT output | 149 | 0 |
Signing instructions, pacibsp for the old way and pacibsppc for the new, roughly one per function that saves its return address. Everything but the JIT row is counted in the code on disk, and the libraries are the x1 versions in the shared cache, the single file that holds macOS’s system libraries. The JIT row counts the code Apple’s jsc shell generated while running a loop.
An old pacibsp can still sign the new way if a pacm instruction comes right before it (PACIBSP). None of this code has a pacm, so the old-way counts really are the old signature.
User space. 1,245 of the 1,247 arm64e programs in /usr/bin, /usr/libexec and /usr/sbin ship an x1 slice, and in ssh every signing and return instruction switched. The old instructions left in the libraries come from code no compiler produced from source, in every case we traced. They’re system call stubs in libsystem_kernel, WebAssembly call stubs written in assembly in JavaScriptCore, and stubs the linker added to Foundation.
JavaScriptCore’s JIT. The code it writes at run time signs the old way. Its assembler only knows pacibsp for a return address (MacroAssemblerARM64E.h), and all 149 return-signing instructions it generated in our test were pacibsp.
The kernel. The kernelcache, the file that holds the kernel and its extensions, is an x1 build, and 309 of its 361 entries sign only the new way. XNU itself doesn’t, with 18,184 old signing instructions against 56 new ones. We don’t know why.
Unwinding and crash reports
The return instruction isn’t the only code that reads saved return addresses. When a C++ exception is thrown, the runtime walks back up the stack through them to find the code that catches it, and the crash reporter does the same to list the functions that were running. Both have to handle the new signatures, or exceptions and crash reports would break in x1 code. They don’t. In a small x1 program of our own, an exception thrown three calls deep was caught, and a crash at the same depth produced a report that named every function.
Who gets it
Apple’s code gets it on Macs that load the x1 slice, which the macOS 27 release notes say means “Mac computers with M6 or later chips”. Older Macs run the arm64e slice and keep the old signing.
Third-party code gets it by building the x1 slice, which apps turn on through Enhanced Security in Xcode. The build setting for it, ENABLE_HARDWARE_, is named for checked pointer arithmetic, but it changes return signing too. Our test programs didn’t use Enhanced Security. They ran on our M6, where we set the -arm64e_preview_abi startup option for this kind of testing.
To see it on a Mac with macOS 27 and Xcode’s command line tools:
lipo /usr/bin/ssh -thin arm64e.x1 -output ssh-x1
otool -tV ssh-x1 | grep -m2 -E 'pacibsppc|retabsppc'
sysctl hw.optional.arm.FEAT_PAuth_LR
Appendix: how we checked
Our M6 is a Mac mini (Mac18,5) on macOS 27.0.1 (26A434) with Xcode 27.0 (27A266a). We set the -arm64e_preview_abi startup option on it so it runs arm64e programs we build ourselves.
Apart from the JIT row, the counts come from the files on disk of this one build, read with otool -tV and dyld_info -disassemble. For the kernel, we unpacked a copy with ipsw and searched for each instruction’s 4-byte pattern, which gives the same numbers as otool on ssh. Our pacm search found both pacm instructions in a test library built with -mbranch-protection=pac-ret+pc, so it works.
Further reading
- Arm, A64 Instruction Set Architecture, 2026-09, including PACIBSPPC, RETABSPPC and the pointer authentication functions
- LLVM, Pointer Authentication, on how arm64e signs return addresses, and
AArch64PointerAuth.cppforpacm - Liljestrand and others, PACStack: an Authenticated Call Stack (USENIX Security 2021), on reusing return addresses signed with SP alone
- MDSec, ARM64 stack internals and obfuscation on Apple Silicon, for arm64e frames in depth
- Our post on the same slice, New chip, new catch: Checked pointers on Apple silicon