armhub.dev
Architecture · 15 pages
Architecture

PAC, BTI, and MTE

The three modern ARM hardware-security extensions — Pointer Authentication (PAC) against ROP, Branch Target Identification (BTI) against JOP, and Memory Tagging Extension (MTE) against use-after-free and buffer overflows. What they cost, where they're deployed, and the threat model each one defeats.

7 min read · 1,587 wordsupdated · May 13, 2026

Memory-safety vulnerabilities — buffer overflows, use-after-free, type confusion — are the dominant source of severe security bugs in C and C++ codebases. Software mitigations (ASLR, stack canaries, CFI) help but aren't free and aren't complete. ARM has added three architectural extensions over the past decade specifically to make these bug classes much harder to exploit: PAC (Pointer Authentication Code, ARMv8.3), BTI (Branch Target Identification, ARMv8.5), and MTE (Memory Tagging Extension, ARMv8.5). Each targets a specific exploit pattern; together they raise the cost of a working memory-corruption exploit by orders of magnitude.

PAC defeats return-oriented programming (ROP). BTI defeats jump-oriented programming (JOP). MTE catches use-after-free and out-of-bounds heap access at the hardware level, with low enough overhead to run in production. Cortex-A from Apple, Qualcomm, MediaTek, and Samsung all ship PAC + BTI; MTE is rolling out on Pixel and Galaxy phones running Android 14+. Cortex-M85 brings PAC + BTI (collectively branded PACBTI) to the microcontroller world.

PAC / BTI / MTE at a glance
PAC introduced
ARMv8.3-A (2017). Optional on Cortex-A75+, A55+, A510+, A710+, etc. Required on ARMv8.6+.
BTI introduced
ARMv8.5-A (2018). Optional. Required on ARMv9.x.
MTE introduced
ARMv8.5-A (2018). Optional. Required on ARMv9.x.
PACBTI on M-profile
ARMv8.1-M extension. Implemented in Cortex-M85.
PAC threat model
Return-oriented programming (ROP) and forged function pointers. Sign on store, verify on use.
BTI threat model
Jump-oriented programming (JOP). Requires landing-pad instructions at every indirect-branch target.
MTE threat model
Use-after-free, linear buffer overflow on the heap (and stack with -fsanitize=memtag).
MTE tag granule
16 bytes (one 4-bit tag per 16-byte aligned chunk of memory).
Performance impact
PAC: ~0.5–3% on typical workloads. BTI: <1%. MTE in sync mode: 3–8%; async mode: <2%.

PAC — Pointer Authentication Code

PAC takes the unused top bits of a 64-bit pointer (on a system with 48-bit virtual addresses, the top 16 bits are normally unused or sign-extended) and uses them to store a MAC (Message Authentication Code) computed over the pointer's value and a secret key held in a system register. When code signs a pointer, the hardware writes the MAC into the top bits. When code uses the pointer (to call a function, return, or dereference), the hardware verifies the MAC; if it matches, the top bits are stripped and the bare pointer is used. If it doesn't match, the hardware corrupts the pointer (sets the top bit to an unmapped value), turning the next dereference into a fault.

The signing key is held in PAC key registers (APIAKey_EL1, APIBKey_EL1, APDAKey_EL1, etc. — five keys total). The kernel sets these at boot from a CSPRNG; they're per-process on Linux (Android randomizes per app). The user has no access; the keys never leave the CPU.

The PAC instructions:

InstructionAction
PACIA Xd, XnSign Xd as instruction-address (return address) using key A and modifier Xn
PACIB Xd, XnSign with key B (Android uses A for return, B for indirect calls)
PACDA Xd, XnSign as data-address using key A
AUTIA Xd, XnVerify Xd's PAC; if good, strip; if bad, corrupt to fault
AUTIB Xd, XnVerify with key B
PACIASP / RETAAShort forms specifically for return addresses, modifier = SP
XPACI Xd / XPACD XdStrip PAC bits without verification (for debugging/logging)

The compiler emits PACIASP at function entry and RETAA (return + authenticate) at function exit:

