Breaking the Chain: A Hardware Tour of Control Flow Integrity - Part 1

For the better part of two decades, control-flow hijacking has been the go-to technique for exploiting memory corruption. If you can overwrite a single code pointer—whether that’s a return address, a function pointer, or a C++ vtable slot—you can redirect execution anywhere you want, without needing to inject any custom shellcode. Intel’s Control-flow Enforcement Technology (CET) was designed to tackle this exact problem at the hardware level. It turns certain control-flow transfers into illegal operations, with the CPU enforcing these rules on the fly.

CET breaks down into two main components: a shadow stack to protect function returns, and Indirect Branch Tracking (IBT) to secure indirect calls and jumps.

In this post, we’ll break down both features from first principles. We’ll look at the two control-flow “edges” attackers love to abuse, how techniques like ROP and JOP/COP work, and how CET shuts them down. We’ll also dig into the new user-space instructions (complete with assembly examples), explore how things like thread switching and C++ exceptions play along, and finally disassemble a real binary to see exactly what the compiler generates. We’ll also clear up some common misconceptions—like how CET compares to older, probabilistic defenses like ASLR and stack canaries.

Just a quick heads-up on scope: this is Part 1, so we’re strictly focusing on user-space CET. We’ll save the kernel-side mechanics (like supervisor shadow stacks and the scheduler’s save-and-restore paths) for Part 2.

#The two decisions a program makes on the fly

Think of a running program like a passenger navigating a busy airport. Most of the trip is planned out in advance on the itinerary. But two crucial things are decided on the fly: which gate to board next, and where checked luggage gets routed. These two decisions rely on data—boarding passes and luggage tags. In software, attackers exploit memory corruption bugs to rewrite this data. Swap the boarding pass, and the passenger boards the wrong plane. Swap the luggage tag, and the bag gets routed to the attacker’s carousel. That, in a nutshell, is control-flow hijacking.

CET implements two hardware-level security checkpoints to stop this:

  1. The Shadow Stack: To protect the luggage, the airline keeps an untouchable, sealed copy of every luggage tag. When the bag arrives, the two tags are compared. If they don’t match, someone tampered with the tag, and the bag is flagged.
  2. Indirect Branch Tracking (IBT): To protect boarding, passengers can only board through officially designated, staffed gates. If a ticket tells them to board through a maintenance door or walk out onto the tarmac, security stops them.

The most important takeaway here is that CET’s security is structural, not secret. There are no hidden canary values to leak. The system just enforces strict, verifiable rules.

#Control flow and its two edges

A control-flow transfer is just any instruction that changes the instruction pointer (RIP on x86-64) to something other than the next instruction in memory. There are two flavors, depending on where the target address comes from:

  • Direct transfers bake the target address right into the instruction itself. Things like call 0x11a9 and jmp 0x1250. Because the target is fixed at link time and lives in read-only memory, attackers generally can’t mess with it.
  • Indirect transfers read their target from a register or memory location at runtime. Think call *%rax, jmp *%rdx, or ret. Because the target is just data, memory corruption bugs let attackers control where these jump.

Every jump a program makes involves one of two “edges”—corresponding to our airport passenger’s boarding pass and luggage tag. If we look at a program as a control-flow graph:

  • The forward edge is a transfer into a function you’re calling—like an indirect call or jmp. It answers the question, “where are we going next?” The target usually comes from a function pointer, a vtable entry, or a jump table.
  • The backward edge is the ret instruction that takes you back to whoever called the current function. It answers, “where do we return to?” The target is the return address that was pushed onto the stack earlier.

Because both edges read their targets from data, both are vulnerable. Overwrite a saved return address on the stack, and when the function returns, the CPU pops your malicious address into RIP (the backward edge). Overwrite a function pointer on the heap, and when the program calls it, it jumps to your payload (the forward edge).

Data Execution Prevention (NX/DEP) stopped attackers from injecting new executable code, but it did nothing to stop them from changing these data pointers to reuse existing code.

#The threat model: ROP and JOP/COP

Once NX became standard, attackers adapted by reusing code that was already present in the process. They string together short snippets of existing instructions (called gadgets) that end in an indirect transfer. By chaining these gadgets together, they can execute arbitrary logic.

There are two main families of code reuse attacks, and they map perfectly to our two edges:

  • Return-Oriented Programming (ROP) attacks the backward edge. Every gadget ends in a ret. The attacker floods the stack with a list of gadget addresses. Since ret pops its target from the stack, controlling the stack means controlling the execution flow. The stack effectively becomes the program script, and ret acts as the interpreter.
  • Jump-Oriented and Call-Oriented Programming (JOP/COP) attack the forward edge. These gadgets end in jmp *reg or call *reg. Because there’s no ret to naturally drive the chain from the stack, the attacker uses a “dispatcher” gadget to iterate through a table of addresses and jump to them. These techniques were specifically created to bypass defenses that only protected returns.
