- Complete title: Evaluating the effectiveness of current anti-ROP defenses
- PDF: 429d9abdea007f8a6e8a8398c2b671900e6d33a4962b9f437c0cd2559d26e13b
This fine paper from peoples from the Ruhr-Universität Bochum explains how to bypass in a generic fashion three different ROP mitigations: kBouncer, ROPecker, and ROPGuard. All of them are using the LBR (last branch recording) CPU feature to detect suspicious control-flows.
tl;dr fill the LBR non-suspicious-looking data, and your ROP-chain won't get detected.
kBouncer
kBouncer hooks dangerous functions (link VirtualProtect), checks for
suspicious LBR records, and if none are found, set a "checkpoint" consumed
when the corresponding syscall is hit; preventing direct calls to syscalls.
An attack is detected either if there is a non call-preceded return,
or a chain of up to 20 gadgets ending in the latest LBR stack.
A possible bypass for kBouncer is to execute enough code before calling a dangerous function to fill the LBR stack with legitimate records, with things called LBR-flushing function.
- On x86-32, those gadgets are are looking either like
call <anything>; A; jmp {esi, edi, ebx, ebp}orcall <anything>; A; call {esi, edi, ebx, ebp}; B; ret. Interestingly, callingEtwInitializeProcess(present inntdll.dll),pre_c_init(present in all binaries) or evenlstrcmpiWis enough to flush the LBR. - On x64-64, since the calling convention is different (first arguments passed in registers instead of the stack), a JOP-style dispatcher gadget is used to call dummy gadgets to fill the LBR.
ROPGuard
ROPGuard hooks winapi function, and check is something fishy is going on via several tricks; the two main ones being apparently:
- Past and future control flow analysis: checking if the return address is
call-preceded, and simulating the control flow to check for future non
call-preceded returns. Simulation stops after a certain number of
instructions, or at the first
call/jump. - Stack checks: checking if the stack pointer is pointing inside the correct range (to detect stack pivots).
The aforementioned kBouncer bypasses are enough to fool the first mitigation (the control flow simulation stops early due to jumps/calls), and the second one isn't even triggered.
ROPecker
ROPecker doesn't hook functions, instead it works by catching access violations outside of a circular buffer of a couple of executable pages, if not attack is detected upon fault, the page is added to the ring. The attack detection works by analyzing the past (via LBR) and (simulated) future control flow. If a sequence of instruction matches all the following criteria, then it's considered as a ROP-gadget:
- The last instruction is an indirect branch
- Doesn't contain any direct branching instruction
- Is 6 instructions long at most
The past detection ends when a branch isn't a ROP-gadget, same for the future detection. If the length of accumulated gadget chain exceed a certain threshold (between 11 and 16, as suggested by the paper), then an attack is detected.
Bypassing ROPecker is a simple matter of interleaving non-gadgets (containing a branch, or more than 6 instructions) for time to time in their ROP chain to thwart the detection.