Skip to content

Overcoming Security Protections: ROP Chains

ROP chaining is the process of returning to another function when we overflow instead of adding our own code to the stack.

Say, for example, we have the function loser() and the function winner(). The main() function will normally call loser(), but instead, we can make it call winner(), before we call the underlying exit().

To make this work, we need to have a buffer that we overflow. We overflow the buffer, then overwrite the rbp, which is 4 bytes. From here, we write the address for the function we want to jump to, which will overwrite the previously assigned return address.

If we then want to modify the control flow further, we can simply put the address of the next function as the return address of the function that we initially jump to.

Therefore, we can jump to any arbitrarily long set of functions, before calling libc's exit() to exit cleanly. We can find the addresses of the functions using gdb-peda. The overall payload, if we wanted to change control flow for a buffer length 100 to a function rop1, then rop2, then rop3, then exit would be:

payload = 'A'*100 + 'BBBB' + rop1Addr + rop2Addr + rop3Addr + exitAddr

We can then compile the program with

gcc -m32 -fno-stack-protector -z execstack rop1a.c -o rop1a

Functions with Parameters

This works well for functions that don't take parameters, however, for functions with arguments, we need to add the argument to the stack immediately following the address of the function. If we have multiple arguments, then we simply put them one after the other.

We also need to pop the items off the stack, then ret. These are called 'rop gadgets', and are helper bits of code addresses that call pop ret for 1 argument, pop pop ret for multiple arguments.

These sequences of code will already exist in the code at specific addresses, which we can get using the ropgadget tool from gdb-peda.

As these gadgets already exist in the program memory and not the stack, then we can bypass the NX bit protection that the stack has. Using the ropgadget tool, we can then examine the memory address for that specific instruction to see what the gadget does, e.g., x/2i 0xabcdef12.

It is important to remember to reverse each byte of the input that we pass to the program as our vulnerability. We can use the struct module, and struct.pack("I", 0xabcdef12) or struct.pack("I", "stringvar"), etc. to pack it correctly.

Bypassing the NX Bit

Previously, with buffer overflows, we have put the code on the stack. OS designers and CPU vendors have decided that this was a bad idea, and thus allow programs to be compiled without an executable stack. This means that any of the buffer overflow code that was created previously that runs on the stack will be thwarted by any program that was compiled recently.

To bypass this NX bit issue, we can simply find the functions we need, e.g., to call execve or something else in libc. We can then pass in a string, such as /bin/sh which is also at a memory address in libc somewhere.

The execve function expects some arguments to be passed into the RDI, RSI, and RDX registers, including the path to the program, the argv and envp arrays.

Bypassing ASLR

ASLR is an OS-based approach to protecting programs from malicious inputs and buffer overflows, as it randomises the addresses of functions, buffers, stack, etc. at runtime for the program.

The payloads shown so far rely on fixed addresses, and these won't run with ASLR enabled. We can use the same idea behind the ROP chaining so that we find a piece of code that executes jmp rsp. We can find the memory addresses of these functions which jump to the stack using the ropper tool:

ropper --file ./a.out --search "jmp rsp"

Antireversing Techniques

So far, most of the techniques we have looked at involve looking through the symbols to get the right memory addresses for each of the functions. In C, most compilers eliminate this information, and when compiled, we will typically have a goto with LAB_XXXXXX.

Variables also don't have to be given names outside of the source code, as the compiler tracks their location and pointers.

In languages such as Java, the compiled code typically has the class names, and function names, the only things that we have to guess are the local variables.

Another thing we can do is obfuscate the program, e.g., by encrypting the code, modifying the layout, logic, data and code organisation such that we keep the functionality of the code but kill the readability.

We can also alter the code flow, have predicates that will always evaluate to false, inline or outline code, interleave functions, transform data, restructure arrays, etc.

Finally, we can embed antidebugger code, such that if we detect a debugger, we can damage or disable it, or quit. We can check for breakpoints in the code using suitable APIs.

We can also check the timings of functions that should have a known runtime and quit if this differs substantially.