Technique Edge Terminating instruction Control vector
ROP backward ret corrupted return addresses on the stack
JOP forward jmp *reg corrupted pointer + dispatcher
COP forward call *reg corrupted pointer + dispatcher

Because attackers can exploit both edges, a complete defense needs to protect both. That’s why CET isn’t just one feature—the shadow stack locks down the backward edge, while Indirect Branch Tracking handles the forward edge.

#The shadow stack

The backward edge is vulnerable because return addresses are stored on the same writable stack as the local variables and buffers that attackers overflow. The shadow stack fixes this by physically separating them.

As the name implies, it’s a secondary stack used only for storing return addresses. The kernel allocates it using a special shadow-stack page type, meaning normal memory writes (like the mov instructions used in a buffer overflow) will trigger a page fault if they try to touch it. Only the CPU’s internal call/return operations and a few specific CET instructions are allowed to modify it. The CPU uses a dedicated register (the Shadow Stack Pointer, or SSP) to track the top of the shadow stack.

Here’s how it works in practice:

  1. When a call happens, the CPU pushes the return address to the normal stack like always. But it also pushes a copy to the shadow stack and decrements the SSP.
  2. When a ret happens, the CPU pops the return address from the normal stack, pops the top of the shadow stack, and compares the two.
  3. If they match, execution continues normally. If they differ, the CPU throws a #CP (Control Protection) exception.

On Linux, this #CP exception gets surfaced to the application as a SIGSEGV with si_code == SEGV_CPERR. By default, this immediately kills the process. There’s no graceful recovery—if someone tries to hijack the control flow, the program crashes rather than handing over control.

An attacker can still overflow a buffer and overwrite the return address on the normal stack, but they can’t touch the shadow copy. When the function returns, the CPU spots the mismatch and halts the program.

#Geometry: base, growth, push, pop

Just like the standard x86 stack, the shadow stack grows downwards towards lower addresses. Let’s say the kernel maps it at [0x7ffff7a00000, 0x7ffff7a08000). The SSP starts at the top (highest address) and moves down as functions are called:

