Control-Flow Integrity
Returning to the right caller
Section titled “Returning to the right caller”The address leak gave you what ASLR had taken away: a way to locate the code your chain needed. With the canary preserved and the instructions already in executable memory, the program could reach those instructions without either check objecting. But the address you put in the return slot still wasn’t the one the caller had left there.
That gives us a different question to ask of a return. Instead of checking whether its destination is executable or making that destination harder to find, what if the machine checked that it was returning to the caller it actually came from? Knowing the replacement address wouldn’t make it the expected one.
There are protections for other transfers of control too. This recorded function listing gives us a clue to one of them:
00000000000011e9 <session>: 11e9: f3 0f 1e fa endbr64The endbr64 instruction marks a destination for certain calls and jumps. It doesn’t check returns, which is why finding it in the binary doesn’t answer whether your altered return address would be accepted. We need to distinguish the transfers being checked, then find out whether those checks are running at all.
Which transfers are allowed?
Section titled “Which transfers are allowed?”Control-flow integrity, or CFI, restricts execution to transfers allowed by a policy. In their 2005 paper, Abadi, Budiu, Erlingsson and Ligatti described a policy based on a graph of allowed control flow, with checks intended to hold even when an attacker could corrupt data memory.
The useful change is what gets checked: a destination must satisfy the policy, rather than merely contain executable instructions.
For returns, a list of allowed destinations leaves something unanswered. Suppose a function can be called from two places. Both are legitimate callers, but on a particular invocation only one supplied the return address.
To check that this return goes back to that caller, we need a record of the calls made during this run, not just a graph of the calls the program might make.
That record also has to survive the corruption we’re trying to detect. Another ordinary array beside the first stack would give us two copies an attacker might overwrite.
Indirect branches and returns
Section titled “Indirect branches and returns”Intel’s Control-flow Enforcement Technology, or CET, provides two hardware mechanisms relevant here. They protect different transfers, and neither the name CET nor an instruction in a disassembly tells us that both are enabled.
Indirect branch tracking, or IBT, checks destinations of tracked indirect call and jmp instructions. When it is enabled, the destination must begin with the appropriate landing marker, endbr64 in this 64-bit code.
A marker says that a location can be an indirect branch target; it doesn’t specify which caller is allowed to reach it. This is a coarser check than enforcing every edge of a program-specific control-flow graph.
The shadow stack checks returns:
- On a call, the processor saves the return address on both the ordinary stack and a second stack protected from ordinary application writes.
- On a return, it compares the two copies and faults if they differ.
The second copy records where this invocation should return, so changing only the ordinary stack no longer changes the destination unnoticed.
The Linux CET documentation describes these mechanisms and their support boundaries. Its current implementation supports userspace shadow stacks and kernel IBT; an IBT marker in our userspace program should not be read as evidence that Linux is enforcing IBT there.
An overwritten return is checked by a shadow stack, not by IBT. Even if the replacement destination contains an endbr64 marker, the transfer out of the function still happens through ret.
The canary check happens before that return. If it passes, an enabled shadow stack still compares the return address with its protected copy and rejects the mismatch. Preserving a canary doesn’t preserve that separate record of the caller.
Illustrative enabled shadow-stack behavior, not a recording of enforcement in the course VM. IBT does not perform this return check.
The call saves two copies
Ordinary stack
Normal input
Protected shadow stack and check
- Protected return copy
- caller: after call
- Ordinary writes
- Cannot overwrite this protected copy
- Comparison
- Not performed yet
The protected copy records this invocation’s caller. A matching canary cannot make a different return address match that call history.
Blue underlines mark changed values; dashed outlines mark current sources or destinations. Byte addresses increase left to right. Unshown values are omitted, not zero.
The two checks ask different questions:
- The canary detects a change to a checked value in the ordinary frame.
- The shadow stack checks the return address against protected call history.
Learning the expected address doesn’t grant an ordinary write access to that protected copy.
What a valid edge can still do
Section titled “What a valid edge can still do”CFI checks a policy. That policy may be as small as “the destination starts with endbr64,” or it
may distinguish targets by function type or by a more detailed control-flow graph. Passing the check
means the transfer belongs to the set the policy allows. It doesn’t mean the operation is authorized,
or even sensible, for the object in front of it.
Suppose a program keeps two address-taken callbacks with the same type:
typedef void (*handler_fn)(struct request *);
static void preview(struct request *request);static void approve(struct request *request);The program intends to put preview in an untrusted request and reserve approve for a privileged
path. If a memory error lets an attacker replace the callback, both destinations still have the type
void (struct request *). Clang’s
cfi-icall check
checks that the called function has the type expected at the call site, so a policy based on that
type can accept either function.
The shadow stack has no disagreement to catch either. The program reaches approve through a real
indirect call, which records the continuation on both stacks. When approve returns normally, the
two return addresses match. Nothing disabled either mechanism. The attack chose a transfer they
permit.
That example is small, but researchers have built several much more capable versions of the same idea:
- Control-flow bending composes harmful behavior from calls and paths already admitted by the program’s control-flow graph. Carlini and colleagues demonstrated useful attacks even against precise static CFI in Control-Flow Bending.
- Counterfeit object-oriented programming, or COOP, arranges counterfeit C++ objects so that legitimate virtual-call sites invoke a useful sequence of whole functions. The original COOP paper showed why a policy that knows a call is virtual may still know too little about which object and operation make sense there.
- Data-only attacks change security decisions, arguments, counters, credentials, or other ordinary data while leaving control transfers alone. The early study Non-Control-Data Attacks Are Realistic Threats established that these attacks can have consequences comparable to control-flow hijacking. Block Oriented Programming later showed how whole basic blocks along valid paths can be composed without violating CFI or shadow-stack policies.
- Coverage gaps appear where a policy cannot see enough of the program. Separate compilation, dynamically loaded libraries, assembly, plugins and just-in-time generated code all complicate a whole-program graph. Clang’s documentation, for example, describes calls from instrumented code into an uninstrumented shared object as unchecked in its experimental cross-DSO mode.
Bypass does not always mean broken
Section titled “Bypass does not always mean broken”The word bypass gets used for all of these, although they are not the same result. One attack may exploit a coarse set of allowed targets, another may cross an unchecked boundary, and a third may avoid protected control data entirely. Those distinctions tell us whether a stricter policy could help or whether we need memory safety, data-flow integrity, authorization checks, or another defence instead.
An intact hardware shadow stack has a narrower and stronger claim: ordinary application stores
cannot modify it, and a mismatched ret faults. Treat “overwrite the shadow stack” as an
implementation or deployment question, not the generic bypass. The more general route is to leave
returns alone and attack a forward edge or ordinary data. Different software shadow-stack designs
also make different security, compatibility and performance tradeoffs. The survey
Shining Light on Shadow Stacks compares those designs rather than
treating them as one mechanism.
Why this remains an open problem
Section titled “Why this remains an open problem”Making the allowed set smaller sounds like the obvious answer, but the compiler has to preserve every legitimate callback, virtual dispatch, shared-library boundary, exception and unusual control transfer the program actually uses. A policy that invents an impossible edge is weaker, while a policy that omits a real one breaks the program. More runtime context can improve precision, but it also costs time, memory and compatibility.
Even a perfect record of control-flow edges would not say that this user may call approve, or that
changing request->approved from zero to one is forbidden. Those are properties of data and program
meaning, not merely of the instruction that executes next. This is why CFI and shadow stacks are
valuable exploit mitigations rather than substitutes for memory safety.
The problem is still active rather than tidily solved by one sufficiently clever graph. Work such as the 2025 DOPPLER research continues to find and automate expressive attacks that avoid violating control-flow protections. The hard part is enforcing enough of the program’s intended meaning to stop them while keeping real software fast, compatible and buildable in pieces.
From binary support to runtime checks
Section titled “From binary support to runtime checks”Let’s use a program that does very little, because we only need it to stay alive long enough to inspect. In the Ubuntu terminal, create a directory for it:
mkdir -p ~/underdunn-course/cfi ~/underdunn-course/notescd ~/underdunn-course/cfiSave this complete program as return-check.c:
#include <stdio.h>#include <unistd.h>
static void wait_for_enter(void){ puts("Press Enter when you have finished inspecting this process."); fflush(stdout); (void)getchar();}
int main(void){ printf("PID: %ld\n", (long)getpid()); wait_for_enter(); return 0;}getpid() supplies the process ID. getchar() waits for input, so we have time to read the process’s status from another terminal. The final return 0 gives us an ordinary successful exit.
This inspection uses GCC, objdump, and readelf from the course’s Linux toolchain. Keep their versions with your results:
gcc --versionobjdump --versionreadelf --versionCompiler defaults
Section titled “Compiler defaults”gcc -Q --help=common | grep -i cf-protection -fcf-protection -fcf-protection=fullThat was the default in the original course recording; yours may differ. GCC’s instrumentation options distinguish branch protection, return protection and their combination.
So far, that tells us how the program is built. We haven’t inspected a running process yet.
Compare the generated instructions
Section titled “Compare the generated instructions”Build the program twice, explicitly selecting full and none so the comparison doesn’t depend on that default:
gcc -g -O0 -Wall -Wextra -fno-omit-frame-pointer -fcf-protection=full return-check.c -o return-check-fullgcc -g -O0 -Wall -Wextra -fno-omit-frame-pointer -fcf-protection=none return-check.c -o return-check-noneNow inspect the same function in each:
objdump -d -Mintel ./return-check-full | sed -n '/<wait_for_enter>:/,+3p'objdump -d -Mintel ./return-check-none | sed -n '/<wait_for_enter>:/,+3p'On the supported x86-64 GCC toolchain, look for endbr64 at the start of wait_for_enter in the full build and its absence there in the none build. Compare the instruction, not the numeric address, which may differ between the two files.
That connects the marker to the compiler option. Whether the marker is enforced is a different question.
Binary compatibility
Section titled “Binary compatibility”readelf -n ./return-check-fullreadelf -n ./return-check-noneLook for an x86 feature property naming IBT or SHSTK. Check what the actual files advertise, including when that property is absent. The final executable includes startup objects and other link inputs, so selecting a compiler option for this source doesn’t guarantee that the final note will contain both features.
These properties advertise compatibility; they don’t switch the checks on by themselves. The Linux kernel doesn’t directly enable them from this note: userspace startup code, usually the loader, participates in enabling shadow stacks.
Platform support
Section titled “Platform support”grep -o 'user_shstk' /proc/cpuinfo | sort -uOn Linux, user_shstk indicates support from both the current kernel and hardware. No matching output means this check didn’t report that support; it doesn’t identify the processor alone as the missing piece.
If your VM reports it, you’ve established platform support. We still need to find out whether our program is using the feature.
Runtime enablement
Section titled “Runtime enablement”Linux exposes shadow-stack enablement per thread. Programs and their loaders can use the arch_prctl system-call interface to control that state, but you do not need to change it for this inspection.
In the first terminal, start the full build without redirecting its input:
./return-check-fullLeave it waiting at the Enter prompt. In a second terminal, replace PID below with the number it printed, not the shell’s process ID:
grep -E '^x86_Thread_features' /proc/PID/statusLook for shstk in the x86_Thread_features field. That reports enablement for the inspected thread; the adjacent locked-features field concerns whether settings are locked.
If these fields aren’t available, leave runtime status unknown through this check. The kernel documentation’s process-status section explains the fields.
Return to the first terminal, press Enter, then check the exit status immediately:
echo $?The expected status is 0. That tells us the program finished normally, not which protections were active. Repeat the inspection with ./return-check-none, using its newly printed PID and keeping its output separate.
Group the observations by the question each one answers:
- What does the binary advertise?
- What does the platform support?
- What has the running thread enabled?
Leave a group marked unknown if you couldn’t obtain evidence for it.
What the inspection results establish
Section titled “What the inspection results establish”An ELF note and a normal exit tell us different things. The note describes compatibility; the exit status tells us the program finished. Neither supplies the missing runtime status.
That’s the distinction from Crashes and Evidence of Control: an observed result and its explanation are different things.
A fault at a return has the same limitation. The instruction tells us where execution stopped; the fault information and runtime state help explain why. One process’s result doesn’t describe every program on the machine.
If your binary has a compatibility note but the platform check reports no support, keep both observations. The note isn’t evidence of an active shadow stack.
On a supporting machine, the thread’s runtime status answers the remaining question.
Knowledge check
Question 1 of 4
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
When it doesn’t work
Section titled “When it doesn’t work”Your toolchain and VM may differ from the original course environment. Different output can be part of the answer, so keep it even when a feature is absent.
- If
readelfshows no feature property, inspect the full notes and check the build command. A missing property isn’t an explanation of why it is absent. - If
endbr64is missing, compare the compiler settings and the actual function output. Its absence alone doesn’t establish the function’s visibility or how callers use it. - If
gcc -Qreports the default asnone, keep that result. The two build commands above select their settings explicitly, so the default isn’t the setting used for both files. - If
/proc/PID/statuscannot be opened, confirm that you replacedPIDand that the program is still waiting. If it opens but lacks the fields, treat runtime status as unavailable through this check. - If the program exits before you can inspect it, start it in an interactive terminal without redirected input. End-of-file lets
getchar()finish too. - If GCC rejects
-fcf-protection, check the error and check that you’re using the x86-64 Linux toolchain. Don’t treat a failed build as a binary with protection disabled.
To recover a known state, rebuild both files from return-check.c with the commands above. Start a fresh process and use its printed PID. No change to the VM’s protection settings is required.
Check your understanding
Section titled “Check your understanding”Consider a colleague who finds:
SHSTKin the binary’s notes.user_shstkin the machine’s CPU information.
The program exits before they inspect its runtime status, but they report that it ran with shadow stacks enabled. Which parts of that claim have evidence, and what is still missing?
Check your reasoning
They found evidence of binary compatibility and platform support. They didn’t establish that this program enabled shadow stacks during that run.
Runtime status from the relevant thread, or equivalent evidence, would answer that remaining question. Until then, enablement is unknown.
Reading somebody else’s binary
Section titled “Reading somebody else’s binary”When somebody hands you a binary they built, you don’t have their compiler command to explain what you’re seeing. The file can still advertise protection features, but the runtime environment determines whether some of them are active.
Those are different questions, just as they were for our waiting program. A list of features in the file doesn’t answer both.