Security Engineering · Binary Exploitation

Shadow Stacks, Pointer Authentication, and Memory Tagging in Silicon

Three hardware answers to memory corruption differ in what they protect and what they cost: a second return stack, signed pointers, and per-allocation tags checked on every access.

Software mitigations that check addresses at runtime pay a branch on every return or every indirect call. Hardware mitigations move the check into the pipeline, where it can run alongside the memory access it guards and fault only when it fails. The three mechanisms in current silicon address different parts of the same problem, and none of them addresses all of it. Their designs differ in what they assume an attacker can write, which is the property that determines whether a bypass exists.

The shadow stack as a second copy of the return address

A call pushes a return address onto the stack, and ret pops it. The shadow stack is a second stack, in memory the program cannot write through ordinary stores, that receives the same address on call and is compared on ret. If the two differ, the ret faults.

The implementation detail that makes this workable is that the shadow stack is not simply a protected region. On the Intel CET design, a call writes the return address to both stacks, and the shadow stack pointer is a separate architectural register. A ret pops the shadow entry, compares it with the value popped from the ordinary stack, and faults if they differ. The comparison is a single check, and the fault is a control-protection exception distinct from a page fault, which lets the operating system report the failure as what it is.

The write to the shadow stack is a store to a page that the processor marks as shadow-stack type in the page tables. Ordinary stores to that page fault, which is what prevents an attacker from simply overwriting the shadow copy. The page must still be writable by the call instruction, so the protection is not a permission bit but a page type: the hardware distinguishes a store performed as part of a call from a store performed by a mov.

That distinction has a cost. The shadow stack occupies real memory, eight bytes per outstanding call, and it must be allocated per thread. A deeply recursive program needs as much shadow stack as ordinary stack, which doubles the memory committed per thread. The kernel allocates the shadow stack when the thread is created and manages its growth, and a thread that exhausts it faults rather than growing the ordinary stack.

The bypass class follows from what the shadow stack does not cover. It protects the return address and nothing else, so an overflow that corrupts a saved frame pointer, a function pointer in the frame, or a jmp_buf used by longjmp is unaffected. A stack pivot that never executes a ret also avoids the check: the shadow stack pointer is restored by the pivot’s own ret, but a chain that jumps rather than returns from the pivot is not compared against anything. Intel’s specification pairs the shadow stack with an indirect branch tracking mechanism for exactly this reason, so that a jmp to a non-endbr64 target faults.

Pointer authentication and what the signature covers

ARM’s pointer authentication signs pointers with a key and a context value. The signature occupies unused bits of the pointer, which on a 64-bit address space with a 48-bit virtual address are the high bits that would otherwise be zero or sign-extended. A signed pointer can be verified before use, and a modified pointer fails verification with overwhelming probability.

For return addresses the scheme is a sign on call and a verify on ret, using a key that is not readable from user mode and a context that includes the stack pointer. The context binding is what prevents a signed address from being replayed at a different stack depth: the signature was computed with the stack pointer as an input, so moving the value to another slot invalidates it.

The strength depends on how many signature bits are available, which in turn depends on the virtual address width. A 48-bit address space leaves sixteen bits of a 64-bit pointer for the signature, which is a 1-in-65,536 chance for a random guess. A configuration with a 52-bit address space leaves twelve bits, which is weaker. The architecture allows a larger signature at the cost of a smaller address space, and the choice is a deployment decision rather than a fixed property.

Signing and verifying are instructions, not memory accesses, so the cost is in the instruction stream rather than in memory traffic. Each protected function gains a sign in its prologue and a verify in its epilogue, and the verify branches to a failure path. The instructions are cheap but not free, and code that is not recompiled does not get them at all, which is why the mechanism is only as complete as the toolchain’s coverage.

