Vulnerability Research

Pointer authentication: why arbitrary read/write isn't game over on arm64e

PAC signs pointers with a hardware-keyed MAC tucked into their unused bits, so a memory leak can no longer forge a callable pointer. We build it from the instruction set up (signing and authentication, keys and modifiers, what Apple's arm64e ABI signs) and show why it breaks the cheap path from arbitrary read/write to code execution.

If you read the JavaScript engine post, you know how it ends: one memory bug becomes a type confusion, the type confusion becomes arbitrary read and write, and for years that was effectively the end of the fight. With read/write you would map an RWX page, drop shellcode in it, point some function pointer at it, and call it. The mitigations chipped away at that last step (W^X took the RWX page away), but the shape of the finish never changed: overwrite a pointer the program will later call, and you own the program counter.

Pointer authentication breaks that shape. On Apple’s arm64e it is the single biggest reason a clean arbitrary read/write is no longer game over. This post is about how it works, what Apple signs with it, and why a leak alone cannot forge the one thing you need.

Everything here is public: the ARMv8.3-A architecture, Apple’s documented arm64e ABI, and the named bypass classes at the end. There is no exploit and no bypass chain in this article.

The spare bits of a pointer

A 64-bit pointer is mostly empty space. AArch64 virtual addresses are far narrower than the register that holds them: a typical configuration only translates the bottom 39 to 48 bits, so the top of every pointer goes unused. Top-byte-ignore (TBI) frees the highest 8 bits as well, letting software stash a tag there that the MMU quietly discards. PAC moves into exactly this dead space.

Sign a pointer and the processor computes a short message authentication code over the address and writes it into those unused bits, split into two fields that straddle bit 55:

 63        56 55 54                          VA region            0
+-----------+--+-----------------------------+----------------------+
|  PAC hi   |  |            PAC lo           |   virtual address    |
+-----------+--+-----------------------------+----------------------+
             ^
             bit 55 selects TTBR0 (user) / TTBR1 (kernel), preserved

The signature has to dodge bit 55, the bit that selects whether the pointer lives in the user half or the kernel half of the address space, because that bit must survive intact. What’s left is small: depending on the virtual-address size and whether TBI is on, the PAC is on the order of a dozen to a few dozen bits, roughly 16 in a common userland configuration. Keep that smallness in mind; it comes back at the end.

Sign and authenticate

Two operations bracket the life of an authenticated pointer. To sign, a PAC* instruction takes a pointer and a 64-bit modifier and fills in the PAC field:

; sign the pointer in x0 with key IA, using x1 as the modifier
pacia   x0, x1

To use the pointer you first authenticate it. AUT* recomputes the MAC from the current address and the modifier, and if it matches the PAC field, strips it back to a clean, dereferenceable pointer:

; authenticate x0 with key IA and modifier x1; on success x0 is usable
autia   x0, x1

What happens on failure is the whole game. In the original ARMv8.3 design the authentication does not fault. Instead it deliberately corrupts the high bits so the pointer becomes non-canonical, and the next dereference takes a translation fault. ARMv8.6 added FPAC, which faults at the AUT instruction itself, turning a silently poisoned pointer into an immediate, precise crash; Apple’s newer cores implement it.

A few variants matter in practice. XPACI/XPACD strip a PAC without checking it, for when software just wants the bare address. PACGA is the odd one out: it doesn’t sign a pointer at all, it computes a 32-bit generic MAC over two registers, meant for protecting arbitrary data rather than addresses. And because return addresses are everywhere, the ISA bakes in fused forms. PACIASP/AUTIASP sign and authenticate the link register using the stack pointer as the modifier, and RETAA, BLRAA, BRAA, LDRAA authenticate-and-then-act in a single instruction. A protected function looks like this:

func:
    paciasp                     ; sign LR, modifier = SP
    stp     x29, x30, [sp, #-16]!
    ; ... body ...
    ldp     x29, x30, [sp], #16
    retaa                       ; authenticate LR against SP, then return

Smash the saved return address on the stack and retaa will refuse to honor it. The classic stack-based control-flow hijack is dead on arrival.

Keys and the modifier

Five keys feed these instructions, each 128 bits: two for instruction pointers (IA, IB), two for data pointers (DA, DB), and one generic key (GA) for PACGA. They live in system registers that ordinary code cannot read. Crucially, even code with arbitrary read/write of its own address space cannot reach them, because they are not mapped memory. The key material is established at boot and per process, and it is the one secret the whole scheme rests on.

The second ingredient is the modifier, the 64-bit value you saw as the second operand. The PAC is a function of (key, pointer value, modifier), so the same address signed with two different modifiers yields two different signatures. This is what turns PAC from a single global checksum into something useful: each kind of pointer, and often each site, is signed with a different modifier, so a signature minted in one place is worthless in another. The choices are deliberate:

Underneath, the MAC is a truncated QARMA, a lightweight tweakable block cipher, with the key as the key, the pointer as the input, and the modifier as the tweak. The cipher’s internals don’t matter here. What matters is the binding: change the address, the key, or the context, and the signature no longer checks out.

What Apple signs on arm64e

arm64e is Apple’s PAC-hardened ABI, shipping on every device since the A12 (2018). It doesn’t protect one pointer type. It signs essentially everything an attacker would historically overwrite to hijack control flow:

The rule that falls out is blunt: a generic memory write no longer produces a pointer the CPU will branch to. You can corrupt the value all you like, but the instant something authenticates it, it dies.

Back to the browser chain

Now reconnect this to where the JavaScript-engine post left off. You hold arbitrary read and write inside the renderer. You go to overwrite a function pointer (a vtable entry, a JIT code pointer) with the address of your gadget or your shellcode. On arm64e that pointer is signed. For your forged value to survive authentication you would have to compute its PAC, and to do that you need the key. The key isn’t in memory you can read; it’s in a system register. The primitive that used to end the exploit, arbitrary read/write, doesn’t hand you the one secret you need.

That is the concrete form of the asymmetry the previous post described. Triggering the bug is the easy afternoon’s work. Turning a clean read/write into a validly signed code pointer on a current iPhone is the part that is genuinely hard.

Why it isn’t absolute

PAC raises the cost; it doesn’t make exploitation impossible, and treating it as a hard wall is a mistake. Without walking through any of them, the shapes attackers reach for are well documented:

And none of this touches the kernel, which signs its own pointers with PAC too (alongside related mitigations like PPL), or the sandbox you are still trapped behind after the renderer falls.

Conclusion

Pointer authentication doesn’t fix a single bug. The type confusion still happens, the read/write is still real. What PAC removes is the cheap, mechanical step that used to connect them to a program counter you control: forge a pointer, get it called, win. By binding every interesting pointer to a key you can’t reach and a context you can’t reuse, it pushes the attacker off the easy path and onto the narrow, fragile ones: oracles, gadgets, reuse, speculation. The work doesn’t vanish; it gets multiplied. That multiplication, a simple memory bug now wrapped in a problem with no obvious cheap solution, is, once again, exactly what makes a modern target interesting.

This post picks up where Exploiting JavaScript engines left off: the same chain, seen from the mitigation that ends it.