[PATCH v2] elf: Make string tunables startup-only

Adhemerval Zanella <[email protected]> Wed, 29 Jul 2026 17:18:47 -0300
Newsgroups gmane.comp.lib.glibc.alpha
Message-ID <[email protected]>
A string tunable value usually references the GLIBC_TUNABLES (or alias)
environment string, which lives in the environment block the kernel places
on the initial stack.  That memory is owned by the application, which may
overwrite it (e.g. setproctitle), so the reference is only safe while no
application code has run (a value coming from the system-wide tunables
cache is a copy instead, but the rule is applied uniformly).

This patch make the lifetime explicit and enforced without copying the value
or allocating any memory by adding __tunable_seal_strings, which drops every
string tunable reference once early startup is complete.

The seal is applied after the only string tunable consumer and before any
code outside of the startup sequence runs.

Checked on aarch64-linux-gnu and x86_64-linux-gnu.  I also run the elf
tests on powerpc64le-linux-gnu, loongarch64-linux-gnuf64, and
s390x-linux-gnu.
---
 csu/libc-start.c               |  4 ++
 elf/Makefile                   |  5 +++
 elf/dl-tunables.c              | 43 +++++++++++++++++++
 elf/dl-tunables.h              |  2 +
 elf/rtld.c                     |  6 +++
 elf/tst-tunables-seal-static.c |  1 +
 elf/tst-tunables-seal.c        | 75 ++++++++++++++++++++++++++++++++++
 manual/README.tunables         | 16 ++++++++
 sysdeps/aarch64/Makefile       |  3 ++
 sysdeps/loongarch/Makefile     |  3 ++
 sysdeps/powerpc/Makefile       |  3 ++
 sysdeps/s390/Makefile          |  3 ++
 sysdeps/x86/Makefile           |  3 ++
 13 files changed, 167 insertions(+)
 create mode 100644 elf/tst-tunables-seal-static.c
 create mode 100644 elf/tst-tunables-seal.c

diff --git a/csu/libc-start.c b/csu/libc-start.c
index ec42f23981b..b6fc8597a10 100644
--- a/csu/libc-start.c
+++ b/csu/libc-start.c
@@ -268,6 +268,10 @@ LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
 
   ARCH_INIT_CPU_FEATURES ();
 
