[PATCH v1] libio: validate and mangle the wide vtable

Alessandro Schino 7991aleschino@gmail.com
Mon Aug 24 15:06:00 GMT 2026


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.

Harden the wide vtable with two independent layers:

  * WIO_validate_vtable: like IO_validate_vtable, but additionally
    requires the pointer to be aligned to the start of a jump table
    inside __io_vtables.  The alignment check closes the unaligned
    mid-section pointer trick that the narrow check does not catch.

  * pointer mangling: the wide vtable pointer is stored mangled with
    the pointer guard (PTR_MANGLE) and demangled on use (PTR_DEMANGLE).
    A raw overwrite does not survive demangling without a guard leak.
    This reuses the mechanism already applied to exit handlers.

The standard streams are statically initialized with a plain wide
vtable, so they are re-mangled from __libc_early_init, after the guard
is set up and before any user I/O.  The two spots that copy the wide
vtable into the narrow vtable slot demangle and validate first.

Add tst-wide-vtable-check to verify that a corrupted wide vtable
aborts the process and that legitimate wide I/O is unaffected.

Signed-off-by: Alessandro Schino <7991aleschino@gmail.com>
---
 elf/libc_early_init.c         |   6 ++
 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/libioP.h                |  96 +++++++++++++++++++++++++-
 libio/stdfiles.c              |  21 ++++++
 libio/tst-wide-vtable-check.c | 123 ++++++++++++++++++++++++++++++++++
 11 files changed, 255 insertions(+), 15 deletions(-)
 create mode 100644 libio/tst-wide-vtable-check.c

diff --git a/elf/libc_early_init.c b/elf/libc_early_init.c
index 4ff38baee7..df71b264e3 100644
--- a/elf/libc_early_init.c
+++ b/elf/libc_early_init.c
@@ -24,6 +24,7 @@
 #include <sys/single_threaded.h>
 #include <getrandom-internal.h>
 #include <malloc/malloc-internal.h>
+#include <libioP.h>
 
 #ifdef SHARED
 _Bool __libc_initial;
@@ -48,4 +49,9 @@ __libc_early_init (_Bool initial)
 
   /* Initialize system malloc (needs __libc_initial to be set).  */
   call_function_static_weak (__ptmalloc_init);
+
+  /* The pointer guard is set up by this point (in both the shared and
+     static startup paths).  Re-mangle the wide vtable of the standard
+     streams, which was statically initialized with a plain pointer.  */
+  _IO_stdfiles_mangle_wide_vtables ();
 }
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..9029527dbb 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_demangle_validate_vtable (fp->_wide_data->_wide_vtable);
 
 	  /* 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..2746633321 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_demangle_validate_vtable (fp->_wide_data->_wide_vtable);
     }
 
   /* Set the mode now.  */
diff --git a/libio/libioP.h b/libio/libioP.h
index 78e8ee6835..25a384c134 100644
--- a/libio/libioP.h
+++ b/libio/libioP.h
@@ -100,8 +100,12 @@
 #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 (mangled) wide vtable pointer.  Do not
+   dereference this directly: the stored value is mangled with the
+   pointer guard.  Use _IO_WIDE_JUMPS_FUNC, which demangles and
+   validates, or _IO_WIDE_JUMPS_FUNC_UPDATE to store a new value.  */
+#define _IO_WIDE_JUMPS_RAW(THIS) \
+  (_IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable)
 #define _IO_CHECK_WIDE(THIS) \
   (_IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data) != NULL)
 
@@ -120,7 +124,13 @@
   (_IO_JUMPS_FILE_plus (THIS) = (VTABLE))
 # define _IO_vtable_offset(THIS) 0
 #endif
