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.
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
callpushed?"
Different mechanisms protect each edge, and a complete deployment needs both.
| Scheme | Edge | Granularity | Platform | Enforcement |
|---|---|---|---|---|
Clang CFI (-fsanitize=cfi) | Forward | Type-based | Any, needs LTO | Software |
Microsoft CFG (/guard:cf) | Forward | Coarse (valid call targets) | Windows | Software + OS |
| Intel IBT (CET) | Forward | Coarse (ENDBR landing pads) | x86-64 | Hardware |
| Arm BTI | Forward | Coarse (BTI landing pads) | AArch64 | Hardware |
| Intel shadow stack (CET) | Backward | Exact | x86-64 | Hardware |
| Arm PAC (return address signing) | Backward | Cryptographic | AArch64 | Hardware |
| Clang ShadowCallStack | Backward | Exact | AArch64 (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:
- A CPU with CET (Intel since Tiger Lake, AMD since Zen 3).
- 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. - A C library that activates it for compatible binaries. In glibc this is controlled at build time and by the
glibc.cpu.x86_shstktunable. - 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.
Related hardware: memory tagging
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
| Gap | Why it remains |
|---|---|
| Valid-but-wrong targets | Coarse schemes allow any function entry; type-based schemes allow any function with the same signature |
| Data-only attacks | Corrupting a flag, a length or a permission check changes behaviour without an illegal control transfer |
| Information leaks | Out-of-bounds reads are not control-flow events |
| JIT and dynamically generated code | Needs its own integrity schemes |
| Incompatible components | One 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=fulland 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:cfand/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.