In October 2015, at the ACM CCS 2015 conference, my colleagues Dennis Andriesse and Victor van der Veen from the Vrije Universiteit Amsterdam presented a paper, co-authored by me, a researcher at Lastline Labs, on control-flow integrity entitled "Practical Context-Sensitive CFI". This paper discusses PathArmor, a system that protects users from exploits using return-oriented programming (ROP) to launch an attacker’s code on the victim machine.
In a nutshell, PathArmor uses recent extensions of CPU hardware to collect detailed information about the execution of a program at runtime. It uses this data to examine if the program behaves “as expected”. For example, if an attacker exploits a vulnerability to trick the program to execute a shellcode, PathArmor raises an alert since it sees that the execution does not conform to the behavior implemented by the programmer. More specifically, the system uses the 16 Last Branch Record (LBR) registers available in modern Intel processors to store the targets of control flow changing instructions (such as jump) exercised at runtime.
What is Control-Flow Integrity (CFI)? CFI is a well-known technique in the research world and has been around for more than a decade. In its purest form, CFI reliably stops code reuse attacks, such as ROP or return-to-libc, against binary programs. Typically, such attacks circumvent common defense techniques such as DEP/W+X or ASLR by diverting a program's control flow and executing a set of Return-Oriented Programming (ROP) gadgets.
CFI prevents exploitation attempts by ensuring that all control transfers follow the program's original Control Flow Graph (CFG), as defined by the programmer. For instance, if function A() calls B(), CFI checks that any return from B continues at the B’s callsite in A(). If, on the other hand, an exploit can force function B() to return to a different location of the attacker’s choosing, CFI finds this discrepancy and terminates the program before an attacker can do any harm.
What’s the problem with CFI? Even though CFI was originally proposed in 2005, researchers are still struggling to design a practical implementation. To make enforcing control-flow integrity efficient, one flavour of common CFI solutions relaxes constraints on the legal targets of control edges. For example, they may dictate that a call instruction needs to always target a function in the program, without checking if the programmer ever calls this particular function at the call site in question. While doing so stops many current exploitation attempts with reasonably low performance overhead, unfortunately it also leaves a lot of freedom for attackers. A string of recent research publications shows how to circumvent all these lightweight solutions with relatively low effort.
A fundamental problem with current CFI solutions is that they enforce only context-insensitive CFI policies. In other words, they examine control edges in isolation. This allows attackers to freely chain edges together and form paths that are infeasible in the original CFG. For example, if (at runtime) function A() has called B(), the context-insensitive CFI policies would allow a return from B() to any callsite of B() in the program, not only to A(). However, when coming from A(), a context-sensitive CFI would only allow a backward edge to A(). While the idea of context-sensitive CFI has been acknowledged by the research community years ago, it has been dismissed as too resource expensive in practice.
How does PathArmor address this limitation? PathArmor implements a context-sensitive, low-overhead CFI solution: it considers each control transfer in the context of recently executed transfers, so CFI checks are enforced per path, rather than per edge. In the example above, PathArmor monitors where function B() was called from, and it enforces that execution returns to its call site in A().
As illustrated in the figure below, PathArmor builds on the following major components: 1) a kernel module employing hardware support to efficiently monitor execution paths, 2) an on-demand static analysis to examine if the observed paths are legal in the program, and 3) a binary instrumentation to actually enforce the CFI invariants.
- Kernel module. The kernel module used by PathArmor has two tasks: first, it intercepts sensitive/dangerous system calls that are required to launch a successful attack, e.g., exec or mprotect. Next, on each such execution point, it provides a Branch Record core to support control transfer monitoring. We use the 16 Last Branch Record (LBR) registers available in modern Intel processors, which lets us observe paths of recently exercised control transfers. Since this monitoring comes with virtually no overhead, PathArmor yields comparable performance to previous CFI implementations. At the same time, it has enough runtime information to much more thoroughly examine if the program behaves “as expected”, offering a significantly stronger security protection.
- Static analysis. The static analysis component verifies at runtime if a particular path reported by the kernel module is valid. To this end, it consults the CFG of the binary and searches for the path in question. The paper discusses details on how PathArmor builds the CFG and overcomes the path explosion problem.
- Dynamic instrumentation. The dynamic binary instrumentation component sets up a communication channel with the kernel module to enable and manage path monitoring. In practice, this component is also essential for proper handling of libraries used by the protected program - for details please refer to the paper.
Want to learn more about PathArmor? For more info on PathArmor, check out the full paper here. To try it out and see PathArmor prevent an exploit from taking over your machine, you may download a prototype implementation from https://github.com/dennisaa/patharmor.