-#define _IO_WIDE_JUMPS_FUNC(THIS) _IO_WIDE_JUMPS(THIS)
+/* Demangle + validate the wide vtable on every dispatch.  */
+#define _IO_WIDE_JUMPS_FUNC(THIS) \
+  (WIO_demangle_validate_vtable (_IO_WIDE_JUMPS_RAW (THIS)))
+/* Store VTABLE into the wide vtable slot, mangled.  Replaces every direct
+   "fp->_wide_data->_wide_vtable = ..." assignment.  */
+#define _IO_WIDE_JUMPS_FUNC_UPDATE(THIS, VTABLE) \
+  (_IO_WIDE_JUMPS_RAW (THIS) = WIO_mangle_vtable (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)
@@ -1036,6 +1046,10 @@ IO_set_accept_foreign_vtables (void (*flag) (void))
    terminate the process.  */
 void _IO_vtable_check (void) attribute_hidden;
 
+/* Re-mangle the wide vtable of the standard streams once the pointer
+   guard is available.  Called from __libc_early_init.  */
+extern void _IO_stdfiles_mangle_wide_vtables (void) attribute_hidden;
+
 /* Perform vtable pointer validation.  If validation fails, terminate
    the process.  */
 static inline const struct _IO_jump_t *
@@ -1050,6 +1064,82 @@ IO_validate_vtable (const struct _IO_jump_t *vtable)
   return vtable;
 }
 
+#if IS_IN (libc)
+/* Wide vtable hardening.
+
+   Calls through _IO_wide_data._wide_vtable (the WJUMP* macros) were
+   historically not validated, unlike the narrow vtable.  This is the
+   primitive abused by House of Apple 2 / FSROP: point the checked narrow
+   vtable at _IO_wfile_jumps, then hide a forged function pointer in the
+   unchecked wide vtable.
+
+   Two layers defend it:
+     * WIO_validate_vtable: like IO_validate_vtable, plus an alignment
+       requirement, which removes the "unaligned mid-section pointer"
+       trick that the narrow check does not catch.
+     * pointer mangling: the stored pointer is mangled with the pointer
+       guard, so a raw overwrite does not survive PTR_DEMANGLE.  */
+
+static inline const struct _IO_jump_t *
+WIO_validate_vtable (const struct _IO_jump_t *vtable)
+{
+  uintptr_t ptr = (uintptr_t) vtable;
+  uintptr_t offset = ptr - (uintptr_t) &__io_vtables;
+  if (__glibc_unlikely (offset >= IO_VTABLES_LEN))
+    /* Not in the __io_vtables section.  */
+    _IO_vtable_check ();
+  else if (__glibc_unlikely (offset % sizeof (struct _IO_jump_t) != 0))
+    /* In the section but not aligned to the start of a jump table.  */
+    _IO_vtable_check ();
+  return vtable;
+}
+
+/* Validate and mangle a wide vtable pointer for storage.  NULL is
+   preserved so that _IO_CHECK_WIDE-style NULL tests keep working.  */
+static inline const struct _IO_jump_t *
+WIO_mangle_vtable (const struct _IO_jump_t *vtable)
+{
+  if (vtable == NULL)
+    return NULL;
+  WIO_validate_vtable (vtable);
+  uintptr_t p = (uintptr_t) vtable;
+  PTR_MANGLE (p);
+  return (const struct _IO_jump_t *) p;
+}
+
+/* Demangle and validate a stored wide vtable pointer for use.  */
+static inline const struct _IO_jump_t *
+WIO_demangle_validate_vtable (const struct _IO_jump_t *stored)
+{
+  uintptr_t p = (uintptr_t) stored;
+  PTR_DEMANGLE (p);
+  return WIO_validate_vtable ((const struct _IO_jump_t *) p);
+}
+
+#else /* !IS_IN (libc) */
+
+/* Outside libc (e.g. in test modules that include libioP.h) the internal
+   __io_vtables and _IO_vtable_check symbols are not available and the
+   hardening is not needed.  Provide pass-through stubs so the WJUMP*
+   macros still expand to valid code.  */
+static inline const struct _IO_jump_t *
+WIO_validate_vtable (const struct _IO_jump_t *vtable)
+{
+  return vtable;
+}
+static inline const struct _IO_jump_t *
+WIO_mangle_vtable (const struct _IO_jump_t *vtable)
+{
+  return vtable;
+}
+static inline const struct _IO_jump_t *
+WIO_demangle_validate_vtable (const struct _IO_jump_t *stored)
+{
+  return stored;
+}
+
+#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..83c1a6e082 100644
--- a/libio/stdfiles.c
+++ b/libio/stdfiles.c
@@ -69,4 +69,25 @@ _IO_stdfiles_init (void)
     (*f)->_prevchain = f;
 }
 
