Skip to content

Control-Flow Integrity: CFI, Intel CET and Arm PAC/BTI

How forward-edge and backward-edge control-flow integrity work, from Clang CFI and Microsoft CFG to Intel CET shadow stacks and Arm pointer authentication, and their limits.

Published on 6 min read

Once non-executable memory became universal, attackers stopped injecting code and started reusing it: redirecting an indirect call or a return to existing instructions that do something useful for them. Techniques in this family, the best known being return-oriented programming, are the reason control-flow integrity (CFI) exists. CFI makes the program check, at runtime, that every indirect transfer of control goes somewhere the original code could legitimately go. This guide explains the main CFI schemes on Linux, Windows and Arm, how to enable them, and what they do not cover. It builds on the classic mitigations in binary hardening flags.

Forward edges and backward edges

A program's control flow has two kinds of indirect transfers:

  forward edge                         backward edge
  (indirect call / jump)               (return)

  caller                               callee
    |  call *%rax  ---------->  f()      |  ret  ---------->  caller+N
    |                                    |
    +-- where may %rax point?            +-- is the return address the
                                             one pushed by the call?
  • Forward edges are calls through function pointers, C++ virtual calls, and indirect jumps. The question is "is this target a legitimate destination for this call site?"
  • Backward edges are returns. The question is "is this the address the matching call pushed?"

Different mechanisms protect each edge, and a complete deployment needs both.

SchemeEdgeGranularityPlatformEnforcement
Clang CFI (-fsanitize=cfi)ForwardType-basedAny, needs LTOSoftware
Microsoft CFG (/guard:cf)ForwardCoarse (valid call targets)WindowsSoftware + OS
Intel IBT (CET)ForwardCoarse (ENDBR landing pads)x86-64Hardware
Arm BTIForwardCoarse (BTI landing pads)AArch64Hardware
Intel shadow stack (CET)BackwardExactx86-64Hardware
Arm PAC (return address signing)BackwardCryptographicAArch64Hardware
Clang ShadowCallStackBackwardExactAArch64 (and RISC-V)Software, register-reserved

Forward-edge CFI

Clang CFI

Clang's -fsanitize=cfi family checks, at every indirect call, that the target function's type matches the type expected at the call site. A virtual call on a Widget* may only land on a Widget method; a call through int (*)(const char *) may only reach functions with that signature. Because the compiler needs to see the whole program to build these type sets, CFI requires link-time optimization and hidden visibility:

clang++ -O2 -flto -fvisibility=hidden -fsanitize=cfi \
        -fno-sanitize-trap=cfi -fsanitize-recover=cfi \
        -o app app.cpp        # diagnostic mode, for testing

clang++ -O2 -flto -fvisibility=hidden -fsanitize=cfi -o app app.cpp  # trapping mode, for production

The diagnostic mode prints the violating call site, which is useful when first enabling CFI on a large code base: casts between unrelated function types, a common C idiom, show up as violations and need fixing. Chromium ships with Clang CFI enabled on several platforms, which demonstrates that fine-grained forward-edge CFI is practical at scale.

Microsoft Control Flow Guard

CFG (/guard:cf at compile and link time) records every address that is a valid indirect call target in a bitmap maintained by the OS. Before each indirect call, a check verifies the target is in the set. It is coarse: any valid function entry passes. Microsoft has since experimented with a type-aware extension (XFG) for finer granularity. On Windows, CFG is widely deployed across system binaries.

Intel IBT and Arm BTI

Both are hardware landing-pad schemes. The compiler inserts a marker instruction at every location that may legitimately be reached by an indirect branch: ENDBR64 on x86-64, BTI on AArch64. When enforcement is on, an indirect call or jump that lands anywhere else raises a fault. They are coarse-grained like CFG, but have almost no runtime cost and require no whole-program analysis.

# x86-64: emit ENDBR landing pads and shadow-stack compatibility markers
gcc -O2 -fcf-protection=full -o app app.c