my_function:
    pacisp                  @ sign LR using SP as modifier; LR's top bits = MAC(LR, SP, keyA)
    stp  x29, x30, [sp, #-16]!    @ save signed LR to stack
    ...
    ldp  x29, x30, [sp], #16       @ load signed LR from stack
    retaa                   @ verify + return; fault if MAC wrong

The threat model is precise: an attacker who corrupts the stack-resident LR can overwrite it with any value, but they cannot forge a valid MAC unless they know the key. The RETAA checks and either returns to the legitimate caller or corrupts the pointer and faults — never returns to attacker-controlled code. ROP chains, which rely on chaining together return-gadgets, are defeated outright.

PAC layout in a 64-bit pointer (48-bit VA)
PAC (signature)151Virtual address (48 bits)48 bits64 bits total
Because the virtual address space is only 48 bits, the upper 16 bits of every pointer are otherwise unused — PAC repurposes them as a MAC over (pointer, modifier, key). AUTIA validates and strips; on mismatch the pointer is corrupted to fault on next use.

The performance cost of PAC is small: PACIASP is 1 cycle in the typical front-end, RETAA is 1 cycle in the back-end (replacing a normal ret). Workloads measure 0.5–3% overhead; Apple's iOS sees about 1–2% on average across the system.

BTI — Branch Target Identification

PAC protects return-style indirect branches. BTI protects forward indirect branches — function pointers, virtual calls, computed jumps. The mechanism is different: BTI requires every legitimate indirect-branch target to begin with a BTI instruction (encoded as a hint, so it's a NOP on older CPUs). An indirect branch lands somewhere; the CPU checks the very next instruction; if it's not the expected BTI variant, the CPU raises a Branch Target Exception.

my_indirect_target:
    bti  j                  @ landing pad — required for any BR/BLR landing here
    ...

BTI variants:

  • BTI — generic landing pad. Accepts all indirect branches.
  • BTI c — accepts indirect call (BLR). Used at function entry points exposed via function pointer.
  • BTI j — accepts indirect jump (BR). Used at jump-table targets.
  • BTI jc — accepts both.

The compiler decides which form to emit based on how a function is reachable. __attribute__((section(".text.func_called_via_ptr"))) plus -mbranch-protection=bti gets a BTI c at function entry. Switch-statement jump targets get BTI j.

The threat model: a JOP attack chains together gadgets ending in indirect branches. The attacker corrupts function pointers and jumps the CPU to gadget addresses. With BTI, only addresses immediately preceded by a BTI are valid targets — and a useful gadget is unlikely to land at a BTI. The attack surface shrinks dramatically.

Performance cost of BTI is essentially zero — landing-pad instructions are 1-cycle hints that fold into the front-end. The cost is in binary size: every function exposed via pointer needs a 4-byte BTI prologue.

MTE — Memory Tagging Extension

MTE is the most ambitious of the three. It tags every 16-byte aligned chunk of memory with a 4-bit tag (held in metadata storage maintained by the CPU). It also tags every pointer with a 4-bit tag in the pointer's top bits. On every load and store, the hardware compares the pointer's tag against the memory's tag; if they don't match, the access faults (in synchronous mode) or records the mismatch for later reporting (in asynchronous mode).

The expected use:

  1. malloc(N) returns a tagged pointer. The allocator picks a random 4-bit tag, writes it to the memory's tag store for the allocated region, and OR's it into the pointer's top bits.
  2. The application uses the tagged pointer for loads and stores. Every access checks: memory tag == pointer tag? OK. Mismatch? Fault.
  3. free(p) clears the memory's tag (or sets it to a new random value, depending on the implementation). Any later use of the original pointer fails — its top bits hold the old tag; the memory now has a different one. Use-after-free caught.
  4. A buffer overflow on the heap walks into the next allocation, which has a different tag. Out-of-bounds heap access caught.

The MTE instructions for the allocator:

InstructionAction
IRG Xd, XnInsert random tag into Xd's top bits (used by allocator on alloc)
STG Xt, [Xn]Store tag — write Xt's tag bits as the memory tag at [Xn]
ST2G Xt, [Xn]Store tag, 32-byte version
STZG Xt, [Xn]Store tag + zero data
LDG Xt, [Xn]Load memory's current tag into Xt
SUBP Xd, Xn, XmSubtract tagged pointers — strips tags before subtraction

The detection happens on ordinary loads and stores — no instrumentation in the inner loop, no software fast-path overhead. The 4-bit tag space means 1/16 chance of false negative (an attacker who guesses the right tag won't be caught), but that's a powerful filter for randomized bugs.

Sync vs async mode

Synchronous (sync) mode — tag mismatch faults immediately, with the faulting PC pointing at the bad access. Slower (3–8%) because the CPU must check the tag in the critical path of the load. Used in development and high-assurance production.

Asynchronous (async) mode — mismatches are recorded in a register but the access proceeds. Software polls the register periodically (or on context switch) to harvest detected mismatches. Faster (<2% overhead) because tag checks happen out of the critical path. Used in production "sampling" deployments — catch most bugs, accept some delay in detection.

Android's MTE rollout uses async mode for the kernel and sync mode for high-assurance apps; the user-space tagged-malloc (Scudo on Android) integrates with both. The kernel periodically rolls the per-allocation tag to make the detection probabilistic in time as well as space.

PACBTI on Cortex-M85

The v8.1-M PACBTI extension brings PAC and BTI to M-profile. Cortex-M85 implements it; Cortex-M55 does not. The instructions are scaled down to 32-bit Thumb encodings but the semantics are the same as AArch64:

my_m85_function:
    pac   r12, lr, sp           @ sign LR (with r12 scratch) using SP as modifier
    push  {r4, lr}
    ...
    pop   {r4, lr}
    aut   r12, lr, sp           @ authenticate LR
    bx    lr                    @ return

On Cortex-M85, function prologue is pac r12, lr, sp (1 cycle) and epilogue gains an aut r12, lr, sp (1 cycle). BTI landing pads are 1-cycle hints. The combined cost on typical RTOS workloads is ~1–2%, much like Cortex-A.

The key registers (APIAKey_EL1 on AArch64) translate to Cortex-M85's PAC_KEY_* registers, accessed via MRS/MSR. The key is set by Secure-world boot code; Non-secure code reads its own keys (different from Secure's).

For MTE, M-profile has nothing equivalent today — MTE is Cortex-A-only as of mid-2026.

Compiler enablement

GCC and Clang both support these extensions via -mbranch-protection:

# AArch64 — Linux/Android typical
-mbranch-protection=standard
    # Equivalent to: pac-ret+leaf+bti
 
# Cortex-M85
-mbranch-protection=standard -mcpu=cortex-m85

The standard setting enables PAC on returns (pac-ret), PAC on leaf functions (+leaf), and BTI (+bti). Granular options:

  • pac-ret — sign return addresses
  • pac-ret+leaf — also sign leaf-function return addresses (extra protection)
  • pac-ret+b-key — use key B instead of A
  • bti — emit BTI landing pads at indirect-branch targets

For MTE, the relevant compiler flags are part of HWASan / MTE-stack-tagging:

# Stack tagging (each stack frame gets its own tag)
clang -fsanitize=memtag-stack
 
# Heap tagging — paired with a tagged malloc (Scudo)
clang -fsanitize=memtag-heap

Adoption status (as of 2026)

Cortex-M lineup
sort:
CoreArchPipeDMIPSFPUDSPMPUTZMVENotes
Cortex-M0ARMv6-M3-stage0.84/MHz—Subset of Thumb-2.
Cortex-M0+ARMv6-M2-stage0.95/MHz—Single-cycle I/O port.
Cortex-M23ARMv8-M Baseline2-stage0.99/MHz—Secure & non-secure worlds.
Cortex-M3ARMv7-M3-stage1.25/MHz—The classic mid-range core.
Cortex-M33ARMv8-M Mainline3-stage1.50/MHzSPUsed in nRF53, STM32L5/U5.
Cortex-M4ARMv7E-M3-stage1.25/MHzSPMost popular general-purpose MCU core.
Cortex-M55ARMv8.1-M4-stage1.6/MHzSP/DP4–5× DSP/ML uplift vs. M4.
Cortex-M7ARMv7E-M6-stage2.14/MHzSP/DPUsed in i.MX RT, STM32H7.
Cortex-M85ARMv8.1-M7-stage3.0/MHzSP/DPi.MX RT700, Renesas RA8.

The general trajectory is clear: PAC + BTI is becoming default on new ARMv8.5+ silicon and new toolchains. MTE is ramping for production workloads now that the performance cost has come down on ARMv9 designs.

What these mitigations don't catch

A short list of bugs not protected:

  • Logic bugs — wrong authorization check, mis-set permission flag. No memory-safety extension helps.
  • Information disclosure — reading memory you shouldn't, without corrupting anything. MTE catches OOB read; PAC and BTI don't.
  • Side channels — Spectre, Meltdown, cache-timing. These extensions don't change microarchitectural behavior.
  • Crypto bugs — weak random, key reuse, side-channel-leaking implementations. Different problem space.
  • Use-after-free of stack pointers — MTE-stack helps; without it, returned-pointer-to-local bugs are still exploitable.

PAC + BTI + MTE shrink the attack surface for memory-corruption exploits dramatically but don't eliminate it. Defense in depth — sanitizers in CI, fuzzing, code review, ASLR + KASLR, plus these hardware extensions — is still the right model.

Cross-references

  • ARMv8-M architecture — PACBTI on Cortex-M85
  • TrustZone-M deep dive — orthogonal isolation primitive
  • Exclusive monitors — concurrency primitives, complementary
  • ARMv8-A Architecture Reference Manual, sections D5 (PAC), D6 (BTI), D7 (MTE)
  • "Pointer Authentication on ARMv8.3" (ARM whitepaper, 2017)
  • "Memory Tagging Extension: Enhancing memory safety through architecture" (Cortex-A blog, 2019)
  • Linux kernel Documentation/arm64/pointer-authentication.rst and memory-tagging-extension.rst