1
2
3
4
5
Address (grows down)         SSP after...           Shadow stack contents
0x7ffff7a07ff8 <-- base (empty)
0x7ffff7a07ff0 <-- SSP after call A -> push [ ret addr into A's caller ]
0x7ffff7a07fe8 <-- SSP after call B -> push [ ret addr into A ]
0x7ffff7a07fe0 <-- SSP after call C -> push [ ret addr into B ]

It’s a perfect mirror of the normal call stack, but it only contains return addresses.

#How a pop fails

The verification at ret is a simple 8-byte comparison between what was popped from RSP and what was popped from SSP. A mismatch usually happens for one of three reasons:

  • A buffer overflow clobbered the return address on the normal stack (a classic ROP attempt).
  • The code executed an unbalanced ret without a matching call.
  • The two stacks fell out of sync because software manually unwound the normal stack without updating the shadow stack. (This is why things like C++ exceptions and coroutines need special handling, which we’ll cover in a bit).

#Indirect Branch Tracking (IBT)

The shadow stack does nothing to protect the forward edge. An indirect call *%rax will happily push a valid return address to the shadow stack and then jump straight into an attacker’s payload. This is where IBT steps in.

IBT enforces one simple rule: the target of any indirect call or jmp must be a specific instruction called endbr64 (endbr32 in 32-bit mode). Think of it as a mandatory landing pad.

Here’s the state machine:

  1. An indirect call or jmp executes, putting the CPU into a WAIT_FOR_ENDBRANCH state.
  2. The CPU fetches the instruction at the target address.
  3. If the instruction is endbr64, the state clears and execution continues normally.
  4. If it’s anything else, the CPU throws a #CP exception.

When you compile code with CET enabled, the compiler automatically inserts an endbr64 instruction at the beginning of every function that could be called indirectly (like virtual methods or functions whose addresses are taken). Normal indirect calls will naturally land on these pads.

JOP and COP gadgets, however, typically start in the middle of a function or instruction stream to do something specific. Since there’s no endbr64 there, trying to jump to a gadget instantly faults.

Crucially, endbr64 is encoded using the hint-NOP space (F3 0F 1E FA). On older CPUs that don’t support CET, it’s just safely ignored as a no-op. This means you can compile a single binary that benefits from IBT on new hardware but still runs perfectly fine on legacy machines.

It’s important to note that IBT is coarse-grained. It only checks that you landed on an endbr64, not necessarily the right one. If an attacker controls a function pointer, they can still point it at any valid function entry point in the program. IBT doesn’t provide perfect control-flow integrity, but it drastically reduces the number of usable targets.

Also, unlike Arm’s Pointer Authentication (PAC), IBT does not use cryptography or signatures. It’s just a simple opcode check. We’ll dive more into how Arm handles this later in the series.

#Deterministic versus probabilistic defense

It’s worth highlighting why CET is such a big deal compared to older mitigations.

Defenses like ASLR and stack canaries are probabilistic. They rely on keeping a secret (the memory layout or the canary value) hidden from the attacker. If an attacker finds an information leak, they can bypass the defense entirely.

CET, on the other hand, is deterministic. The shadow stack guarantee is baked into the hardware architecture. IBT’s landing pads are structural. You could give an attacker a full memory read primitive so they know exactly where everything is, and they still couldn’t execute a ROP chain because the CPU will physically block the mismatched ret. It replaces a probabilistic gamble with a hard structural rule.

#The user-space instruction set

CET introduces a few new instructions. Since the CPU handles the shadow stack automatically during calls and returns, user-space code only needs instructions to inspect, unwind, or switch the SSP.

Here are the key instructions (the D/Q suffixes denote 32-bit or 64-bit operands).

RDSSPD / RDSSPQ — Read the current shadow stack pointer into a general-purpose register. Interestingly, if shadow stacks are disabled, this instruction acts as a NOP and leaves the destination register untouched. This gives you a clever, branch-free way to check if CET is active:

1
2
3
4
5
read_ssp:
endbr64
xor %rax, %rax
rdsspq %rax ; rax gets SSP, or stays 0 if CET is off
ret

INCSSPD / INCSSPQ — Discard N entries from the shadow stack by incrementing the SSP. Instead of executing multiple rets, an unwinder can just bump the SSP up to match the normal stack. It unwinds up to 255 frames at a time.

1
2
3
4
5
pop_two_frames:
endbr64
mov $2, %eax
incsspq %rax ; discard 2 shadow-stack entries in one step
ret

RSTORSSP — Switch to a different shadow stack. You can’t just mov a value into the SSP. Instead, this instruction reads a special “restore token” from the target shadow stack, validates it atomically, and then updates the SSP.

SAVEPREVSSP — Usually paired with RSTORSSP, this drops a restore token on the current shadow stack before you switch away, allowing you to safely return to it later. Together, these two instructions make thread switching and coroutines possible.

WRSSD / WRSSQ — Write directly to the shadow stack. Normal memory writes will fault, but this instruction explicitly allows modifying the shadow stack. It’s only permitted if a specific control bit (WR_SHSTK_EN) is set, and it’s mostly used by specialized runtimes.

1
2
3
4
write_ss:                    
endbr64
wrssq %rsi, (%rdi) ; store rsi into shadow-stack slot at [rdi]
ret

(Note: There is also WRUSSD / WRUSSQ, which lets the kernel write to a user-mode shadow stack, but that’s supervisor-only and belongs in Part 2).

#Checking whether CET is on

Just because your CPU supports CET doesn’t mean it’s turned on for your process.

On Linux, you can check CPU support via /proc/cpuinfo (look for the user_shstk and ibt flags). But to know if it’s actually active for your thread, you either need to query the OS using arch_prctl, or just use the RDSSPQ trick shown above.

#The user-space programming model

While the hardware enforces the rules, the software stack still has to play along. Here’s what needs to happen under the hood:

Allocation at startup: CET is strictly opt-in. The compiler embeds a special ELF note (.note.gnu.property) in the binary. When you run the program, the loader checks this note for the main executable and every shared library it loads. It only enables CET if all of them support it. If even one dusty old .so file lacks the note, the loader silently leaves CET off to prevent crashing.

Thread switching: Every thread gets its own shadow stack, usually allocated via the map_shadow_stack syscall. When the OS switches threads, it swaps out the SSP registers behind the scenes.

Stack unwinding: When a C++ exception is thrown (or longjmp is called), the code might skip over several stack frames. The unwinder has to manually issue INCSSPQ instructions to discard the corresponding shadow stack entries so the two stacks stay perfectly synchronized. If it doesn’t, the very next ret will trigger a #CP fault.

Coroutines and Green Threads: Any user-space code that implements its own threading (like fibers or coroutines) has to manually allocate a shadow stack for each context and use RSTORSSP / SAVEPREVSSP when swapping between them. Because older coroutine libraries often just swap RSP directly, they are a notorious source of crashes when running on CET-enabled systems.

#Building and disassembling a real binary

Let’s look at how this all comes together in practice with a simple C++ program that uses direct calls, indirect calls, and virtual methods.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// cet_demo.cpp
#include <cstdio>

int greet(int x) { return x + 1; } // Target of a direct call
int via_pointer(int x) { return x * 2; } // Target of an indirect call

struct Base { // Virtual -> vtable call
virtual int compute(int x) { return x - 1; }
virtual ~Base() = default;
};

int main() {
int a = greet(41); // Direct call
int (*fp)(int) = via_pointer; // Function pointer setup
int b = fp(a); // Indirect call: callq *reg
Base obj; Base* p = &obj;
int c = p->compute(b); // Virtual call: callq *(vtable slot)
printf("a=%d b=%d c=%d\n", a, b, c);
return 0;
}

Let’s compile it twice: once with CET enabled (-fcf-protection=full), and once with it disabled (=none).

1
2
3
4
$ g++ -O0 -fcf-protection=full -o cet_demo     cet_demo.cpp
$ g++ -O0 -fcf-protection=none -o cet_demo_off cet_demo.cpp
$ ./cet_demo
a=42 b=84 c=83

#Checking the ELF Note

First, let’s verify that the CET compiler flag actually added the opt-in note:

1
2
3
4
5
6
7
8
9
10
$ readelf -n cet_demo
Displaying notes found in: .note.gnu.property
Owner Data size Description
GNU 0x00000020 NT_GNU_PROPERTY_TYPE_0
Properties: x86 feature: IBT, SHSTK
x86 ISA needed: x86-64-baseline
...

$ readelf -n cet_demo_off
Properties: x86 ISA needed: x86-64-baseline

As expected, the full build explicitly requests IBT and SHSTK.

#Spotting the landing pads

If we disassemble the binaries, we can see IBT in action. Here are the functions from the full build:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
00000000000011a9 <_Z5greeti>:          ; int greet(int)
11a9: endbr64 ; <-- Mandatory landing pad!
11ad: push %rbp
11ae: mov %rsp,%rbp
...

00000000000011bc <_Z11via_pointeri>: ; int via_pointer(int)
11bc: endbr64 ; <-- Landing pad
11c0: push %rbp
...

00000000000011ce <main>:
11ce: endbr64 ; <-- Landing pad
11d2: push %rbp
...

Every single function starts with an endbr64. If we look at the none build:

1
2
3
4
0000000000001169 <_Z5greeti>:
1169: push %rbp ; No endbr64 here
116a: mov %rsp,%rbp
...

The landing pads are completely gone.

If we trace the calls inside main (from the full build), we can see how the different transfers play out:

1
2
3
4
5
6
7
11ef:  call   11a9 <_Z5greeti>        ; DIRECT call
...
120b: call *%rdx ; INDIRECT call (via function pointer)
...
1239: call *%rcx ; INDIRECT call (via vtable)
...
12a9: ret ; BACKWARD edge return

Even though greet is only called directly at 11ef, it still gets an endbr64 because the compiler plays it safe. The indirect calls at 120b and 1239 are exactly the kind of branches IBT protects—they are forced to land on the endbr64 of their respective targets. And finally, the ret at 12a9 is guarded by the shadow stack.

#Common misconceptions

  1. “CET stops all memory-corruption exploits.” - Thats not true. CET purely protects control flow. If an attacker uses a buffer overflow to change a sensitive variable (like an isAdmin boolean or a user ID), CET won’t do a thing. It’s not generic memory safety.
  2. “IBT guarantees an indirect call reaches the exact intended function.” - False. IBT is coarse-grained. It just verifies that the target has an endbr64 instruction. An attacker can still hijack a function pointer to jump to any other valid function entry point in the program.
  3. “The shadow stack is just memory, so an attacker can overflow that too.” - Not quite. The kernel configures shadow stack pages so that standard mov or store instructions trigger a fault. Unless the attacker can magically hijack the specific WRSS hardware instructions, the shadow stack is untouchable.
  4. “Compiling with -fcf-protection means my program is safe.” - Only if everything aligns. You need a CPU that supports CET, an OS kernel that enables it, and every single shared library your program links against must also be compiled with CET support. A single legacy dependency will silently disable CET for the entire process.
  5. “Since we have CET, we can turn off ASLR and stack canaries to save performance.” - Please don’t. They are complementary defenses. Canaries can catch non-control-flow buffer overflows, and ASLR makes it harder for attackers to locate data targets that CET doesn’t cover. Defense in depth is still the name of the game.

#Summary

At its core, control-flow hijacking targets two specific avenues: the forward edge (indirect calls/jumps used in JOP/COP) and the backward edge (returns used in ROP).

Intel CET locks down both by enforcing structural, hardware-level rules rather than relying on hidden secrets. The shadow stack maintains a secure, hardware-managed copy of return addresses, while IBT forces all indirect jumps to land on designated endbr64 instructions.

This two-edge model isn’t just an x86 concept—it’s universal. In Part 2, we’ll stay in x86-land and look at how the Linux kernel manages supervisor shadow stacks and context switching. After that, we’ll shift gears and look at how Arm handles these exact same problems using a very different toolkit (BTI, PAC, and GCS).

Comments

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×