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) shows which field supplies the function pointer, but not what address sits in that field once the line runs. 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() takes no length, 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 simple as the above diagram suggests because the processor would execute from anywhere, including the stack and heap where the input ended up. Data Execution Prevention (DEP) withdraws that permission from data memory, so an overflow can overwrite handler and aim it back at the buffer and still accomplish nothing, since the processor faults the instant it tries to execute the bytes waiting there.
Even after DEP keeps code from running on the stack, 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, strings these sequences together by choosing ones that themselves end in a ret, so that finishing one sequence pops the next address off the stack and the chain advances on its own.
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.
Constraint
You may now wonder (as I did): "If everything ROP runs is code the processor would execute anyway, how is it supposed to know anything is wrong?" Good question! It can't, really. All it checks is whether an address is executable, and everything ROP runs already is. Each ret reads its address from the stack, which the attacker controls. A control-flow check restricts where each call and ret can go:
Normal flow:
call [Object.handler] -> handle()
ret [saved return addr] -> after call
ROP flow:
ret [overwritten addr] -> code fragment
The possible targets for each indirect call are fixed at compile time, so the compiler emits a CFI (Control Flow Integrity) check that the actual one is among them before the call proceeds. An overflow that redirects handler into executable code is stopped at that check all the same.
Newer processors add Intel's CET (Control Flow Enforcement Technology), and on each call it saves a second copy of the return address on a separate, protected stack. A ret is then checked against that copy before it runs, and a rewritten one fails to match, ending most of ROP. It also adds a coarser check for indirect branches, letting them reach only the entry points the compiler marked.
Conclusion
It's an interesting journey to follow control flow all the way down and see the processor simply jumping between addresses. It can confirm the address points to real code, though that is its only certainty. Every choice a programmer makes about what should call what is flattened, in the end, into plain addresses in memory. The processor has those addresses while the reasoning that placed them has been abstracted away, so a pointer aimed somewhere it doesn't belong looks indistinguishable from one pointed where it's supposed to. The one fact here, that the address is real, decides essentially nothing... I guess Nietzsche was right!
Every one of the processor's defenses guards control flow, so the way around them is to just leave it alone. The program runs its own correct code on a corrupted length or flag and reaches the attacker's result with every branch legal. These are data-oriented attacks, and I don't think hardware can mitigate them effectively. This is why I'm interested in solving them in an explicit way, such as what Zig seeks to do, by which the developer can take on some of the burden, in return for runtime guarantees that fail closed by design.