; function entry: sign the link register with the caller's stack pointer as context
paciasp
stp  x29, x30, [sp, #-16]!

; function exit: authenticate before returning through it
ldp  x29, x30, [sp], #16
autiasp
ret

paciasp computes a signature over x30 using the stack pointer as the modifier, and stores it in the upper bits of x30. autiasp recomputes the same value and faults on mismatch, so a link register that was overwritten while the function ran cannot be returned through.

The interesting weakness is signing gadgets. A signed pointer can be used as an oracle: a gadget that signs a value under attacker control produces a valid signature for a chosen pointer, and the attacker then uses that signature. The mechanism assumes the signing key is secret and the signing instruction is only reachable through legitimate code paths; a memory-corruption bug that reaches a signing instruction breaks both assumptions. This is a general property of cryptographic protections in a process that can be made to execute chosen instructions.

Memory tagging and the granularity problem

Memory tagging assigns a four-bit tag to each 16-byte granule of memory and a matching tag to each pointer. A load or store checks that the pointer’s tag matches the granule’s tag, and a mismatch faults. The tag is stored in bits that the architecture reserves for it, so the memory overhead is zero for suitably aligned allocations, and the check happens on every access rather than only on control flow.

The tags are not secret. Their purpose is to detect an access through a pointer that does not belong to the object, which is the common shape of both a use-after-free and an overflow into an adjacent allocation. A freed object’s memory is retagged, so a stale pointer fails; an overflow into the next allocation crosses a granule whose tag differs, so it fails at the first granule it does not own.

The granularity is the design constraint. A 16-byte granule means an overflow of fewer than 16 bytes past the end of an allocation may not be detected if the neighbouring granule happens to carry the same tag. Tags are chosen randomly per allocation, so the probability of a match is 1 in 16 per granule, and an overflow that crosses several granules is caught with high probability. A single-byte overflow into a granule with the same tag is not caught. That is a probabilistic guarantee, and it is stated as such: the mechanism detects most corruptions, not all.

The deployment question is adoption. Tagged pointers require the allocator to set tags, the compiler to insert checks, and the hardware to enforce them. Code that is not compiled with tagging still runs, but its accesses are unchecked, so the protection covers only the parts of the process that participate. A library built without tagging is a hole in the coverage, and a process that loads such a library has a mixed guarantee.

Choosing among them

The three mechanisms sit at different layers. The shadow stack protects one specific datum, the return address, with a deterministic check and a memory cost proportional to call depth. Pointer authentication protects any pointer the compiler chooses to sign, with a probabilistic check whose strength depends on the available bits, and an instruction cost on every protected function. Memory tagging protects data accesses with a probabilistic check at 16-byte granularity, and its cost is in the tag manipulation and the check on every load and store.

The failure modes differ in a way that matters for how an attack evolves. The shadow stack fails only if the attacker avoids ret entirely or finds a way to write to the shadow page through a mechanism the hardware does not distinguish. Pointer authentication fails if the attacker can reach a signing instruction or if the signature bits are few enough to guess. Memory tagging fails on small overflows within a granule or on accesses through pointers that were never tagged.

The common gap is that all three are checks on the use of a corrupted value, not on the corruption itself. A write that stays within the object it was aimed at, and a read that stays within the buffer it was given, are invisible to all of them. The class of bug that produces them — an index computed from untrusted input, a length that disagrees with the allocation, a parser that trusts a count — is untouched. Hardware protection changes the cost of converting a bug into an exploit; it does not remove the bug.

That is why the software techniques described earlier remain in use against protected targets. A format string that writes a length is not a control-flow transfer and is not a tagged access. A heap corruption that stays within the chunk’s own granules passes every tag check. The hardware raises the floor, and the techniques that remain above it are the ones that never needed a corrupted address to begin with.

References

  1. Intel Control-flow Enforcement Technology Specification — Intel Corporation
  2. ARM Architecture Reference Manual, ARMv8, for ARMv8-A Architecture Profile — Arm Limited
  3. Memory Tagging and how it improves C/C++ memory safety — Serebryany et al., 2018
  4. Pointer Authentication on ARMv8.3 — Qualcomm Technologies, 2017