Controlling rbp

A method of arbitrary writing

Once again, let's go back to the demo program.

// gcc demo.c -o demo -no-pie -fno-stack-protector
#include <stdio.h>

int main() {
	char buf[0x20];
	puts("ROP me if you can!");
	gets(buf);
}

The main idea we'd have when overflowing this to just control the return address to get RCE. And in most cases that's sufficient, however with the lack of pop rdi ; ret, we're forced to look for alternatives.

There's another value that's overwritten when we overflow, and that is the saved base pointer. While not as exciting as the return address, it can also be very useful.

What is the saved base pointer?

In our case, the stack is managed using 2 registers: rsp and rbp.

  • rsp is the stack pointer, and is used when popping and pushing values.

  • rbp is the base pointer, and is used to determine where the variables on the stack are located.

Sometimes In optimized code, only rsp is needed, and rbp is instead used as a scratch variable. This is common to see in glibc code. However, this isn't relevant here.

rsp will typically point to the bottom of the stack (address-wise), and rbp to the top of the current stack frame.

When main is called:

  1. call main will push rip (the address of the instruction after call), which is the return address. Then it jumps to the function.

  2. push rbp will push the previous value of rbp to the stack. This becomes the saved base pointer.

  3. mov rbp, rsp makes rbp now point to the recently pushed "saved base pointer". This is important to recognise, that rbp points to the saved base pointer of the previous function.

  4. sub rsp, 0x20 allocates the space for the variables (the space used for variables is between rsp and rbp)

  5. The code of the function

  6. leave will restore the previous values of rbp and rsp before push rbp (i.e. at the very start of the function). It does this by effectively doing

    mov rsp, rbp
    pop rbp
  7. ret now jumps back to the previous function, with rsp and rbp back to their original values.

And we also see that when gets is called, the argument is loaded using lea rax, [rbp-0x20], showing that the buf buffer is relative to rbp.

Arbitrary write

However, in the process above, when we overflow, things start going wrong. The obvious one is changing the return address to jump somewhere else, but right before the return address, is the saved base pointer. When we overwrite that, we end up controlling the value of rbp when we return.

Combining this with the fact that we have a gadget that does gets(rbp-0x20), we can return back to this with a controlled value of rbp, and get an arbitrary write!

Overwriting GOT

The scope of this write is limited when we don't have any leaks, but one example of a useful target is GOT. In fact, I wrote a challenge for HTB Business CTF 2024 which used this exact technique, and if you want to see an example of how you could use this, check out my writeup.

Other targets

Overwriting GOT can be useful in some cases, but what about if there are no good targets in GOT, or FULL RELRO is enabled?

Aside from that, your targets for a write could be global variables used by the program for example.

Another idea is to create fake objects in a known location, which can be useful for certain things, such as ret2dlresolve (covered here).

Another idea is to forge a fake stack frame, and to return to a certain section of a function. Since all variables are relative to rbp, by pointing rbp to a controlled buffer, you could "set" certain variables to controlled values, such as pointers to buffers, size variables, and so on.

Unfortunately both of these are dependent on what the binary does, so there aren't many universal approaches for when FULL RELRO is enabled, but this can still remain to be a useful trick which may aid exploitation in some cases.

Last updated