Thanks Avinal, good catch.
You're right that _IO_vtable_check can return without aborting (when
IO_accept_foreign_vtables is set, in secondary namespaces, and in the
static dlopen case) so an out-of-range index would fall through to
the &__io_vtables[index] access, which is UB.
Looking at the wide path, my understanding is that this scenario cannot
legitimately occur there: the wide vtable is only ever set to an
internal table (&_IO_wfile_jumps and friends), and the foreign-vtable
acceptance handled by _IO_vtable_check applies to the narrow vtable of
the standard streams (see check_stdfiles_vtables), not to the wide
dispatch. If that reading is correct, an out-of-range index on this
path is always a genuine error rather than a legitimate foreign vtable.
If so, one option would be to terminate directly with __libc_fatal,
which is noreturn, so a bad index can never reach the array access. I'd
also rename the helpers to IO_wide_* for consistency as you suggested.
Does that approach sound reasonable, or is there a wide-path case I'm
missing where a foreign vtable could legitimately be in use?
Thanks,
Alessandro
Il giorno lun 7 set 2026 alle ore 11:53 Avinal Kumar
<avinal.xlvii@gmail.com> ha scritto:
>
> On Mon, Aug 24, 2026 at 10:27 PM Alessandro Schino
> <7991aleschino@gmail.com> wrote:
> >
> > +/* 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 ();
>
> In cases where _IO_vtable_check() returns without aborting, like
> IO_accept_foreign_vtables is set, the index can be out-of-range, which
> may result in UB. There can be other cases too where the function just
> returned cleanly. My question is, is this scenario not possible in the
> current case?
>
> > + 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 ();
>
> Same doubt as above.
>
> > + return offset / sizeof (struct _IO_jump_t);
> > +}
> > +#endif /* IS_IN (libc) */
> > +
>
> If the scenario described above is possible, we will need to add a
> test to check the fallthrough condition. The rest looks good to me.
>
> One small nitpick, new additions uses WIO_ prefix, it would be
> preferable to have something similar to IO_wide_* to maintain naming
> consistency.
>
> Thanks
> - Avinal
@@ -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
@@ -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
@@ -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);
@@ -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);
@@ -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
@@ -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;
@@ -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. */
@@ -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;
@@ -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
@@ -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};
new file mode 100644
@@ -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>