+  /* Every string tunable has been consumed by ARCH_INIT_CPU_FEATURES, before
+     any code outside of libc startup runs.  */
+  __tunable_seal_strings ();
+
   /* Do static-pie self relocation for the non-IRELATIVE part after tunables
      and cpu features are set up.  IFUNC entries are deferred until after the
      TCB and the stack-protector canary are usable, so that an instrumented
diff --git a/elf/Makefile b/elf/Makefile
index 94c5b7e6ed8..e6d0a7db94d 100644
--- a/elf/Makefile
+++ b/elf/Makefile
@@ -290,6 +290,7 @@ tests-static-internal := \
   tst-tls1-static-non-pie \
   tst-tunables \
   tst-tunables-enable_secure \
+  tst-tunables-seal-static \
   # tests-static-internal
 
 ifeq (yes,$(have-gcc-ifunc))
@@ -579,6 +580,7 @@ tests-internal += \
   tst-tls6 \
   tst-tls7 \
   tst-tls8 \
+  tst-tunables-seal \
   unload \
   unload2 \
   # tests-internal
@@ -3144,6 +3146,9 @@ $(objpfx)tst-glibc-hwcaps-cache.out: $(objpfx)tst-glibc-hwcaps
 tst-tunables-ARGS = -- $(host-test-program-cmd)
 tst-tunables-enable_secure-ARGS = -- $(host-test-program-cmd)
 
+CFLAGS-tst-tunables-seal-static.c += $(CFLAGS-tst-tunables-seal.c)
+tst-tunables-seal-static-TUNABLES += $(tst-tunables-seal-TUNABLES)
+
 $(objpfx)list-tunables.out: tst-rtld-list-tunables.sh $(objpfx)ld.so
 	$(SHELL) $< $(objpfx)ld.so '$(test-wrapper-env)' \
 	    '$(run_program_env)' > $(objpfx)/tst-rtld-list-tunables.out
diff --git a/elf/dl-tunables.c b/elf/dl-tunables.c
index 54d1296bfff..ee4a1aab31b 100644
--- a/elf/dl-tunables.c
+++ b/elf/dl-tunables.c
@@ -65,6 +65,17 @@ get_next_env (char **envp, char **name, char **val, char ***prev_envp)
   return NULL;
 }
 
+/* Set by __tunable_seal_strings once the values of the string tunables are
+   no longer valid.  */
+static bool tunables_strings_sealed attribute_relro;
+
+static void __attribute__ ((noreturn))
+tunable_sealed_error (const tunable_t *cur, const char *action)
+{
+  _dl_fatal_printf ("Fatal glibc error: %s: string tunable %s after "
+		    "process initialization\n", cur->name, action);
+}
+
 static void
 do_tunable_update_val (tunable_t *cur, const tunable_val_t *valp,
 		       const tunable_num_t *minp,
@@ -78,6 +89,8 @@ do_tunable_update_val (tunable_t *cur, const tunable_val_t *valp,
   switch (cur->type.type_code)
     {
     case TUNABLE_TYPE_STRING:
+      if (__glibc_unlikely (tunables_strings_sealed))
+	tunable_sealed_error (cur, "set");
       cur->val.strval = valp->strval;
       cur->initialized = true;
       return;
@@ -629,6 +642,10 @@ __tunable_get_val (tunable_id_t id, void *valp, tunable_callback_t callback)
 	}
     case TUNABLE_TYPE_STRING:
 	{
+	  /* String tunable values are only valid during early startup; once
+	     sealed they must not be read.  */
+	  if (__glibc_unlikely (tunables_strings_sealed))
+	    tunable_sealed_error (cur, "read");
 	  *((const struct tunable_str_t **) valp) = &cur->val.strval;
 	  break;
 	}
@@ -641,3 +658,29 @@ __tunable_get_val (tunable_id_t id, void *valp, tunable_callback_t callback)
 }
 
 rtld_hidden_def (__tunable_get_val)
+
+/* A string tunable value usually references the GLIBC_TUNABLES (or alias)
+   environment string, which lives in the environment block the kernel places
+   on the initial stack.  That memory is owned by the application, which may
+   overwrite it (e.g. setproctitle), so the reference is only safe while no
+   application code has run.
+   A value coming from the system-wide cache is a private copy instead, but
+   it is sealed as well so that the lifetime rule does not depend on where
+   the value came from.
+   Drop the references so that a later access triggers a fatal error.  */
+void
+__tunable_seal_strings (void)
+{
+  for (int i = 0; i < tunables_list_size; i++)
+    {
+      tunable_t *cur = &tunable_list[i];
+
+      if (cur->type.type_code != TUNABLE_TYPE_STRING)
+	continue;
+
+      cur->val.strval = (struct tunable_str_t) { NULL, 0 };
+    }
+
+  tunables_strings_sealed = true;
+}
+rtld_hidden_def (__tunable_seal_strings)
diff --git a/elf/dl-tunables.h b/elf/dl-tunables.h
index 3f34329614f..bc703553c11 100644
--- a/elf/dl-tunables.h
+++ b/elf/dl-tunables.h
@@ -54,12 +54,14 @@ extern void __tunable_get_val (tunable_id_t, void *, tunable_callback_t);
 extern void __tunable_set_val (tunable_id_t, tunable_val_t *, tunable_num_t *,
 			       tunable_num_t *);
 extern void __tunable_get_default (tunable_id_t id, void *valp);
+extern void __tunable_seal_strings (void);
 rtld_hidden_proto (__tunables_init)
 rtld_hidden_proto (__tunables_print)
 rtld_hidden_proto (__tunable_is_initialized)
 rtld_hidden_proto (__tunable_get_val)
 rtld_hidden_proto (__tunable_set_val)
 rtld_hidden_proto (__tunable_get_default)
+rtld_hidden_proto (__tunable_seal_strings)
 
 /* Define TUNABLE_GET and TUNABLE_SET in short form if TOP_NAMESPACE and
    TUNABLE_NAMESPACE are defined.  This is useful shorthand to get and set
diff --git a/elf/rtld.c b/elf/rtld.c
index fc053df8586..b37c650631b 100644
--- a/elf/rtld.c
+++ b/elf/rtld.c
@@ -1671,6 +1671,12 @@ dl_main (const ElfW(Phdr) *phdr,
 
   _dl_handle_execstack_tunable ();
 
+  /* Every string tunable has been consumed by now (init_cpu_features runs
+     from DL_PLATFORM_INIT, before dl_main).  It precedes any code outside
+     the dynamic loader (the audit modules and IFUNC resolvers), and
+     RELRO.  */
+  __tunable_seal_strings ();
+
   /* If the current libname is different from the SONAME, add the
      latter as well.  */
   {
diff --git a/elf/tst-tunables-seal-static.c b/elf/tst-tunables-seal-static.c
new file mode 100644
index 00000000000..143f972fdc1
--- /dev/null
+++ b/elf/tst-tunables-seal-static.c
@@ -0,0 +1 @@
+#include "tst-tunables-seal.c"
diff --git a/elf/tst-tunables-seal.c b/elf/tst-tunables-seal.c
new file mode 100644
index 00000000000..b0494fbd8e8
--- /dev/null
+++ b/elf/tst-tunables-seal.c
@@ -0,0 +1,75 @@
+/* Verify that string tunables are sealed after early startup.
+   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/>.  */
+
+/* This generic test is parameterized by TST_SEAL_TUNABLE_NAME, the name of
+   a glibc.cpu string tunable for the target ABI.  */
+
+#include <stdlib.h>
+#include <support/check.h>
+
+#ifdef TST_SEAL_TUNABLE_NAME
+
+# include <string.h>
+# include <unistd.h>
+# include <support/capture_subprocess.h>
+
+# define TUNABLE_NAMESPACE cpu
+# include <elf/dl-tunables.h>
+
+# define STRINGIFY(x) STRINGIFY1 (x)
+# define STRINGIFY1(x) #x
+
+/* The full internal name of the tunable, e.g. "glibc.cpu.hwcaps", as
+   embedded in the fatal error message.  */
+# define TST_SEAL_TUNABLE_FULLNAME \
+  STRINGIFY (TOP_NAMESPACE) "." STRINGIFY (TUNABLE_NAMESPACE) "." \
+  STRINGIFY (TST_SEAL_TUNABLE_NAME)
+
+static void
+read_sealed_tunable (void *closure)
+{
+  TUNABLE_GET (TST_SEAL_TUNABLE_NAME, struct tunable_str_t *, NULL);
+  /* Not reached: the read above is a fatal error.  Exit successfully so
+     that a missing diagnostic is caught as an unexpected exit status.  */
+  _exit (EXIT_SUCCESS);
+}
+
+#endif /* TST_SEAL_TUNABLE_NAME  */
+
+static int
+do_test (void)
+{
+#ifndef TST_SEAL_TUNABLE_NAME
+  FAIL_UNSUPPORTED ("the target ABI has no glibc.cpu string tunable");
+#else
+  struct support_capture_subprocess result
+    = support_capture_subprocess (read_sealed_tunable, NULL);
+
+  support_capture_subprocess_check (&result, "tst-tunables-seal", 127,
+				    sc_allow_stderr);
+  TEST_VERIFY (strstr (result.err.buffer,
+		       "Fatal glibc error: " TST_SEAL_TUNABLE_FULLNAME
+		       ": string tunable read after process initialization")
+	       != NULL);
+
+  support_capture_subprocess_free (&result);
+  return 0;
+#endif
+}
+
+#include <support/test-driver.c>
diff --git a/manual/README.tunables b/manual/README.tunables
index 594879397b9..0c191e18094 100644
--- a/manual/README.tunables
+++ b/manual/README.tunables
@@ -132,6 +132,22 @@ The tunable list is set as read-only after the dynamic linker relocates itself,
 so setting tunable values must be limited only to tunables within the dynamic
 linker, that too before relocation.
 
+STRING TUNABLES ARE STARTUP-ONLY
+--------------------------------
+
+The value of a STRING tunable usually points into the GLIBC_TUNABLES (or
+alias) environment variable, which lives in the environment block the kernel
+places on the initial stack.  That memory is owned by the application, which
+may overwrite it (e.g. setproctitle), so the value is only valid while no
+code outside of startup has run.
+
+The values are therefore sealed by __tunable_seal_strings, called from
+dl_main for dynamically linked programs and from __libc_start_main for
+static ones.  Both calls happen after init_cpu_features, the only consumer
+of string tunables, and before audit modules, IFUNC resolvers or
+constructors can run.  Reading or setting a string tunable after that point
+is a glibc internal error and terminates the process.
+
 FUTURE WORK
 -----------
 
diff --git a/sysdeps/aarch64/Makefile b/sysdeps/aarch64/Makefile
index 52ac85a75db..87ce51c8214 100644
--- a/sysdeps/aarch64/Makefile
+++ b/sysdeps/aarch64/Makefile
@@ -50,6 +50,9 @@ tests-internal += \
   tst-ifunc-arg-4 \
   # tests-internal
 
+CFLAGS-tst-tunables-seal.c += -DTST_SEAL_TUNABLE_NAME=hwcaps
+tst-tunables-seal-TUNABLES += glibc.cpu.hwcaps=-midr
+
 tests += \
   tst-vpcs \
   # tests
diff --git a/sysdeps/loongarch/Makefile b/sysdeps/loongarch/Makefile
index b00c090faaa..5818755f053 100644
--- a/sysdeps/loongarch/Makefile
+++ b/sysdeps/loongarch/Makefile
@@ -19,6 +19,9 @@ sysdep-dl-routines += \
 gen-as-const-headers += \
   dl-link.sym \
   # gen-as-const-headers
+
+CFLAGS-tst-tunables-seal.c += -DTST_SEAL_TUNABLE_NAME=hwcaps
+tst-tunables-seal-TUNABLES += glibc.cpu.hwcaps=-LASX
 endif
 
 ifeq ($(subdir),csu)
diff --git a/sysdeps/powerpc/Makefile b/sysdeps/powerpc/Makefile
index 5cdb64f29ba..fadd45807fb 100644
--- a/sysdeps/powerpc/Makefile
+++ b/sysdeps/powerpc/Makefile
@@ -17,6 +17,9 @@ $(objpfx)tst-tlsopt-powerpc: $(objpfx)mod-tlsopt-powerpc.so
 tests-static += tst-cache-ppc-static
 tests-internal += tst-cache-ppc-static
 
+CFLAGS-tst-tunables-seal.c += -DTST_SEAL_TUNABLE_NAME=hwcaps
+tst-tunables-seal-TUNABLES += glibc.cpu.hwcaps=-arch_3_1
+
 ifeq (yes,$(build-shared))
 modules-names += mod-cache-ppc
 tests += tst-cache-ppc tst-cache-ppc-static-dlopen
diff --git a/sysdeps/s390/Makefile b/sysdeps/s390/Makefile
index 481e8347925..309425c7e1d 100644
--- a/sysdeps/s390/Makefile
+++ b/sysdeps/s390/Makefile
@@ -165,6 +165,9 @@ tst-dl-runtime-resolve-audit-ENV = $(env-audit)
 tst-dl-runtime-profile-noaudit-ENV = $(env-profile)
 tst-dl-runtime-profile-audit-ENV = $(env-profile) $(env-audit)
 endif
+
+CFLAGS-tst-tunables-seal.c += -DTST_SEAL_TUNABLE_NAME=hwcaps
+tst-tunables-seal-TUNABLES += glibc.cpu.hwcaps=z13
 endif
 
 ifeq ($(subdir),string)
diff --git a/sysdeps/x86/Makefile b/sysdeps/x86/Makefile
index 232e388d325..5058fbd4bd3 100644
--- a/sysdeps/x86/Makefile
+++ b/sysdeps/x86/Makefile
@@ -104,6 +104,9 @@ tst-ifunc-isa-2-ENV = GLIBC_TUNABLES=glibc.cpu.hwcaps=-SSE4_2,-AVX,-AVX2,-AVX512
 tst-ifunc-isa-2-static-ENV = $(tst-ifunc-isa-2-ENV)
 tst-hwcap-tunables-ARGS = -- $(host-test-program-cmd)
 
+CFLAGS-tst-tunables-seal.c += -DTST_SEAL_TUNABLE_NAME=hwcaps
+tst-tunables-seal-TUNABLES += glibc.cpu.hwcaps=-AVX2
+
 CFLAGS-tst-gnu2-tls2.c += -msse2
 CFLAGS-tst-gnu2-tls2mod0.c += -msse2 -mtune=haswell
 CFLAGS-tst-gnu2-tls2mod1.c += -msse2 -mtune=haswell
-- 
2.53.0