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.
- 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 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:
|
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:
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:
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_OVERFLOWat all;vtable→&_IO_wfile_jumps(real → passes_IO_vtable_check);_wide_data→ a controlled addressW;W->_IO_buf_base = 0(and write base unset) → forces_IO_wdoallocbufdown the_IO_WDOALLOCATEbranch;W->_wide_vtable→ a controlled addressV, withV->__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:
|
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:
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
vtableanywhere illegal — the second vtable does the work, and nobody validates that one; - the trigger is
exit(), which almost every program reaches, and theabort/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.