I was always fascinated by projects like Unicorn Engine and Capstone Engine where the author repurposed existing software development tools, such as QEMU and LLVM, for Software Security. While working on Reverse Engineering a binary in the MIPS WiFi Router. I needed to intercept and manipulate Syscall data exchange between router application with a kernel driver. I couldn’t find any open-source tool that could do that for MIPS architecture, then it stuck me that was the perfect opportunity to do something like Unicorn project.
For about twenty years, control-flow hijacking has been the engine of memory-corruption exploitation. Corrupt a single code pointer — a return address, a function pointer, a C++ vtable slot — and you can redirect execution wherever you like without injecting a single instruction of your own. Intel’s Control-flow Enforcement Technology (CET) goes after this directly: it makes certain control-flow transfers illegal in hardware, and the CPU checks the rule on every instruction that matters. CET has two halves — a shadow stack that protects function returns, and Indirect Branch Tracking (IBT) that protects indirect calls and jumps.
This post builds both from first principles. We start with what a control-flow transfer actually is, work out the two “edges” an attacker gets to corrupt, and pin down how ROP and JOP/COP exploit them. Then we see how the shadow stack and IBT enforce each edge in hardware, document every user-space instruction (with real, assembled examples), walk through the user-mode programming model — thread switches, C++ exceptions, coroutines — and finish by compiling and disassembling a real binary so you can watch the machinery appear in the generated code. Along the way we contrast CET’s deterministic guarantee with the probabilistic mitigations (ASLR, stack canaries) it complements, and close on the misconceptions that trip people up.
A note on scope. This is Part 1, and it covers user-space CET only. Kernel-side concerns — supervisor shadow stacks, SETSSBSY/CLRSSBSY, the scheduler’s SSP save-and-restore path — are Part 2 material, and they show up here only where a user-space concept forces us to mention them.
Picture a running program as a passenger connecting through a transit airport. Most of the journey is printed on the ticket in advance — this flight, then that one — and nothing an attacker outside the airport can do will reprint it. But two things are decided in the moment. At each connection the passenger is handed a boarding assignment and sent to a gate for the next flight, and every checked bag carries a routing tag that says where it should be returned to its owner. Those two decisions — which gate to board next and where the luggage comes back — are driven by data written at the airport, and data is exactly what a memory-corruption bug lets an attacker rewrite. Swap the boarding assignment and the passenger walks onto a plane of your choosing; re-label the luggage tag and the bag is routed to your carousel instead of theirs. That is control-flow hijacking, reduced to its two moving parts.
Two defenses fall straight out of that picture, and together they are the whole of CET. To protect the luggage, the airline seals its own copy of each bag’s routing tag into a system the passenger can’t reach the instant the bag is checked. At the destination it lays the tag on the bag beside the sealed copy; if the two don’t match, someone re-labeled the bag in transit, and it’s pulled from the belt. That is the shadow stack. To protect boarding, a passenger may step onto a plane only through a real, staffed gate — a marked jet bridge. A boarding assignment pointing anywhere else, out onto the tarmac or into a service corridor, is refused at the door. That is Indirect Branch Tracking. Hold onto one thing about both as you read on: the guarantee is structural, not secret. The sealed tag and the gate locations aren’t hidden from the attacker — you could post them on a board — the security is that you can’t forge past the match, or board anywhere but a gate.
The rest of the post earns these two pictures in hardware. The passenger’s two on-the-fly decisions have precise names, so we start there.
A control-flow transfer is any instruction that sets the instruction pointer (RIP on x86-64) to something other than “the address right after me.” There are two kinds, and the distinction is where the target comes from.
A direct transfer encodes its target as a constant which is part of the instruction. call 0x11a9 and jmp 0x1250 are direct. The target is fixed at link time and lives in read-only code, so an attacker who can’t rewrite the code segment can’t change it.
An indirect transfer reads its target from a register or memory operand computed at run time. call *%rax, jmp *%rdx, call *(%rax), and ret are all indirect. Their targets are data, and data is exactly what a memory-corruption bug lets an attacker control.
Every transfer a program makes lands on one of two edges — the passenger’s “which gate to board next” and “where the luggage comes back,” respectively. The names come from drawing the program as a control-flow graph:
The forward edge is a transfer into a function you’re about to enter — an indirect call or indirect jmp. It answers “where does execution go next?” The target comes from a function pointer, a vtable entry, a PLT/GOT slot, or a jump table.
The backward edge is the ret that takes you back to the caller. It answers “where does execution return to?” The target is the return address that a matching call pushed onto the stack earlier.
Both edges are indirect, both read their target from data, and that is why both are attackable. A stack buffer overflow overwrites the saved return address; when the function runs ret, the CPU pops the attacker’s value into RIP. That’s the backward edge. A heap overflow or use-after-free overwrites a function pointer or an object’s vtable pointer; when the program does call *%rax or call *(%rax), it jumps wherever the attacker wrote. That’s the forward edge.
Non-executable memory (NX/DEP) shut the door on injecting new code, but it did nothing here. Both attacks only change a data pointer — they never write an instruction — so NX never sees them. That gap is what the next section exploits.
Once NX was widespread, attackers stopped injecting shellcode and started reusing the code already in the process. The unit of reuse is a gadget: a short run of existing instructions ending in an indirect transfer. String gadgets together so each one’s terminating transfer lands on the next, and you have a gadget chain — arbitrary computation assembled entirely out of borrowed instructions.
There are two families, and they map cleanly onto the two edges.
Return-Oriented Programming (ROP) abuses the backward edge. Every gadget ends in ret. The attacker’s overflow fills the stack with a list of addresses, and since ret pops its target from the stack, controlling the stack controls the whole chain: ret runs gadget one, whose own ret pops the address of gadget two, and so on. The stack has become the attacker’s program and ret is the interpreter.
Jump-Oriented and Call-Oriented Programming (JOP/COP) abuse the forward edge. Gadgets end in jmp *reg (JOP) or call *reg (COP). There’s no ret driving the chain from the stack, so the attacker uses a dispatcher gadget that walks a pointer through a table of gadget addresses and indirect-jumps to each in turn. JOP and COP exist specifically to get around defenses that only watch 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
Two edges, two families. Any complete defense has to cover both, which is why CET is two mechanisms rather than one. The shadow stack defends the backward edge; Indirect Branch Tracking defends the forward edge.
The backward edge is soft because the return address sits on the same writable stack as the buffers an attacker overflows. The shadow stack breaks that shared fate.
A shadow stack is a second stack that holds nothing but return addresses. The kernel allocates it and marks its pages with a special shadow-stack page type, so ordinary stores — the movs an overflow uses — can’t write to it. This isn’t read-only memory; it’s a distinct memory type. Only the CPU’s own call/return machinery and a handful of privileged CET instructions can change it. The Shadow Stack Pointer (SSP) is a dedicated register, backed by an MSR rather than a general-purpose register, pointing at the top of the current shadow stack.
The check is a redundancy test on every call/return pair:
call pushes the return address to the ordinary stack, as always, and also pushes an identical copy to the shadow stack, decrementing SSP.
ret pops the return address from the ordinary stack and pops the top of the shadow stack, then compares the two.
Equal, and the return proceeds. Different, and the CPU raises a #CP (Control Protection) exception — a new fault vector, 21, introduced with CET.
On Linux, #CP surfaces to user space as a SIGSEGV with si_code == SEGV_CPERR (“control protection error”), which by default terminates the process. So a detected mismatch isn’t a recoverable event the program negotiates — it’s a hard stop, which is exactly the point: an attempted control-flow hijack ends the process instead of continuing into a gadget.
A stack overflow can still clobber the return address on the ordinary stack, but it can’t reach the shadow copy. At ret the two disagree and the process faults instead of jumping into a gadget. The backward edge is now guarded by an invariant the attacker’s write primitive never touches. This is the sealed luggage tag from the opening made literal: the ordinary stack is the tag stuck on the bag, the shadow stack is the airline’s sealed copy the passenger can’t reach, and ret is the moment at the carousel when the two are checked against each other.
The shadow stack grows toward lower addresses, exactly like the ordinary x86 stack. Say the kernel mapped it over [0x7ffff7a00000, 0x7ffff7a08000). SSP starts at the high end — the base, top of the allocation — and moves down as frames are pushed:
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 ]
A push happens implicitly on call: SSP drops by 8 (in 64-bit mode) and the return address is stored at the new SSP. A pop happens implicitly on ret: the CPU reads the value at SSP, compares it against the ordinary stack’s return address, and bumps SSP up by 8. SSP falls as call depth grows and rises as functions return — a mirror of the call depth that holds only return addresses.
The verification at ret is a plain 8-byte equality check: the address popped from the ordinary stack (RSP) versus the address at the top of the shadow stack (SSP). A failure is just those two diverging, which happens when:
an overflow rewrote the ordinary-stack return address, the setup for classic ROP — the shadow copy is untouched, so they differ, and you get a #CP fault;
code executes an unbalanced ret, returning without a matching call, so SSP points at a stale entry — #CP again;
the two stacks fell out of sync because software unwound one but not the other, which is the reason C++ exceptions and coroutines need explicit shadow-stack handling (more below).
There’s no threshold and no secret here. Any mismatch faults, every time.
The shadow stack says nothing about forward edges. An indirect call *%rax pushes a perfectly correct return address — the shadow stack is happy — and still jumps to an attacker-controlled target. JOP and COP live in that space. IBT closes it.
IBT adds one rule: the target of any indirect call or jmp must be the instruction endbr64 (endbr32 in 32-bit mode). “ENDBR” is short for end-branch, and it works as a mandatory landing pad. The enforcement is a small state machine:
An indirect call/jmp executes, and the core enters the WAIT_FOR_ENDBRANCH state.
It fetches the instruction at the target.
If that instruction is endbr64/endbr32, the state clears and execution continues.
If it’s anything else, the CPU raises #CP.
A compiler emits endbr64 at the top of every function that can be reached indirectly — address-taken functions, virtual methods, PLT entries — so legitimate indirect calls always land on one. A JOP/COP gadget starts in the middle of some instruction stream by definition, never on an endbr64, so aiming an indirect branch at a gadget faults on the spot. This is the staffed gate from the opening: a function’s endbr64 is its only real jet bridge, and a branch aimed anywhere else — into the middle of a function, where gadgets live — is a passenger stepping onto the tarmac, and gets stopped at the door.
endbr64 is encoded in the hint-NOP space of the opcode map (F3 0F 1E FA). On a CPU without CET it decodes as a no-op and costs nothing. That’s what lets a single binary run everywhere: the compiler emits endbr64 unconditionally, and only CET-capable silicon with IBT enabled treats it as a required landing pad.
IBT is coarse-grained, and it helps to be clear about that. It checks that an indirect transfer lands on anendbr64, not on the right one. An attacker who controls a function pointer can still point it at any address-taken function’s entry. IBT shrinks the target set from “any byte in the code segment” down to “the set of valid landing pads,” which is a large win, but it doesn’t pin the exact intended callee. Finer-grained schemes — per-call-site type checks, hardware-assisted CFI — narrow it further and are out of scope here.
It’s worth heading off one expectation directly: IBT does not verify a hash or signature on the branch target. The check is purely “is the first instruction at the target an endbr64?” — a single opcode comparison, no cryptography, no per-target secret. That’s a deliberate design point, and it’s precisely where Arm diverges: Arm’s forward-edge scheme (BTI) is the same landing-pad idea, but its backward-edge scheme, Pointer Authentication (PAC), really does sign pointers with a keyed cryptographic MAC. When the series reaches Arm, that contrast — landing pad versus signature — is one of the main things worth drawing out.
CET differs from the mitigations that came before it in a way that’s worth stating plainly, because it’s the reason CET matters.
Stack canaries put a random value between local buffers and the saved return address and check it in the epilogue. ASLR randomizes the base addresses of the stack, heap, and libraries so the attacker doesn’t know where gadgets live. Both are probabilistic. A canary falls to an information leak that discloses its value, or to an overwrite that skips over it, and it only covers the backward edge against contiguous overflows. ASLR falls to any leak of a single code or stack address, from which the layout can be recomputed; low-entropy setups are brute-forceable too. Both rest on the attacker not knowing a secret — the canary value, the base addresses — and secrets leak.
CET rests on no secret. The shadow stack’s guarantee is architectural: the return address the CPU uses is the one call actually pushed, checked on every ret. IBT’s guarantee is likewise structural: an indirect branch lands on a landing pad or it faults. Neither can be guessed past. Hand an attacker a full memory-disclosure primitive and every address in the process, and a corrupted return address still won’t survive a ret, because the check doesn’t compare against a hidden value — it compares against a copy the write primitive can’t reach.
This doesn’t make CET a superset of canaries and ASLR. They overlap but cover different ground and get deployed together (see the misconceptions at the end). What it means is that CET’s failure mode is stronger: a hardware law, not a lucky bet.
CET adds a small set of instructions. There aren’t many, and they exist for one reason: the CPU maintains the shadow stack automatically on call/ret, so the only instructions software needs are the ones for inspecting SSP, unwinding it, and switching it. Semantics below follow the Intel SDM (Vol. 1 Ch. 18, Vol. 2 instruction reference); the D/Q suffixes pick 32- or 64-bit operands. Every assembly snippet here was assembled with gas and disassembled back to confirm it’s real, not hand-waved.
RDSSPD / RDSSPQ — read the shadow stack pointer. Copies the current SSP into a general-purpose register. Its useful quirk is that it decodes as a NOP when shadow stacks are disabled — it simply leaves the destination register untouched. That gives you a branch-free “am I running with a shadow stack?” check: zero a register, read SSP into it, and see whether it’s still zero.
1 2 3 4 5
read_ssp: endbr64 xor %rax, %rax rdsspq %rax ; rax <- SSP; left as 0 if shadow stacks are off ret ; caller checks: rax == 0 means "no shadow stack"
This is exactly how glibc’s _get_ssp() works. The raw encoding is f3 48 0f 1e c8 — note the f3 prefix and the 0f 1e opcode, the same hint-NOP space endbr64 lives in, which is why it’s inert on pre-CET silicon.
INCSSPD / INCSSPQ — pop N entries off the shadow stack. Adds (register × word size) to SSP, discarding that many shadow-stack slots without reading them. This is the multi-frame unwind primitive: rather than one ret per frame, code that abandons several frames at once bumps SSP past all of them in a single instruction. The count is taken from the low 8 bits of the register, so one INCSSP unwinds at most 255 frames.
1 2 3 4 5
pop_two_frames: endbr64 mov $2, %eax incsspq %rax ; discard 2 shadow-stack entries in one step ret
This is what a CET-aware C++ unwinder or longjmp uses to keep the shadow stack in step with the ordinary stack (see the programming-model section).
RSTORSSP — switch to another shadow stack. Takes a memory operand pointing at a restore token sitting on the target shadow stack, atomically verifies that token, and loads SSP to point into that stack. There is no plain mov into SSP; every switch goes through this verify-then-load instruction. (Why that matters for security — and how it pairs with SAVEPREVSSP on a real context switch — is spelled out in the programming-model section below.)
SAVEPREVSSP — record the stack you’re leaving. Pairs with RSTORSSP. Before or after switching, SAVEPREVSSP writes a previous-SSP token so a later RSTORSSP can return to the shadow stack you just left. The two together are how thread switches, context switches, and coroutine resumes move between per-context shadow stacks safely.
WRSSD / WRSSQ — write to the current shadow stack. A deliberate store into shadow-stack memory, allowed only when the WR_SHSTK_EN control is set. Normal movs fault on shadow-stack pages by design; WRSS is the sanctioned escape hatch for the rare runtime that must rewrite a return address it manages itself.
1 2 3 4
write_ss: ; write_ss(void **addr /*rdi*/, uint64_t val /*rsi*/) endbr64 wrssq %rsi, (%rdi) ; store rsi into shadow-stack slot at [rdi] ret
WRUSSD / WRUSSQ — write to a user shadow stack from supervisor mode. Same idea as WRSS, but callable only from the kernel, which uses it to seed a user shadow stack or lay down signal frames. Your brief listed this as a user-space instruction, so it’s worth being precise: WRSS is the user-mode write; WRUSS is the supervisor-mode write to user memory. They’re a matched pair across the privilege boundary, and only WRSS is a CPL-3 instruction. For completeness, SETSSBSY and CLRSSBSY — which manage the supervisor shadow-stack busy bit — are also supervisor-only, and belong to Part 2.
Two separate questions: does the CPU support CET, and is it enabled for this process?
Support is reported by CPUID. Shadow stack is CPUID.(EAX=07H, ECX=0):ECX[bit 7], the CET_SS bit; indirect branch tracking is CPUID.(EAX=07H, ECX=0):EDX[bit 20], the CET_IBT bit.
Enablement lives in control registers and MSRs: CR4.CET (bit 23) is the master enable, and per-mode state — the enable bits and SSP — sits in IA32_U_CET and IA32_PL3_SSP for user mode. Those MSRs aren’t readable from CPL 3, so user code asks the OS instead. On Linux the practical checks are:
Read the CPU feature flags in /proc/cpuinfo: user_shstk for the user-mode shadow stack and ibt for indirect branch tracking. (Note the flag is user_shstk, not shstk — a grep for the wrong string finds nothing.) This box, for instance, advertises neither, so shadow stacks won’t be enforced here even though the compiler still emits the code.
Query per-thread status with the arch_prctl shadow-stack calls (ARCH_SHSTK_STATUS and friends on modern kernels) — this tells you whether the feature is actually on for the calling thread, which CPUID alone can’t.
Read SSP non-destructively with RDSSPQ, as shown above: it returns the pointer if shadow stacks are on and behaves as a NOP if they’re off — the trick behind glibc’s _get_ssp().
(There is no x86 HWCAP2_SHSTK auxiliary-vector bit, incidentally — that’s an Arm idiom for detecting BTI and GCS, and it’ll show up when the series reaches Arm. On x86 the source of truth is CPUID plus the kernel’s arch_prctl status.)
We’ll see the enablement handshake in the ELF itself further down: the linker records a .note.gnu.property, and the loader only turns the feature on if every component in the process agrees.
Enforcement is hardware, but making a real program work with a shadow stack takes cooperation from the loader, the C runtime, the exception machinery, and any code that manages its own stacks. Here’s what software has to get right.
Allocation at process launch. Shadow stacks are opt-in per binary. The toolchain records the opt-in in an ELF note — .note.gnu.property, program property GNU_PROPERTY_X86_FEATURE_1_AND, with the SHSTK and/or IBT bits set. At execve, the loader ANDs the properties of the main executable and every shared library it pulls in, and enables CET only if all of them declare support. The _AND is literal. If one legacy .so lacks the note, the loader leaves the feature off rather than fault the moment execution reaches that library. When CET is on, the kernel allocates the initial thread’s shadow stack, maps it with the shadow-stack page type, writes the top-of-stack token, and sets SSP. From there call/ret maintain it on their own.
Per-thread stacks and switching. Each thread needs its own shadow stack, just as each has its own ordinary stack. When a thread is created, the runtime allocates a fresh shadow stack for it — on Linux via the map_shadow_stack syscall — and arranges for SSP to point there when the thread runs. Switching between shadow stacks safely is the job of RSTORSSP and SAVEPREVSSP. A shadow stack carries a restore token at a known location, a self-referential value the CPU can validate. RSTORSSP reads that token, verifies it, and atomically loads SSP to point into the target stack; the atomic verify-and-switch is what stops an attacker from aiming SSP at arbitrary memory. SAVEPREVSSP writes a token recording the stack you’re leaving, so a later RSTORSSP can come back to it. This token protocol is why there’s no mov into SSP — every switch goes through an instruction that validates a token and keeps the pointer honest.
Context switching. On a kernel-driven context switch the scheduler saves the outgoing thread’s SSP from its CET MSR and restores the incoming thread’s. From user space this is invisible; the shadow stack just follows the thread. The kernel mechanics — IA32_PL0_SSP and the supervisor busy-bit handling — are Part 2.
C++ exceptions and stack unwinding. A throw, or a C longjmp, drops several stack frames at once: RSP jumps back many frames without running the intervening rets. The shadow stack has to unwind in step, or it keeps stale entries and the next real ret mismatches and faults. The unwinder handles this by hand. For the frames it skips on the ordinary stack, it discards the matching shadow-stack entries — not with one ret per frame but with a single INCSSPD/INCSSPQ carrying the count, advancing SSP past the abandoned frames in one step. The two stacks stay in sync, and the return to the catch or setjmp site validates cleanly. This is why libstdc++ and libgcc have to be CET-aware; a non-CET unwinder would desynchronize the stacks.
Custom threading and coroutines. Anything that switches stacks without going through the kernel — user-space thread libraries, fibers, coroutines, makecontext/swapcontext, green-thread runtimes — has to switch the shadow stack too. Save and restore RSP but leave SSP pointing at the previous stack, and the first ret after the switch compares against the wrong shadow stack and faults. The fix is to allocate a shadow stack per coroutine or fiber with map_shadow_stack and pair every RSP swap with an RSTORSSP (plus SAVEPREVSSP) so SSP moves in lockstep. When you don’t, the symptom is concrete and immediate: a #CP fault on the first return after resuming a coroutine. It’s the most common source of CET breakage in otherwise-correct programs, because these runtimes predate CET and poke at RSP directly.
Forward-edge configuration. IBT is enabled per-process through the same .note.gnu.property opt-in (the IBT bit) and the loader’s AND-across-components rule. There’s no per-call-site setup in user code. The compiler’s whole job is to place an endbr64 at the entry of every function whose address can escape — address-taken functions, virtual methods, and every PLT stub. So “verifying endbr placement” is a build-time property: you confirm it by disassembling and checking that indirectly-reachable entry points start with endbr64, which is exactly what we do next.
Time to make this concrete. Here’s a program that deliberately exercises a direct call, an indirect function-pointer call, a virtual call, and some returns. Everything below is real output from g++ 11.4 and binutils 2.38 on x86-64.
intmain(){ int a = greet(41); // direct call int (*fp)(int) = via_pointer; // function pointer 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); return0; }
Compile it twice, once with CET codegen on and once with it off:
First, check that the opt-in is recorded in the binary. This is the GNU_PROPERTY_X86_FEATURE_1_AND note the loader reads:
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
The full binary declares x86 feature: IBT, SHSTK. The none binary has no such property, so even on CET hardware the loader would leave the feature off for it.
Twenty-one landing pads in the full build, five in the none build. Those five aren’t in our code — they’re in the C-runtime startup stubs and _fini, which ship pre-built with CET codegen no matter what flag we pass. Every landing pad in our functions shows up only in the full build. That’s the direct, reproducible proof of the IBT claim from earlier: -fcf-protection=full makes the compiler put an endbr64 at each entry IBT needs.
The three calls inside main (from the full build) put all the edges in one place:
1 2 3 4 5 6 7 8 9 10 11
11ea: mov $0x29,%edi 11ef: call 11a9 <_Z5greeti> ; (1) DIRECT call — target is a fixed displacement ... 1202: mov -0x28(%rbp),%rdx ; load the function pointer 120b: call *%rdx ; (2) INDIRECT call (COP-shaped) — target from %rdx ... 1227: mov (%rax),%rax ; load vtable pointer from object 122a: mov (%rax),%rcx ; load compute() slot from vtable 1239: call *%rcx ; (3) INDIRECT call — target from vtable slot ... 12a9: ret ; BACKWARD edge — checked against shadow stack
Reading it against the earlier sections:
11ef: call 11a9 is a direct transfer. The target is baked into the instruction, so it isn’t reachable through data corruption and IBT doesn’t police it. Note that greet still opens with endbr64 anyway — the compiler can’t know at compile time whether a function will only ever be called directly, so it marks every function that could plausibly be an indirect target.
120b: call *%rdx and 1239: call *%rcx are forward-edge indirect calls, the exact shape a COP chain abuses. Under IBT each has to land on an endbr64, and each does: %rdx holds &via_pointer, whose entry at 11bc is endbr64, and %rcx holds the resolved compute vtable slot, whose entry at 12aa is endbr64. Corrupt either pointer to aim mid-function and the landing-pad check faults.
12a9: ret is the backward edge. With shadow stacks enforced, the return address popped here is compared against the shadow copy that whoever called main pushed. An overflow that rewrote the stacked return address wouldn’t have rewritten the shadow copy, so this ret would #CP-fault instead of returning into a gadget.
One honest caveat about this machine: /proc/cpuinfo here doesn’t advertise user_shstk/ibt, so shadow-stack enforcement doesn’t happen at run time and the checks are inert. But the code generation is real and complete — the landing pads are there, the ELF opt-in note is there — and on CET-enabled hardware with a CET-aware loader this same binary would be enforced. The disassembly is the artifact; enforcement is a property of the silicon it runs on.
CET stops all memory-corruption exploitation. It doesn’t. CET protects control flow. A data-only attack — flipping a boolean that gates an authorization check, overwriting a uid, forging a length field — never redirects execution, so it never trips a shadow-stack or IBT check. CET raises the bar for control-flow hijacking specifically; it isn’t general memory safety.
IBT guarantees an indirect call reaches its intended function. No. IBT is coarse-grained: it enforces “lands on some endbr64,” not “lands on the right one.” An attacker who controls a function pointer can still send it to any address-taken function’s entry. IBT shrinks the target set enormously without giving you full type-precise CFI.
The shadow stack is just memory, so an overflow can overwrite it too. No. Shadow-stack pages use a distinct page-table encoding that makes ordinary stores fault. The overflow primitive that rewrites the normal stack simply can’t write the shadow stack — only call/ret and the explicitly-enabled WRSS family can. That non-writability by normal stores is the whole source of the guarantee.
Compiling with -fcf-protection protects my program. Not on its own. The flag only makes the compiler emit landing pads and the opt-in note. Enforcement also needs CET-capable hardware, an OS and loader that enable it, and — because the loader ANDs the property across the whole process — every linked library to carry the opt-in note. One legacy .so without it silently disables CET for the entire process. Build-time flags are necessary, not sufficient.
CET replaces ASLR and stack canaries, so I can drop them. No — they’re complementary and cover overlapping but distinct threats. Canaries also catch some non-control-flow overflow corruption; ASLR raises the cost of locating anything, including the data targets CET doesn’t cover. Defense in depth is the intended posture. CET adds to the stack of mitigations rather than replacing it.
Reduced to fundamentals, control flow is two indirect edges — the forward edge (indirect call/jmp) and the backward edge (ret) — and the two dominant exploitation families, ROP and JOP/COP, each attack one of them. CET answers each with a deterministic, hardware-enforced mechanism. The shadow stack guards the backward edge by keeping an untouchable copy of every return address and faulting on any mismatch. IBT guards the forward edge by forcing every indirect transfer to land on an endbr64. Neither relies on a secret, so neither falls to an information leak — the difference that sets CET apart from canaries and ASLR. And we watched the machinery show up in real disassembly: endbr64 at every function entry under -fcf-protection=full, absent under =none, plus the ELF opt-in note the loader checks.
That two-edge model is the spine of this whole series, because it’s vendor-neutral: the edges are the invariant, and each architecture defends them its own way. Part 2 stays on x86 and goes into the kernel — supervisor shadow stacks, the SETSSBSY/CLRSSBSY busy-bit protocol, how the scheduler saves and restores SSP across context switches, signal-frame handling, and the map_shadow_stack/arch_prctl interfaces the kernel hands to user space.
From there the series crosses over to Arm, which solves the same two-edge problem with a different toolkit: BTI (Branch Target Identification) as the forward-edge landing-pad scheme, the direct analog of IBT; PAC (Pointer Authentication), which guards the backward edge by cryptographically signing return addresses rather than keeping a shadow copy — a genuinely different mechanism for the same goal; and GCS (Guarded Control Stack), Arm’s true shadow-stack equivalent. Putting CET, PAC, BTI, and GCS side by side is the point of the tour: same problem, same edges, three chips’ worth of answers.
In the ever-evolving landscape of computer architecture, RISC-V has emerged as a promising and disruptive force. With its open-source nature and elegant design philosophy, RISC-V has garnered significant attention from both academia and industry alike. Unlike proprietary architectures, RISC-V is an open-source instruction set architecture (ISA) that provides unrestricted access to its specifications. This openness has spurred innovation, encouraging a flourishing ecosystem of developers, researchers, and companies to contribute to its development. Recent statistics indicate a surge in the adoption of RISC-V architecture, serving as a testament to its growing popularity. According to industry reports, the shipment of RISC-V-based devices reached an astounding 1 billion units in 2022 alone, marking a significant milestone for this emerging technology.
In the past few months I did some webinar on ARM architecture and shellcode, I thought it would be good idea to create a post which has links to all of those webinars.