A memory-corruption vulnerability does not need to inject malicious code to be dangerous.
An attacker who can corrupt a return address, a function pointer, or any other data that influences where execution goes next may be able to redirect the program toward code that already exists, doing so through the program's own mechanisms. No new instructions. No payload in writable memory. Just a corrupted destination that the program follows because nothing checked whether it was valid.
This is control-flow hijacking. And it is the problem that Control-Flow Integrity was designed to address.
What Control Flow Is
At any moment, a running program is executing one instruction and then moving to the next. Most of the time, "next" means the instruction immediately following in memory. A branch instruction can change that: a conditional test sends execution down one path or another, a function call pushes a return address and jumps to the called function, a return pops that address and jumps back.
These transitions can be direct or indirect.
A direct transfer encodes the destination in the instruction itself. The compiler knows the target at compile time. call printf or jmp loop_start has a fixed destination baked into the instruction encoding. An attacker who wants to change where that goes has to modify the instruction itself.
An indirect transfer computes the destination at runtime. A function pointer call says "jump to whatever address is stored in this register." A virtual dispatch says "look up the function for this type in this table and call it." A return says "jump to whatever return address is on the stack." The destination is data, not code. And data can be corrupted.
The entire attack surface of control-flow hijacking is those indirect transfers.
Why Memory Corruption Can Redirect Execution
Memory-corruption vulnerabilities, overflows, use-after-free, type confusion, and their relatives, can overwrite data that the program later uses as a control-flow destination.
The classic case is a return address. When a function is called, the return address lands on the stack. If something overwrites the stack beyond its bounds, it might overwrite that address. When the function returns, execution goes to the overwritten address, not the legitimate caller.
Function pointers are equally vulnerable. A program that stores a pointer to a callback, a handler, or a virtual function table entry, and that data can be overwritten, gives an attacker a mechanism to redirect execution by corrupting that pointer.
In both cases, the program is doing exactly what it was told to do: it read an address from memory and jumped to it. The fault is that the address came from corrupted data. The program had no way to know.
Why Protecting Memory Executability Is Not Enough
NX and DEP (non-executable memory and data execution prevention) address a specific class of attack: placing attacker-controlled instructions in a data region, like the stack, and redirecting execution there. By marking data pages non-executable, the processor refuses to execute instructions from those regions.
This is effective against a specific technique. But it leaves a large attack surface intact.
If the program's own code is executable, and it is, that code is available to the attacker. An attacker who can corrupt a return address can redirect execution toward functions or instruction sequences that already exist in legitimate executable memory. NX blocks execution of injected code. It says nothing about whether a given address in already-executable code is a valid destination for a particular control transfer.
Return-Oriented Programming exploits this gap by chaining together short sequences of existing instructions (gadgets) that each end in a return. By controlling the stack, the attacker controls which gadgets execute in sequence. None of it requires injecting new code. All of it requires corrupting control data.
CFI asks a harder question than NX does. Not "is this memory executable?" but "is this destination a valid target for this particular control transfer?"
What CFI Actually Does
Control-Flow Integrity establishes a policy describing which control-flow transfers are legitimate and enforces that policy at runtime for indirect transfers.
The policy comes from program analysis, typically performed by the compiler. The compiler understands the program's intended structure: which functions can be called through which function pointers, which call sites can reach which targets, where each function can legitimately return. This analysis produces a Control-Flow Graph.
A Control-Flow Graph (CFG) represents the intended execution structure of a program as a graph. Nodes represent relevant execution points, such as function entry points or basic blocks. Edges represent permitted transitions between them.
main
|
+---+---+
| |
handler fallback
|
callback [pointer]
|
???
Without CFI, the callback pointer can be corrupted to point anywhere. With CFI, the compiler inserts a check before the indirect call that verifies the destination is a node the CFG permits for this call site. If the destination is not among the permitted targets, the check fails and execution is terminated or the transfer is blocked.
The mechanism is conceptually simple: label valid targets and check that indirect transfers go only to labeled targets. The complexity lies in how precisely the valid target set is defined.
Forward Edges and Backward Edges
Control-flow transfers fall into two categories that CFI handles differently.
Forward edges are transfers that move execution to a new destination: indirect calls, indirect jumps, function-pointer calls, virtual dispatch. The destination is computed at runtime. CFI for forward edges checks that the runtime destination is in the permitted set for that call site.
Backward edges are returns. A return pops a return address from the stack and jumps to it. The intended destination is wherever the function was called from. The vulnerability is that the stack is writable data, and the return address on it can be corrupted.
Returns get special attention because the mechanism for protecting them can be cleaner than general CFI. A shadow stack provides this.
Shadow Stacks
When a function is called, a shadow stack stores a protected copy of the return address in a separate memory region that ordinary write operations cannot reach.
CALL:
Regular stack ← return address
Shadow stack ← protected copy of return address
RET:
Read return address from regular stack
Compare against shadow stack copy
Match: continue
Mismatch: fault
If the regular stack's return address has been corrupted, the shadow stack comparison catches it. The program terminates rather than jumping to an attacker-chosen address.
Shadow stacks provide strong backward-edge protection because they protect the specific data that backward-edge attacks target. They require hardware or operating system support to make the shadow stack itself resistant to modification. Some processor architectures provide this directly, making the protection more reliable.
Shadow stacks are related to CFI but address a specific subset of the problem. General CFI covers all indirect transfers. Shadow stacks provide particularly strong protection for returns.
Coarse-Grained vs Fine-Grained CFI
A CFI policy is only as strong as the precision of its permitted target set.
Coarse-grained CFI may define a large equivalence class of valid targets for a given call site. For example, it might say "any function with the right signature is a valid target for this call." That covers many legitimate cases, but it also includes many functions the programmer would never intend to be called from that site.
Fine-grained CFI attempts to restrict the permitted set much more tightly, based on the actual intended relationships between call sites and callees. An ideal fine-grained policy would limit each call site to exactly the functions it was designed to call.
The security difference matters. An attacker trying to mount a code-reuse attack needs gadgets that satisfy the CFI policy at each step. With coarse-grained CFI, there may still be enough valid-but-unintended targets to compose a useful sequence. With fine-grained CFI, the permitted set at each point is smaller, and finding a complete usable chain becomes harder.
The tradeoff: fine-grained analysis is more complex to implement, may have higher overhead, and can interfere with legitimate dynamic behavior like plugins, JIT compilers, or other patterns that require runtime flexibility in control flow. Real CFI implementations balance precision against these constraints differently.
A Concrete Example
Consider a program that accepts a function pointer from a configuration structure and calls it:
Normal:
Configuration
|
| function pointer → process_request
↓
Function A
|
indirect call via pointer
↓
process_request (expected destination)
The CFI policy for this call site permits process_request as a valid target. It might also permit a small set of similar functions with compatible signatures.
Now an attacker corrupts the function pointer:
After corruption:
Function A
|
indirect call via pointer
↓
attacker-chosen target
Without CFI: execution goes to the attacker-chosen target.
With CFI: the check at the call site compares the runtime destination against the permitted set. If the attacker's target is not in that set, the check fails. The program is stopped rather than redirected.
The protection is real, but its strength depends on how small the permitted set is. If it allows any function with a matching signature, there may be many functions in the binary the attacker could still redirect to. If it is tightly defined, the attacker's options narrow significantly.
How CFI Is Implemented
The general approach involves three things: analysis to determine the intended CFG, instrumentation to insert checks at indirect transfer points, and runtime enforcement of those checks.
The compiler analyzes the program and builds a model of its expected control-flow structure. It then inserts code before each indirect transfer that verifies the destination against the model. Valid targets are identified through labels, metadata, or runtime tables.
The overhead of these checks varies. A check before an indirect call might be a handful of instructions: load the target, validate it against a set, continue or fault. For programs with many indirect calls, this cost can accumulate. Implementations use various techniques to minimize the overhead while preserving the policy.
Hardware can improve both the security and the performance of CFI. Some processor features can enforce control-flow restrictions with lower overhead and in ways that are harder for software-level attacks to subvert. These hardware mechanisms operate by tracking what instruction types are valid targets for specific transfer types, and faulting on violations.
No single implementation covers every possible scenario, and CFI implementations differ meaningfully in what they protect, at what granularity, and with what overhead.
Limitations
CFI constrains what execution can do, but it does not make all attacks impossible. The strength of the protection depends entirely on the precision of the policy.
If the permitted target set for a call site includes many functions, an attacker may be able to find targets within that set that are still useful for composing an attack. The attacker cannot redirect execution arbitrarily, but they may be able to redirect it toward a valid target that happens to do something dangerous. This is sometimes called "control-flow bending" or Counterfeit Object-Oriented Programming in contexts involving virtual dispatch.
Dynamic behavior can create tension with CFI policies. Programs that use callbacks, plugins, JIT compilation, or language runtimes with late binding may have more complex legitimate CFGs, making fine-grained policies harder to define without blocking legitimate behavior.
CFI also applies to indirect transfers. Direct calls and jumps, whose destinations are fixed in the instruction, are not covered because their destinations cannot be corrupted through a data write to the control-flow data.
And CFI does not prevent the initial memory corruption that would enable a control-flow attack. It reduces what a successful corruption can accomplish. The underlying vulnerability still needs to be fixed.
How CFI Fits With Other Mitigations
CFI is most useful as part of a layered defense.
ASLR makes memory addresses unpredictable, which complicates knowing where to redirect execution. CFI constrains which destinations are allowed regardless of knowledge.
NX/DEP prevents executing injected data as code. CFI constrains redirection within already-executable code. They address different problems.
Stack canaries detect certain types of stack corruption before a return happens. Shadow stacks protect the return address more directly. CFI covers the broader category of indirect transfers.
Memory-safe languages eliminate many of the corruption vulnerabilities that enable control-flow attacks. This is the most thorough fix but requires writing code in those languages.
Sandboxing limits what a compromised component can reach even after exploitation. CFI limits what the exploitation technique can accomplish in terms of execution control.
No single mechanism provides comprehensive coverage. Each addresses different parts of the attack chain. Modern exploit mitigation increasingly involves stacking several of these barriers, so that defeating any one of them still leaves the attacker facing the others.
The Deeper Idea
Memory corruption becomes especially dangerous when it can influence where execution goes. An attacker who can corrupt a return address or a function pointer can potentially turn a bug into something that executes arbitrary logic, not because they injected code, but because they redirected the program toward code that was already there.
CFI changes the security model by treating control flow as something subject to an explicit policy rather than a property the program implicitly guarantees. Before CFI, the implicit assumption was that programs jump where they are supposed to jump because the code was written that way. CFI makes that assumption explicit and enforces it at runtime.
The broader lesson is that modern exploit mitigation is increasingly about enforcing constraints on what software can do, not merely preventing one specific class of vulnerability. Preventing the bug is better. But when bugs exist, the question becomes what they can be turned into.
CFI narrows that answer by limiting where execution is allowed to go.
Top comments (0)