On Control Flow
"There are no facts, only interpretations."
— Friedrich Nietzsche
The quote is a little grand for a C struct, but bear with me here. In C source, obj->handler(obj->buffer) tells you which field supplies the function pointer. It does not tell you what address will be stored in that field when the line runs, though. If a write into buffer continues into handler, the next call uses the overwritten pointer just as readily as the original one.
Why does this matter? The field after the buffer is the function pointer the program calls next. In a small example, it looks like this:
struct Object {
char buffer[64];
void (*handler)(char *data);
};
void process(struct Object *obj, char *input) {
strcpy(obj->buffer, input);
obj->handler(obj->buffer);
}
Because strcpy() does not know how much space buffer has, it keeps copying bytes from input until it reaches the null byte at the end. A long enough input runs past the 64 bytes reserved for the buffer and starts overwriting the next field in the struct.
If data memory is executable, the input can include instructions and overwrite handler with an address back into the buffer.
Before overflow:
+---------+------------------------+
| buffer | input data |
+---------+------------------------+
| handler | -> handle() |
+---------+------------------------+
After overflow:
+---------+------------------------+
| buffer | code bytes from input |
+---------+------------------------+
| handler | -> buffer |
+---------+------------------------+
At that point, the program has not broken its own rules so much as followed a rule that was too loose; an address was loaded, and execution continued from there...
Convention
Older exploits could be as direct as the diagram suggests. The input contained instructions, and the overwritten handler pointed back to the buffer that held them. DEP (Data Execution Prevention) made that route harder because the stack and heap could still hold the input, but the processor would no longer execute instructions from those data pages.
Even after DEP made stack bytes non-executable, ordinary returns still read their next address from that same stack. If an attacker can overwrite saved return addresses, each ret can be made to continue at a chosen sequence of existing instructions. Return-oriented programming, or ROP, builds a chain out of those sequences, many of which end in another ret. In summary, DEP still prevents the stack from becoming code, whereas ROP uses the stack to choose existing code instead, producing behavior from the order of the returns rather than from new instructions.
If you've cared to read this far, you may wonder: "If ROP stays inside executable code, what is the processor supposed to reject?" Good question. Nietzsche would have something to say here, but we'll get to that.
Constraint
The answer is that "executable" is only one fact about an address, and control flow still has to interpret how that address is being reached. In a ROP chain, a return can land in the middle of another function while every byte it executes still comes from a code page. At that point, a defense has to describe which targets make sense for each kind of control transfer:
Normal flow:
call [Object.handler] -> handle()
ret [saved return addr] -> after call
ROP flow:
ret [overwritten addr] -> code fragment
CFI (Control Flow Integrity) gives the program a means to enforce those relationships explicitly. A call through handler can be checked against the set of functions that are valid for that call rather than being allowed to land anywhere executable.
More recently, CET (Control Flow Enforcement Technology) gives newer processors hardware support for related checks. For returns, the processor can keep a protected shadow copy of the expected address and compare it when ret runs. For indirect branches, the destination must be a marked entry point rather than an arbitrary spot in the instruction stream. In both cases, the processor gets more context than the raw address alone, which we've learned is not enough to safely describe valid control flow.
Conclusion
The blissfully ignorant machine can be right about the address and wrong about the path, and this is the pesky place where Nietzsche is incessantly but undeniably correct. The address is a fact the processor can act on, but whether that address belongs to this return or this indirect call is an interpretation the raw address cannot support by itself. And by the time the processor branches, most of the program has collapsed into values in memory, which no longer carry much of the intent that produced them.
If I were to take anything away from this mess, it'd be this: ROP remains viable under DEP because it reuses executable instructions, but those instructions are reached through corrupted control flow. It seems as if the most useful defenses work by preserving just enough provenance at the branch itself to tell whether the address is a valid target for that branch, and consequently to reject a destination that is executable but wrong for the path that reached it.