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

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.

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 <[email protected]>
---
 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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.