x86 leaned on the stack canary for two decades. ARM64 raised the bar twice: PAC cryptographically signs pointers, and MTE tags memory so a stale or out-of-bounds pointer doesn't match its target. They're genuinely stronger. They also fail to the same primitives we always reach for — leaks, oracles, and brute force against a field that's only a few bits wide.
- arch
- aarch64
- PAC
- on (ARMv8.3 pointer authentication)
- MTE
- on (ARMv8.5 memory tagging, synchronous)
- note
- both are probabilistic / key-based, not absolute
PAC — a MAC in the pointer's spare bits#
A 64-bit pointer doesn't use all 64 bits for the address. PAC fills the unused high bits with a cryptographic MAC (QARMA) computed over the pointer, a 64-bit modifier (context salt), and a secret key held in a system register. The compiler signs the return address on entry and authenticates it on exit:
Overwrite the saved x30 with a raw gadget address and autiasp fails: it flips
the pointer to a poison value and the ret faults (on FPAC, it faults
immediately). You can't forge the MAC without the key. So you don't forge it:
- Signing gadget / oracle. Find code that runs
pac*on a pointer you control with the key and modifier you need — now the hardware signs your gadget for you. The most reliable PAC bypass there is. - Leak and replay. A signed pointer is valid for its
(key, modifier)pair. If the same modifier recurs (or pointers are signed with modifier0), leak one signed pointer and reuse it wherever that context applies. - Brute force a small field. With a 48-bit VA the PAC is ~16 bits. If failures don't hard-crash (a forking service), guessing is cheap — expect about tries for a -bit field, and here .
- PACMAN. A speculative side channel leaks whether a guessed PAC was correct without the architectural crash — turning the brute force above into a silent oracle.
MTE — a 4-bit lock on every allocation#
MTE tags each aligned 16-byte granule of memory with a 4-bit value, and stores a 4-bit tag in the pointer's top byte (top-byte-ignore). Every access checks pointer tag == memory tag; a mismatch faults. The allocator tags each new allocation randomly, retags on free, and gives neighbours different tags:
Deterministic-feeling, but it's probabilistic — only 4 bits:
- Guess the tag. 16 possible tags, so a blind forge matches 1 in 16 — . In async mode, or anywhere a wrong guess doesn't kill the process, you just retry.
- Leak the tag. Any primitive that reveals the target pointer reveals its tag too; forge a pointer with the matching top byte and the check passes every time.
- Stay in the granule. Corruption within the same 16-byte granule shares the tag — MTE never sees it. Sub-granule overwrites slip through.
- Hit untagged memory. MTE usually covers the heap; stack/globals may be untagged depending on config — corrupt those instead.
the shape of a modern aarch64 exploit
Leak first (a tag for MTE, a signed pointer or signing gadget for PAC), then
the classic chain runs — the mitigations changed the price of the leak, not
the existence of the bug:
segfault{16_bits_is_not_a_lot_of_bits}
PAC and MTE are the best mainstream memory-safety mitigations shipping — they turn "corrupt a pointer" into "leak, or guess a small field, or find a gadget that signs for you." That's a real tax. But it's a tax, not a wall: the bug still exists, and the same leak that armed every x86 exploit arms these too.