# AArch64: return-address signing (PAC) + BTI landing pads
gcc -O2 -mbranch-protection=standard -o app app.c

You can confirm the markers are present in the ELF properties:

readelf -nW ./app | grep -A2 'GNU_PROPERTY'
      Properties: x86 feature: IBT, SHSTK

On AArch64 the same note reports AArch64 feature: BTI, PAC. If any object linked into the binary, including a static library or a hand-written assembly file, lacks the property, the linker drops it for the whole binary. That is the most common reason CET or BTI silently ends up disabled.

Backward-edge CFI

Shadow stacks (Intel CET)

A shadow stack is a second stack, protected by the hardware and the OS, that holds only return addresses. Every call pushes the return address on both stacks; every ret compares the two and faults on mismatch (a control-protection exception, delivered as SIGSEGV on Linux). Normal memory writes cannot modify shadow-stack pages, so a stack buffer overflow that overwrites the return address is caught at the next return, even if it skipped the stack canary.

Enforcement requires the whole chain:

  1. A CPU with CET (Intel since Tiger Lake, AMD since Zen 3).
  2. OS support. Windows enables Hardware-enforced Stack Protection for binaries linked with /CETCOMPAT. On Linux, user-space shadow stack support was merged in kernel 6.6 for x86-64.
  3. A C library that activates it for compatible binaries. In glibc this is controlled at build time and by the glibc.cpu.x86_shstk tunable.
  4. Every loaded object marked compatible, as shown above.

See the shadow stack glossary entry for a short definition.

Pointer authentication (Arm PAC)

On Armv8.3-A and later, pointer authentication stores a short cryptographic signature in the unused upper bits of a pointer. With -mbranch-protection=standard (or pac-ret), functions sign the return address in the link register on entry (PACIASP) and verify it before returning (AUTIASP). A modified return address fails authentication and faults when used. Apple platforms use PAC extensively (the arm64e ABI), and Linux supports it for user space on capable hardware.

ShadowCallStack

Clang's -fsanitize=shadow-call-stack is a software shadow stack for AArch64 that reserves register x18 to point to a separate stack of return addresses. Android uses it in parts of the platform and kernel. It depends on keeping the shadow stack's location secret, which makes it weaker than hardware enforcement but available on processors without PAC.

Arm's Memory Tagging Extension (MTE, Armv8.5-A) is not CFI, but it is worth knowing alongside it. It assigns a 4-bit tag to each 16-byte granule of memory and to pointers; a mismatch between the pointer's tag and the memory's tag is detected on access. This catches many heap overflows and use-after-free bugs probabilistically, at the source rather than at the control-flow hijack. Android supports MTE on devices whose SoCs implement it, and it is the hardware basis for HWASan-like checking with very low overhead.

What CFI does not stop

GapWhy it remains
Valid-but-wrong targetsCoarse schemes allow any function entry; type-based schemes allow any function with the same signature
Data-only attacksCorrupting a flag, a length or a permission check changes behaviour without an illegal control transfer
Information leaksOut-of-bounds reads are not control-flow events
JIT and dynamically generated codeNeeds its own integrity schemes
Incompatible componentsOne unmarked library can disable enforcement for the whole process

CFI is therefore a layer that sits on top of bug prevention and detection, not a substitute for them. The combination that works is: memory-safe code where possible, sanitizers and fuzzing to find what remains, classic hardening to make exploitation expensive, and CFI to remove the easiest ways to hijack control flow.

Deployment checklist

  • Build x86-64 code with -fcf-protection=full and AArch64 code with -mbranch-protection=standard; check the ELF property notes in CI.
  • Audit hand-written assembly and prebuilt static libraries, which commonly lack the CET/BTI markers.
  • On Windows, link with /guard:cf and /CETCOMPAT.
  • For C++ services where you control the whole build, evaluate Clang CFI with LTO in diagnostic mode first, then trapping mode.
  • Test on hardware and kernels where enforcement is actually on, not only where the markers are present.