+/* The wide vtable of the standard streams is set by static initialization
+   (above) with a plain, unmangled pointer, because PTR_MANGLE cannot run
+   at compile time.  Re-mangle it once the pointer guard is available.
+   Called from __libc_early_init, which runs after the guard is set up in
+   both the shared and static cases, and before any user I/O.  */
+void
+_IO_stdfiles_mangle_wide_vtables (void)
+{
+  struct _IO_FILE *f;
+  for (f = (struct _IO_FILE *) _IO_list_all;
+       f != NULL;
+       f = f->_chain)
+    if (f->_wide_data != NULL)
+      /* Only the standard streams are on the list at early-init time, and
+	 their wide vtable is still the plain, statically-initialized
+	 &_IO_wfile_jumps.  Mangle that known value in place.  Using the
+	 constant (rather than re-reading and re-mangling the stored field)
+	 keeps this idempotent and avoids double-mangling.  */
+      _IO_WIDE_JUMPS_FUNC_UPDATE (f, &_IO_wfile_jumps);
+}
+
 libc_hidden_data_def (_IO_list_all)
diff --git a/libio/tst-wide-vtable-check.c b/libio/tst-wide-vtable-check.c
new file mode 100644
index 0000000000..f05e0a82c9
--- /dev/null
+++ b/libio/tst-wide-vtable-check.c
@@ -0,0 +1,123 @@
+/* Test that a corrupted wide vtable (_IO_wide_data._wide_vtable) is
+   detected and the process is terminated.  This exercises the hardening
+   that closes the House of Apple 2 / FSROP primitive, where the checked
+   narrow vtable is left legitimate and a forged pointer is hidden in the
+   previously unchecked wide vtable.
+   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 with a raw, attacker-controlled
+   address (as in a House of Apple 2 overwrite) and trigger a wide
+   operation.  With the hardening in place, the stored pointer is
+   mangled, so a raw overwrite fails to demangle into a valid table and
+   the dispatch aborts.  */
+static void
+corrupt_raw_address (void *closure)
+{
+  FILE *fp = open_wide_stream ();
+  /* An arbitrary non-NULL address the attacker might choose.  */
+  fp->_wide_data->_wide_vtable = (const struct _IO_jump_t *) 0x4141414141414141UL;
+  /* Force buffer allocation, which dispatches through the wide vtable
+     (_IO_WDOALLOCATE and friends).  */
+  fputwc (L'x', fp);
+  /* Should not be reached.  */
+  fclose (fp);
+}
+
+/* Callback: point the wide vtable at an address outside the
+   __io_vtables section (here, a stack object).  A raw, unmangled
+   write will not demangle into the section, so the range check in
+   WIO_validate_vtable rejects it and the process aborts.  */
+static void
+corrupt_out_of_section (void *closure)
+{
+  FILE *fp = open_wide_stream ();
+  /* The address of a stack object: definitely outside __io_vtables.  */
+  int local;
+  fp->_wide_data->_wide_vtable = (const struct _IO_jump_t *) &local;
+  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 ("raw-address", corrupt_raw_address);
+  expect_termination ("out-of-section", corrupt_out_of_section);
+
+  return 0;
+}
+
+#include <support/test-driver.c>
-- 
2.55.0



More information about the Libc-alpha mailing list