Using ARMv8.3 Pointer Authentication to Harden Return Addresses on AArch64
ARMv8.3 Pointer Authentication signs return addresses with a per-process key so corrupted returns fault instead of redirecting execution. This note covers the minimal compiler-flag design, trust boundaries, verification tests, and failure modes.
07 Jul 2026, 10:46 UTC

The problem: return addresses are the cheapest thing to corrupt
Stack buffer overflows remain a practical exploit path because the return address sits in writable memory. An attacker who can overwrite it redirects execution on function return. ARMv8.3 Pointer Authentication (PAC) attacks exactly this: the CPU signs the return address with a secret key before it is pushed, and verifies the signature before jumping to it. A corrupted pointer fails authentication and the process faults instead of executing attacker-chosen code.
The useful takeaway: on a PAC-capable CPU with a recent GCC or Clang, you get this protection for your own code with one compiler flag (-mbranch-protection=standard), no source changes, and roughly 1–3% overhead on integer-heavy workloads. The rest of this note covers when that is enough, and when it is not.
How the mechanism works
AArch64 pointers in a 48-bit virtual address configuration leave the upper bits unused. PAC computes a short cryptographic signature (a pointer authentication code) over the pointer value plus a context value, using a hardware key, and stores the signature in those spare bits. Two relevant key registers exist per exception level: the IA key for instruction addresses and the DA key for data addresses.
With -mbranch-protection=standard, the compiler emits paciasp on function entry (sign the link register using the stack pointer as context) and autiasp before ret (re-authenticate). If the signature does not match, the authenticated value is left in a non-canonical form, so the subsequent ret faults — typically delivered to user space as SIGSEGV, sometimes reported as SIGILL depending on kernel version. On CPUs without PAC, paciasp/autiasp execute as NOPs, so the same binary runs everywhere.
Requirements
- A CPU implementing ARMv8.3-A PAC. Check from the shell:
grep -o 'paca\|pacg' /proc/cpuinfoon Linux, or inspectID_AA64ISAR1_EL1fields if you have a bare-metal or privileged context. Run as an unprivileged user; no special permissions needed for/proc/cpuinfo. - A kernel that enables PAC for user space (mainline Linux has done so for years; assume any 5.x+ distro kernel, but verify on your target).
- GCC or Clang recent enough to support
-mbranch-protection(GCC 8+/Clang 7+ era; verify withgcc --version).
The smallest suitable design
Compile your code with:
gcc -O2 -mbranch-protection=standard -o myapp main.cstandard enables both return-address signing and branch target identification (BTI) where the CPU supports it. BTI constrains indirect jumps to valid landing pads, complementing PAC's protection of returns; together they blunt most code-reuse techniques (ROP/JOP).
Key management is handled for you: the Linux kernel generates per-process keys at execve, so two processes cannot forge each other's signatures, and keys are not shared across fork-and-exec boundaries. You do not need to touch key registers in user space, and you should not — leave them to the kernel.
Verify the flag took effect by disassembling a function:
objdump -d myapp | grep -E 'paciasp|autiasp' | headYou should see paciasp at function prologues. If you see none, check that the flag actually reached the compile step (it is easy to lose it in a build system that overrides CFLAGS).
Trust and data boundaries
The trust boundary is the key. PAC assumes the attacker can read and write process memory but cannot read the key registers. That means:
- Signatures are unforgeable only while keys stay secret. A bug that leaks a raw key register (they are not readable from EL0, so this would require a kernel or hypervisor flaw) collapses the protection.
- The signature is short — it lives in the spare address bits, so its width depends on your virtual address size and whether you use tagged pointers (top-byte ignore). Fewer spare bits means fewer signature bits and more room for brute-force guessing. Treat PAC as raising the bar, not as a proof.
- Context diversity matters. Signing the return address with the stack pointer as context means the same return address at a different stack depth gets a different signature, which limits signature-reuse attacks. The compiler flag gives you this; custom assembly that signs pointers without a context value is weaker.
Operational checks
To confirm the protection actually fires, write a small test that deliberately smashes a saved return address (a classic strcpy into an undersized stack buffer, compiled without -fstack-protector so only PAC stands between you and the corruption). Run it on the PAC-capable target:
- Expected result: the process dies with SIGSEGV (or SIGILL) at function return, before reaching the corrupted target.
- Check
dmesgfor the fault record to confirm the address is the non-canonical, failed-authentication value rather than a normal segfault address. - Control experiment: run the same binary under an environment where PAC is disabled, or on a non-PAC CPU (where the instructions are NOPs). The corruption should now behave like a classic overflow — a jump to a wrong but canonical address, or a crash at a different site. The difference in failure location confirms PAC caused the earlier fault.
Do not claim the protection works in production based only on the presence of paciasp in the binary; the corruption test is the meaningful check.
Failure modes and limits
- Mixed binaries. Libraries built without PAC still run, but their returns are unprotected. An attacker can target the unprotected library's frames. Audit your dependency closure (
objdumpthe shared objects you ship) if the threat model justifies it. - Assembly and JITs. Hand-written assembly that manipulates the link register must use the matching
paciasp/autiasppair or it will fault. JIT compilers must emit PAC-aware epilogues or run with PAC disabled for their code paths. - setjmp/longjmp and stack unwinding. These are handled by modern glibc and libgcc, but an old or custom runtime can break. If longjmp crashes after enabling PAC, suspect the runtime first.
- Performance. Expect roughly 1–3% on call-heavy integer code; measure your own workload rather than trusting any published figure.
- Signal handlers and debuggers. Some older debuggers mishandle signed pointers in backtraces. If your crash tooling shows mangled addresses, strip the signature bits (mask the top byte region) before symbolizing.
What would change the design
Reconsider if: you must support CPUs without PAC and cannot ship a single binary (the NOP behavior usually solves this, but verify on your oldest target); your threat model includes attackers who can repeatedly probe and observe faults (short signatures are guessable given enough attempts); or you already control the full software stack and can adopt a stronger model such as CFI plus memory tagging (MTE) for the corruption itself rather than its exploitation. For most user-space services on ARM64 Linux, though, the single compiler flag plus a corruption test is the right-sized design.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.