PAC bypass on ARM64e

PAC bypass on ARM64e

TL;DR: Apple Silicon signs function pointers, return addresses, and select data pointers with a per-process key — bypass is rarely “crack the key”, it’s finding a signing oracle, hijacking an unsigned pointer the code still trusts, or reusing a signed pointer the attacker can replay.

What it is

Pointer Authentication (ARMv8.3 PAC) inserts a 16-bit MAC (“tag”) into the unused high bits of a pointer using one of four keys (IA, IB, DA, DB) plus a 64-bit context modifier. PACIA x0, x1 signs; AUTIA x0, x1 verifies. A mis-auth corrupts the pointer so the next access faults. macOS / iOS use:

  • IA — function pointers, indirect calls.
  • IB — return addresses.
  • DA — Obj-C isa, vtables, cf_runtime_class.
  • DB — block invoke pointers, some Swift metadata.

Keys are reseeded per exec() and are kernel-only readable. The MAC is short (16 bits) — brute force is not infeasible for an oracle, but the more common path is to make the code sign something for you.

Preconditions / where it applies

  • Apple Silicon (ARM64e) target — Apple’s own binaries, kernel, WebKit content process, frameworks compiled with -mbranch-protection=pac-ret.
  • An existing arbitrary read/write or type-confusion primitive.

Technique

1. Signing oracle. Find code that accepts an attacker-influenced pointer and signs it. Common shapes:

  • Obj-C isa swizzling: writing to an object’s isa field invokes objc_setClass, which signs the class pointer with the object’s context. If you control the value, you control a signed isa.
  • Block creation: _Block_copy signs the invoke pointer. If a heap UAF lets you rewrite the block descriptor before _Block_copy runs, the runtime signs your value for free.
  • C++ vtable construction in attacker-controlled allocations.

2. Context confusion. PAC uses the storage address as the context discriminator by default (__ptrauth(key, address-diversity, discriminator)). If you can place a forged pointer where an authenticator expects a different context, the auth fails. But: if you find two sites that share a key + discriminator (e.g., compiler bug, missing __ptrauth attribute), you can move a legitimately-signed pointer between them.

3. Unsigned-pointer hijack. Most data pointers are not signed. A struct with one signed vtable and many raw void* callbacks lets you overwrite the unsigned ones; if the consuming code calls them via an unsigned indirect call (blr x8 without blraa), no auth runs. Look in disassembly for blr vs blraa/blrabblr is unauthenticated.

4. Replay across processes. Keys are per-process, but inside one process you can replay any same-context signed pointer (Linus-style UAF: free a signed-pointer-containing object, reallocate, observe).

5. Brute force. 16-bit tag → 65,536 outcomes. If you have an oracle that lets you observe “fault vs no fault” cheaply and reset state, you can spray and confirm in seconds. Real example: Project Zero’s prosper brute force against WebKit JIT.

6. JIT escape. A signed function pointer in an RWX JIT region can be overwritten with attacker code; if the call site uses blraa it still authenticates the pointer’s tag, but if your overwrite preserves the tagged value and changes only the unsigned low bits, the auth passes (the auth covers the high bits, not the destination instructions).

Confirming PAC. otool -arch arm64e -hv binary shows the arm64e slice. pacibsp in prologues, retab in epilogues confirm pac-ret. BTI instructions (bti c, bti j) accompany it.

Detection and defence

  • Always sign function pointers stored in attacker-reachable memory with __ptrauth_function_pointer (or equivalent) — leaving them unsigned is the most common bug.
  • Use address diversity for every signed pointer; never share a discriminator across unrelated sites.
  • Audit indirect-branch instructions: any blr not preceded by an aut* is a primitive.
  • For exploit developers: dump the dyld_shared_cache slice for arm64e and grep for blraa/braa to map authenticated callsites.

References