[PATCH v2] libio: validate the wide vtable via a bounds-checked index
Alessandro Schino <[email protected]>
| Newsgroups | gmane.comp.lib.glibc.alpha |
|---|---|
| Message-ID | <[email protected]> |
Calls dispatched through _IO_wide_data._wide_vtable (the WJUMP* macros) were not validated, unlike the narrow vtable which goes through IO_validate_vtable. This asymmetry is the primitive used by the FSOP family known publicly as "House of Apple 2" / angry-FSROP: point the checked narrow vtable at _IO_wfile_jumps to pass the existing check, then hide a forged function pointer in the unchecked wide vtable, reached via _IO_wfile_overflow -> _IO_wdoallocbuf -> _IO_WDOALLOCATE. Following Florian Weimer's suggestion, store the index of the wide jump table into __io_vtables rather than a raw pointer, and bounds-check the index on use. The field is internal, so there is no ABI compatibility concern. An attacker who overwrites _wide_vtable_index can only select a value that is either in range (a legitimate table) or out of range (rejected by WIO_validate_index). This is simpler than pointer mangling: no pointer guard dependency, and the standard streams can be statically initialized with the constant index IO_WFILE_JUMPS, so no startup fix-up is needed. The two spots that copy the wide vtable into the narrow vtable slot resolve and bounds-check the index first. Add tst-wide-vtable-check to verify that an out-of-range wide vtable index aborts the process and that legitimate wide I/O is unaffected. Signed-off-by: Alessandro Schino <[email protected]> --- Changes in v2 (following Florian Weimer's review): - Store the __io_vtables index instead of a mangled pointer, and bounds-check it on dispatch (WIO_validate_index). - Rename the _IO_wide_data field to _wide_vtable_index (unsigned int). It is internal, so there is no ABI concern. - Drop pointer mangling entirely: no pointer-guard dependency and no startup fix-up. The standard streams are statically initialized with the constant index IO_WFILE_JUMPS. - elf/libc_early_init.c no longer needs any change. libio/Makefile | 2 +- libio/fileops.c | 12 ++-- libio/freopen.c | 2 +- libio/freopen64.c | 2 +- libio/genops.c | 2 +- libio/iofopen.c | 2 +- libio/iofwide.c | 2 +- libio/libio.h | 9 ++- libio/libioP.h | 48 +++++++++++++- libio/stdfiles.c | 4 +- libio/tst-wide-vtable-check.c | 117 ++++++++++++++++++++++++++++++++++ 11 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 libio/tst-wide-vtable-check.c diff --git a/libio/Makefile b/libio/Makefile index 616107ee10..c2ed504586 100644 --- a/libio/Makefile +++ b/libio/Makefile @@ -169,7 +169,7 @@ $(objpfx)tst-popen-fork: $(shared-thread-library) $(objpfx)tst-file-init-race: $(shared-thread-library) -tests-internal = tst-vtables tst-vtables-interposed +tests-internal = tst-vtables tst-vtables-interposed tst-wide-vtable-check ifeq (yes,$(build-shared)) # Add test-fopenloc only if shared library is enabled since it depends on diff --git a/libio/fileops.c b/libio/fileops.c index 9348d7c3a1..7387449381 100644 --- a/libio/fileops.c +++ b/libio/fileops.c @@ -396,7 +396,7 @@ _IO_new_file_fopen (FILE *fp, const char *filename, const char *mode, cc->__cd_out.step_data.__statep = &result->_wide_data->_IO_state; /* From now on use the wide character callback functions. */ - _IO_JUMPS_FILE_plus (fp) = fp->_wide_data->_wide_vtable; + _IO_JUMPS_FILE_plus (fp) = WIO_validate_index (fp->_wide_data->_wide_vtable_index); /* Set the mode now. */ result->_mode = 1; @@ -449,7 +449,7 @@ _IO_file_setbuf_mmap (FILE *fp, char *p, ssize_t len) /* Change the function table. */ _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps; - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps); /* And perform the normal operation. */ result = _IO_new_file_setbuf (fp, p, len); @@ -458,7 +458,7 @@ _IO_file_setbuf_mmap (FILE *fp, char *p, ssize_t len) if (result == NULL) { _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps_mmap; - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps_mmap; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps_mmap); } return result; @@ -681,7 +681,7 @@ mmap_remap_check (FILE *fp) _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps; else _IO_JUMPS_FILE_plus (fp) = &_IO_wfile_jumps; - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps); return 1; } @@ -751,7 +751,7 @@ decide_maybe_mmap (FILE *fp) _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps_mmap; else _IO_JUMPS_FILE_plus (fp) = &_IO_wfile_jumps_mmap; - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps_mmap; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps_mmap); return; } @@ -764,7 +764,7 @@ decide_maybe_mmap (FILE *fp) _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps; else _IO_JUMPS_FILE_plus (fp) = &_IO_wfile_jumps; - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps); } int diff --git a/libio/freopen.c b/libio/freopen.c index c3047facd4..c851dc430b 100644 --- a/libio/freopen.c +++ b/libio/freopen.c @@ -78,7 +78,7 @@ freopen (const char *filename, const char *mode, FILE *fp) _IO_file_close_maybe_unlink (fp, false); _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps; if (_IO_vtable_offset (fp) == 0 && fp->_wide_data != NULL) - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps); fp->_flags2 &= ~(_IO_FLAGS2_MMAP | _IO_FLAGS2_NOTCANCEL | _IO_FLAGS2_CLOEXEC); diff --git a/libio/freopen64.c b/libio/freopen64.c index c499a8375c..8613dc0f05 100644 --- a/libio/freopen64.c +++ b/libio/freopen64.c @@ -58,7 +58,7 @@ freopen64 (const char *filename, const char *mode, FILE *fp) _IO_file_close_maybe_unlink (fp, false); _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps; if (_IO_vtable_offset (fp) == 0 && fp->_wide_data != NULL) - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps); fp->_flags2 &= ~(_IO_FLAGS2_MMAP | _IO_FLAGS2_NOTCANCEL | _IO_FLAGS2_CLOEXEC); diff --git a/libio/genops.c b/libio/genops.c index 90e08e6571..1021032d44 100644 --- a/libio/genops.c +++ b/libio/genops.c @@ -604,7 +604,7 @@ _IO_no_init (FILE *fp, int flags, int orientation, fp->_wide_data->_IO_backup_base = NULL; fp->_wide_data->_IO_save_end = NULL; - fp->_wide_data->_wide_vtable = jmp; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, jmp); } else /* Cause predictable crash when a wide function is called on a byte diff --git a/libio/iofopen.c b/libio/iofopen.c index 516aee70bd..7ac65872b1 100644 --- a/libio/iofopen.c +++ b/libio/iofopen.c @@ -45,7 +45,7 @@ __fopen_maybe_mmap (FILE *fp) _IO_JUMPS_FILE_plus (fp) = &_IO_file_jumps_maybe_mmap; else _IO_JUMPS_FILE_plus (fp) = &_IO_wfile_jumps_maybe_mmap; - fp->_wide_data->_wide_vtable = &_IO_wfile_jumps_maybe_mmap; + _IO_WIDE_JUMPS_FUNC_UPDATE (fp, &_IO_wfile_jumps_maybe_mmap); } #endif return fp; diff --git a/libio/iofwide.c b/libio/iofwide.c index d016aa33ea..759fe94ac3 100644 --- a/libio/iofwide.c +++ b/libio/iofwide.c @@ -96,7 +96,7 @@ _IO_fwide (FILE *fp, int mode) } /* From now on use the wide character callback functions. */ - _IO_JUMPS_FILE_plus (fp) = fp->_wide_data->_wide_vtable; + _IO_JUMPS_FILE_plus (fp) = WIO_validate_index (fp->_wide_data->_wide_vtable_index); } /* Set the mode now. */ diff --git a/libio/libio.h b/libio/libio.h index 6cbaf29f19..87d0b6cc1c 100644 --- a/libio/libio.h +++ b/libio/libio.h @@ -145,7 +145,14 @@ struct _IO_wide_data wchar_t _shortbuf[1]; - const struct _IO_jump_t *_wide_vtable; + /* Index into __io_vtables identifying the wide jump table, instead of + a raw pointer. Storing an index and bounds-checking it on use (see + _IO_WIDE_JUMPS_FUNC in libioP.h) prevents an attacker who can + overwrite this field from redirecting wide I/O to an arbitrary + address (the "House of Apple 2" / FSROP primitive), since the wide + vtable dispatch was previously unchecked. This field is internal + and not part of the ABI. */ + unsigned int _wide_vtable_index; }; struct _IO_FILE_plus; diff --git a/libio/libioP.h b/libio/libioP.h index 78e8ee6835..a189fef270 100644 --- a/libio/libioP.h +++ b/libio/libioP.h @@ -100,8 +100,9 @@ #define _IO_JUMPS(THIS) (THIS)->vtable #define _IO_JUMPS_FILE_plus(THIS) \ _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE_plus, vtable) -#define _IO_WIDE_JUMPS(THIS) \ - _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable +/* Raw accessor for the stored wide vtable index. */ +#define _IO_WIDE_JUMPS_INDEX(THIS) \ + (_IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable_index) #define _IO_CHECK_WIDE(THIS) \ (_IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data) != NULL) @@ -120,7 +121,13 @@ (_IO_JUMPS_FILE_plus (THIS) = (VTABLE)) # define _IO_vtable_offset(THIS) 0 #endif -#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS) +/* Look up the wide vtable by its stored index, bounds-checked. */ +#define _IO_WIDE_JUMPS_FUNC(THIS) \ + (WIO_validate_index (_IO_WIDE_JUMPS_INDEX (THIS))) +/* Store the index of VTABLE (a pointer into __io_vtables) into the wide + vtable slot. Replaces every direct "_wide_vtable = ..." assignment. */ +#define _IO_WIDE_JUMPS_FUNC_UPDATE(THIS, VTABLE) \ + (_IO_WIDE_JUMPS_INDEX (THIS) = WIO_vtable_to_index (VTABLE)) #define JUMP_FIELD(TYPE, NAME) TYPE NAME #define JUMP0(FUNC, THIS) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS) #define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1) @@ -1050,6 +1057,41 @@ IO_validate_vtable (const struct _IO_jump_t *vtable) return vtable; } +#if IS_IN (libc) +/* Wide vtable hardening. + + The wide jump table reached through the WJUMP* macros is identified by + an index into __io_vtables (stored in _IO_wide_data._wide_vtable_index) + rather than by a raw pointer. On use the index is bounds-checked and + resolved to &__io_vtables[index]. An attacker who overwrites the field + can only select a value that is either in range (a legitimate table) or + out of range (rejected). This closes the House of Apple 2 / FSROP + primitive, where the wide vtable dispatch was previously unchecked. */ + +/* Resolve a stored wide vtable index to its jump table, bounds-checked. */ +static inline const struct _IO_jump_t * +WIO_validate_index (unsigned int index) +{ + if (__glibc_unlikely (index >= IO_VTABLES_NUM)) + _IO_vtable_check (); + return &__io_vtables[index]; +} + +/* Convert a wide vtable pointer (which must point at the start of a table + inside __io_vtables) to its index for storage. */ +static inline unsigned int +WIO_vtable_to_index (const struct _IO_jump_t *vtable) +{ + uintptr_t offset = (uintptr_t) vtable - (uintptr_t) &__io_vtables; + /* The pointer must be aligned to the start of a jump table and lie + within the section; otherwise the stored index would be bogus. */ + if (__glibc_unlikely (offset >= IO_VTABLES_LEN + || offset % sizeof (struct _IO_jump_t) != 0)) + _IO_vtable_check (); + return offset / sizeof (struct _IO_jump_t); +} +#endif /* IS_IN (libc) */ + /* In case of an allocation failure, we resort to using the fixed buffer _SHORT_BACKUPBUF. Free PTR unless it points to that buffer. */ static __always_inline void diff --git a/libio/stdfiles.c b/libio/stdfiles.c index 734865dd71..272efb187e 100644 --- a/libio/stdfiles.c +++ b/libio/stdfiles.c @@ -36,14 +36,14 @@ # define DEF_STDFILE(NAME, FD, CHAIN, FLAGS) \ static _IO_lock_t _IO_stdfile_##FD##_lock = _IO_lock_initializer; \ static struct _IO_wide_data _IO_wide_data_##FD \ - = { ._wide_vtable = &_IO_wfile_jumps }; \ + = { ._wide_vtable_index = IO_WFILE_JUMPS }; \ struct _IO_FILE_plus NAME \ = {FILEBUF_LITERAL(CHAIN, FLAGS, FD, &_IO_wide_data_##FD), \ &_IO_file_jumps}; #else # define DEF_STDFILE(NAME, FD, CHAIN, FLAGS) \ static struct _IO_wide_data _IO_wide_data_##FD \ - = { ._wide_vtable = &_IO_wfile_jumps }; \ + = { ._wide_vtable_index = IO_WFILE_JUMPS }; \ struct _IO_FILE_plus NAME \ = {FILEBUF_LITERAL(CHAIN, FLAGS, FD, &_IO_wide_data_##FD), \ &_IO_file_jumps}; diff --git a/libio/tst-wide-vtable-check.c b/libio/tst-wide-vtable-check.c new file mode 100644 index 0000000000..ebee302a96 --- /dev/null +++ b/libio/tst-wide-vtable-check.c @@ -0,0 +1,117 @@ +/* Test that a corrupted wide vtable index (_IO_wide_data._wide_vtable_index) + is detected and the process is terminated. This exercises the hardening + that closes the House of Apple 2 / FSROP primitive, where the wide vtable + dispatch was previously unchecked. + Copyright (C) 2026 Free Software Foundation, Inc. + This file is part of the GNU C Library. + + The GNU C Library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + The GNU C Library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with the GNU C Library; if not, see + <https://www.gnu.org/licenses/>. */ + +#include <stdio.h> +#include <wchar.h> +#include <string.h> +#include <signal.h> + +#include <libioP.h> +#include <support/capture_subprocess.h> +#include <support/check.h> +#include <support/support.h> + +/* The fatal message printed by _IO_vtable_check on detection. */ +static const char expected_message[] + = "Fatal error: glibc detected an invalid stdio handle\n"; + +/* Open a wide-oriented stream backed by a temporary file and return it. + The stream is oriented wide so that subsequent wide operations follow + the wide vtable dispatch path. */ +static FILE * +open_wide_stream (void) +{ + FILE *fp = tmpfile (); + TEST_VERIFY_EXIT (fp != NULL); + /* Orient the stream towards wide characters. */ + TEST_VERIFY_EXIT (fwide (fp, 1) > 0); + return fp; +} + +/* Callback: overwrite the wide vtable index with an out-of-range value + (as an attacker who can write the field would) and trigger a wide + operation. The bounds check in WIO_validate_index rejects it and the + dispatch aborts. */ +static void +corrupt_out_of_range (void *closure) +{ + FILE *fp = open_wide_stream (); + fp->_wide_data->_wide_vtable_index = IO_VTABLES_NUM + 100; + /* Force buffer allocation, which dispatches through the wide vtable + (_IO_WDOALLOCATE and friends). */ + fputwc (L'x', fp); + /* Should not be reached. */ + fclose (fp); +} + +/* Callback: a large index, as would result from an attacker overwriting + the field with a pointer-sized garbage value truncated into the + index. Also out of range, so rejected. */ +static void +corrupt_huge_index (void *closure) +{ + FILE *fp = open_wide_stream (); + fp->_wide_data->_wide_vtable_index = 0x41414141; + fputwc (L'y', fp); + fclose (fp); +} + +/* Run CALLBACK in a subprocess and require that it terminates with + SIGABRT and prints the fatal stdio message. */ +static void +expect_termination (const char *name, void (*callback) (void *)) +{ + struct support_capture_subprocess proc + = support_capture_subprocess (callback, NULL); + support_capture_subprocess_check (&proc, name, -SIGABRT, sc_allow_stderr); + TEST_COMPARE_BLOB (proc.err.buffer, proc.err.length, + expected_message, strlen (expected_message)); + support_capture_subprocess_free (&proc); +} + +/* Sanity check: an untampered wide stream works and does not abort. */ +static void +legitimate_stream (void *closure) +{ + FILE *fp = open_wide_stream (); + TEST_VERIFY (fputwc (L'z', fp) == L'z'); + TEST_VERIFY (fclose (fp) == 0); +} + +static int +do_test (void) +{ + /* The legitimate case must run to completion (exit status 0). */ + { + struct support_capture_subprocess proc + = support_capture_subprocess (legitimate_stream, NULL); + support_capture_subprocess_check (&proc, "legitimate", 0, sc_allow_stderr); + support_capture_subprocess_free (&proc); + } + + /* Corruptions must be detected and abort the process. */ + expect_termination ("out-of-range", corrupt_out_of_range); + expect_termination ("huge-index", corrupt_huge_index); + + return 0; +} + +#include <support/test-driver.c> -- 2.55.0