[v3] libio: validate the wide vtable via a bounds-checked index

Message ID 20260909115521.90205-1-7991aleschino@gmail.com (mailing list archive)
State New
Headers
Series [v3] libio: validate the wide vtable via a bounds-checked index |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
redhat-pt-bot/TryBot-32bit success Build for i686
linaro-tcwg-bot/tcwg_glibc_build--master-aarch64 success Build passed
linaro-tcwg-bot/tcwg_glibc_check--master-aarch64 success Test passed
linaro-tcwg-bot/tcwg_glibc_build--master-arm success Build passed
linaro-tcwg-bot/tcwg_glibc_check--master-arm success Test passed
redhat-pt-bot/TryBot-still_applies warning Patch no longer applies to master

Commit Message

Alessandro Schino Sept. 9, 2026, 11:55 a.m. UTC
  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).  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.

An out-of-range index is always a genuine error on the wide path: the
wide vtable is only ever set to an internal table, and the foreign
vtable acceptance in _IO_vtable_check applies to the narrow vtable of
the standard streams, not to the wide dispatch.  The check therefore
terminates directly with __libc_fatal, which does not return, so a bad
index can never reach the array access.

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 <7991aleschino@gmail.com>
---
 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
  

Comments

Avinal Kumar Sept. 15, 2026, 10:37 a.m. UTC | #1
Apologies, I was away for a few days.

The patch looks good to me. Thank you.

Reviewed-by: Avinal Kumar <avinal.xlvii@gmail.com>

On Wed, Sep 9, 2026 at 5:25 PM Alessandro Schino
<7991aleschino@gmail.com> wrote:
>
> 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).  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.
>
> An out-of-range index is always a genuine error on the wide path: the
> wide vtable is only ever set to an internal table, and the foreign
> vtable acceptance in _IO_vtable_check applies to the narrow vtable of
> the standard streams, not to the wide dispatch.  The check therefore
> terminates directly with __libc_fatal, which does not return, so a bad
> index can never reach the array access.
>
> 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 <7991aleschino@gmail.com>
> ---
>  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 8d527f2f30..abc44f0aba 100644
> --- a/libio/Makefile
> +++ b/libio/Makefile
> @@ -170,7 +170,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 5a249725ee..d6e6ac5155 100644
> --- a/libio/fileops.c
> +++ b/libio/fileops.c
> @@ -398,7 +398,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) = IO_wide_validate_index (fp->_wide_data->_wide_vtable_index);
>
>           /* Set the mode now.  */
>           result->_mode = 1;
> @@ -451,7 +451,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);
> @@ -460,7 +460,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;
> @@ -683,7 +683,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;
>      }
> @@ -753,7 +753,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;
>             }
> @@ -766,7 +766,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..2019ceb9ac 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) = IO_wide_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..d13271093f 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) \
> +  (IO_wide_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) = IO_wide_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 *
> +IO_wide_validate_index (unsigned int index)
> +{
> +  if (__glibc_unlikely (index >= IO_VTABLES_NUM))
> +    __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
> +  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
> +IO_wide_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))
> +    __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
> +  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..4a7967f74e
> --- /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 __libc_fatal 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 IO_wide_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
>
  
Adhemerval Zanella Sept. 16, 2026, 11:55 a.m. UTC | #2
On 09/09/26 08:55, Alessandro Schino wrote:
> 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).  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.
> 
> An out-of-range index is always a genuine error on the wide path: the
> wide vtable is only ever set to an internal table, and the foreign
> vtable acceptance in _IO_vtable_check applies to the narrow vtable of
> the standard streams, not to the wide dispatch.  The check therefore
> terminates directly with __libc_fatal, which does not return, so a bad
> index can never reach the array access.
> 
> 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 <7991aleschino@gmail.com>

LGTM as well, just minor style issues I will fix before commit.

Reviewed-by: Adhemerval Zanella  <adhemerval.zanella@linaro.org>

> ---
>  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 8d527f2f30..abc44f0aba 100644
> --- a/libio/Makefile
> +++ b/libio/Makefile
> @@ -170,7 +170,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 5a249725ee..d6e6ac5155 100644
> --- a/libio/fileops.c
> +++ b/libio/fileops.c
> @@ -398,7 +398,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) = IO_wide_validate_index (fp->_wide_data->_wide_vtable_index);

Line too long.

>  
>  	  /* Set the mode now.  */
>  	  result->_mode = 1;
> @@ -451,7 +451,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);
> @@ -460,7 +460,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;
> @@ -683,7 +683,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;
>      }
> @@ -753,7 +753,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;
>  	    }
> @@ -766,7 +766,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..2019ceb9ac 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) = IO_wide_validate_index (fp->_wide_data->_wide_vtable_index);

Line too long.

>      }
>  
>    /* 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..d13271093f 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)

Line too long.

>  #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) \
> +  (IO_wide_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) = IO_wide_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 *
> +IO_wide_validate_index (unsigned int index)
> +{
> +  if (__glibc_unlikely (index >= IO_VTABLES_NUM))
> +    __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
> +  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
> +IO_wide_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))
> +    __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
> +  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..4a7967f74e
> --- /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 __libc_fatal 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 IO_wide_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>
  

Patch

diff --git a/libio/Makefile b/libio/Makefile
index 8d527f2f30..abc44f0aba 100644
--- a/libio/Makefile
+++ b/libio/Makefile
@@ -170,7 +170,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 5a249725ee..d6e6ac5155 100644
--- a/libio/fileops.c
+++ b/libio/fileops.c
@@ -398,7 +398,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) = IO_wide_validate_index (fp->_wide_data->_wide_vtable_index);
 
 	  /* Set the mode now.  */
 	  result->_mode = 1;
@@ -451,7 +451,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);
@@ -460,7 +460,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;
@@ -683,7 +683,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;
     }
@@ -753,7 +753,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;
 	    }
@@ -766,7 +766,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..2019ceb9ac 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) = IO_wide_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..d13271093f 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) \
+  (IO_wide_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) = IO_wide_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 *
+IO_wide_validate_index (unsigned int index)
+{
+  if (__glibc_unlikely (index >= IO_VTABLES_NUM))
+    __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
+  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
+IO_wide_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))
+    __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
+  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..4a7967f74e
--- /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 __libc_fatal 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 IO_wide_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>