[PATCH v3 2/4] elf: test AT_EXECFD consumption through a binfmt_misc 'O' handler

Christian Brauner brauner@kernel.org
Fri Jul 17 11:11:59 GMT 2026


Register the dynamic linker under test as a binfmt_misc extension
handler with the 'O' (open-binary) flag inside a private user and
mount namespace - binfmt_misc instances are per-user-namespace since
Linux 6.7, so nothing leaks to the host and the test is parallel-safe.
Kernels without sandboxed binfmt_misc mounts report UNSUPPORTED.

The helper executed through the handler verifies the contract:

  - the application-visible argument vector is exactly what a direct
    execution produces (the splice is consumed by ld.so as usual),
  - getauxval (AT_EXECFD) reports the entry as absent: the descriptor
    was consumed, closed, and neutralized to AT_IGNORE,
  - AT_EXECFN is the original path,
  - LD_TRACE_LOADED_OBJECTS lists the dependencies of the
    descriptor-loaded main program,
  - an execute-only (--x) copy still runs after the test sheds
    CAP_DAC_OVERRIDE/CAP_DAC_READ_SEARCH, while a path open fails
    with EACCES - proving the program really is loaded from the
    descriptor and not re-opened by path.

The namespace setup maps uid/gid 0 (unshare -r style) rather than
using support_become_root, which identity-maps the original uid: the
binfmt_misc inodes are owned by the namespace's uid 0, and an
unmapped owner cannot pass the permission checks for the register
file.  It is provided as a new support_become_ns_root helper next to
support_become_root.  The binfmt_misc instance is unmounted from an
atexit handler so a failing subtest does not leave the mount pinning
the temporary directory.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 support/Makefile                               |   1 +
 support/namespace.h                            |  12 ++
 support/support_become_ns_root.c               | 109 +++++++++++
 sysdeps/unix/sysv/linux/Makefile               |   5 +
 sysdeps/unix/sysv/linux/tst-rtld-execfd-prog.c |  43 +++++
 sysdeps/unix/sysv/linux/tst-rtld-execfd.c      | 243 +++++++++++++++++++++++++
 6 files changed, 413 insertions(+)

diff --git a/support/Makefile b/support/Makefile
index 87eeb8199f..3790c909f5 100644
--- a/support/Makefile
+++ b/support/Makefile
@@ -51,6 +51,7 @@ libsupport-routines = \
   resolv_test \
   set_fortify_handler \
   support-open-dev-null-range \
+  support_become_ns_root \
   support_become_root \
   support_can_chroot \
   support_capture_subprocess \
diff --git a/support/namespace.h b/support/namespace.h
index f4b29a180f..9a0e38faf6 100644
--- a/support/namespace.h
+++ b/support/namespace.h
@@ -35,6 +35,18 @@ __BEGIN_DECLS
    single-threaded processes.  */
 bool support_become_root (void);
 
+/* Attempts to become root (UID and GID 0) in a new user namespace,
+   with a new mount namespace in which mount operations do not affect
+   the host (/ is marked private).  Unlike support_become_root, which
+   identity-maps the original UID, the namespace's UID 0 is mapped to
+   the original UID: kernel objects created in the namespace are owned
+   by its UID 0, and an unmapped owner cannot pass permission checks
+   on them.  Return true if the namespace's root user could be
+   attained.  Print diagnostics to standard output.  The note on
+   multi-threaded processes for support_become_root applies here as
+   well.  */
+bool support_become_ns_root (void);
+
 /* Return true if this process can perform a chroot operation.  In
    general, this is only possible if support_become_root has been
    called.  Note that the actual test is performed in a subprocess,
diff --git a/support/support_become_ns_root.c b/support/support_become_ns_root.c
new file mode 100644
index 0000000000..1b05fdc712
--- /dev/null
+++ b/support/support_become_ns_root.c
@@ -0,0 +1,109 @@
+/* Become root inside a new user namespace.
+   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 <support/namespace.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <sched.h>
+#include <stdio.h>
+#include <string.h>
+#include <support/check.h>
+#include <support/xunistd.h>
+#include <unistd.h>
+#ifdef CLONE_NEWNS
+# include <sys/mount.h>
+#endif /* CLONE_NEWNS */
+
+#if defined CLONE_NEWUSER && defined CLONE_NEWNS
+/* Write STR to PATH.  Failure is left to the caller to report, so
+   that an unsupported kernel results in a false return from
+   support_become_ns_root, not a test failure.  */
+static bool
+write_string_to_file (const char *path, const char *str)
+{
+  int fd = open64 (path, O_WRONLY);
+  if (fd < 0)
+    return false;
+  ssize_t len = strlen (str);
+  bool ok = write (fd, str, len) == len;
+  xclose (fd);
+  return ok;
+}
+#endif /* CLONE_NEWUSER && CLONE_NEWNS */
+
+bool
+support_become_ns_root (void)
+{
+#if defined CLONE_NEWUSER && defined CLONE_NEWNS
+  uid_t original_uid = getuid ();
+  gid_t original_gid = getgid ();
+
+  if (unshare (CLONE_NEWUSER | CLONE_NEWNS) != 0)
+    {
+      printf ("warning: unshare (CLONE_NEWUSER | CLONE_NEWNS) failed: %m\n");
+      return false;
+    }
+
+  /* Map the namespace's UID 0 to the original UID.  Unlike the
+     identity mapping support_become_root sets up, this makes the
+     process the owner of kernel objects created in the namespace,
+     which are owned by its UID 0.  */
+  char buf[100];
+  int ret = snprintf (buf, sizeof (buf), "0 %llu 1\n",
+                      (unsigned long long) original_uid);
+  TEST_VERIFY_EXIT (ret < sizeof (buf));
+  if (!write_string_to_file ("/proc/self/uid_map", buf))
+    {
+      printf ("warning: writing to /proc/self/uid_map failed: %m\n");
+      return false;
+    }
+
+  /* Linux 3.19 introduced the setgroups file.  "deny" must be written
+     to it before gid_map becomes writable.  */
+  if (!write_string_to_file ("/proc/self/setgroups", "deny\n")
+      && errno != ENOENT)
+    {
+      printf ("warning: writing to /proc/self/setgroups failed: %m\n");
+      return false;
+    }
+
+  /* Now map the namespace's GID 0, like the UID.  */
+  ret = snprintf (buf, sizeof (buf), "0 %llu 1\n",
+                  (unsigned long long) original_gid);
+  TEST_VERIFY_EXIT (ret < sizeof (buf));
+  if (!write_string_to_file ("/proc/self/gid_map", buf))
+    {
+      printf ("warning: writing to /proc/self/gid_map failed: %m\n");
+      return false;
+    }
+
+  /* On some systems, / is marked as MS_SHARED, which means that
+     mounts within the namespace leak to the rest of the system,
+     which is not what we want.  */
+  if (mount ("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0)
+    {
+      printf ("warning: making the mount namespace private failed: %m\n");
+      return false;
+    }
+
+  return getuid () == 0;
+#else
+  return false;
+#endif /* CLONE_NEWUSER && CLONE_NEWNS */
+}
diff --git a/sysdeps/unix/sysv/linux/Makefile b/sysdeps/unix/sysv/linux/Makefile
index 56b160e253..d1abf2b563 100644
--- a/sysdeps/unix/sysv/linux/Makefile
+++ b/sysdeps/unix/sysv/linux/Makefile
@@ -697,6 +697,7 @@ $(objpfx)pldd: $(objpfx)xmalloc.o
 tests += \
   tst-rseq-tls-range \
   tst-rseq-tls-range-4096 \
+  tst-rtld-execfd \
   tst-thp-1 \
   tst-thp-1-pde \
   tst-thp-1-static \
@@ -707,6 +708,10 @@ tests-static += \
   tst-rseq-tls-range-static \
   tst-thp-1-static \
 # tests-static
+test-srcs += \
+  tst-rtld-execfd-prog \
+# test-srcs
+$(objpfx)tst-rtld-execfd.out: $(objpfx)tst-rtld-execfd-prog
 modules-names += \
   tst-rseq-tls-range-mod \
   tst-thp-size-mod \
diff --git a/sysdeps/unix/sysv/linux/tst-rtld-execfd-prog.c b/sysdeps/unix/sysv/linux/tst-rtld-execfd-prog.c
new file mode 100644
index 0000000000..e209279255
--- /dev/null
+++ b/sysdeps/unix/sysv/linux/tst-rtld-execfd-prog.c
@@ -0,0 +1,43 @@
+/* Helper program for tst-rtld-execfd: report argv and AT_EXECFD state.
+   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; see the file COPYING.LIB.  If
+   not, see <https://www.gnu.org/licenses/>.  */
+
+/* Executed by tst-rtld-execfd through a binfmt_misc handler whose
+   interpreter is the dynamic linker under test.  Prints what an
+   application observes; the parent compares it against a direct
+   execution.  */
+
+#include <errno.h>
+#include <stdio.h>
+#include <sys/auxv.h>
+
+int
+main (int argc, char **argv)
+{
+  printf ("argc=%d\n", argc);
+  for (int i = 0; i < argc; ++i)
+    printf ("argv[%d]=%s\n", i, argv[i]);
+
+  errno = 0;
+  unsigned long int execfd = getauxval (AT_EXECFD);
+  printf ("AT_EXECFD=%lu errno=%d\n", execfd, errno);
+
+  const char *execfn = (const char *) getauxval (AT_EXECFN);
+  printf ("AT_EXECFN=%s\n", execfn != NULL ? execfn : "(null)");
+
+  return 0;
+}
diff --git a/sysdeps/unix/sysv/linux/tst-rtld-execfd.c b/sysdeps/unix/sysv/linux/tst-rtld-execfd.c
new file mode 100644
index 0000000000..4402a351c7
--- /dev/null
+++ b/sysdeps/unix/sysv/linux/tst-rtld-execfd.c
@@ -0,0 +1,243 @@
+/* Test that ld.so loads the main program from AT_EXECFD.
+   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; see the file COPYING.LIB.  If
+   not, see <https://www.gnu.org/licenses/>.  */
+
+/* Register the dynamic linker under test as a binfmt_misc extension
+   handler with the 'O' (open-binary) flag in a private user and mount
+   namespace, execute a helper through it, and verify that the helper
+   was loaded from the AT_EXECFD descriptor: the application-visible
+   argument vector is unchanged, the descriptor is not observable via
+   getauxval, LD_TRACE_LOADED_OBJECTS works, and an execute-only (--x)
+   copy - unopenable by path - still runs.
+
+   Requires a kernel with per-user-namespace binfmt_misc mounts
+   (Linux >= 6.7); reports UNSUPPORTED otherwise.  */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/prctl.h>
+#include <sys/syscall.h>
+#include <linux/capability.h>
+
+#include <support/capture_subprocess.h>
+#include <support/check.h>
+#include <support/namespace.h>
+#include <support/support.h>
+#include <support/temp_file.h>
+#include <support/xunistd.h>
+
+/* The binfmt_misc extension (filename suffix) the handler matches.  */
+#define EXT "tstexecfd"
+
+static char *binfmt_dir;
+static char *prog_copy;
+static char *libpath_env;
+
+/* Unmount the binfmt_misc instance.  Registered with atexit so that a
+   failing subtest does not leave the mount pinning the temporary
+   directory; the temporary files are deleted after this runs (atexit
+   handlers run in reverse registration order).  */
+static void
+unmount_binfmt (void)
+{
+  umount2 (binfmt_dir, MNT_DETACH);
+}
+
+/* Drop the DAC-bypassing capabilities so that file permissions apply
+   to this (namespace-root) process again.  Cannot be undone.  */
+static void
+drop_dac_capabilities (void)
+{
+  struct __user_cap_header_struct header =
+    { .version = _LINUX_CAPABILITY_VERSION_3, .pid = 0 };
+  struct __user_cap_data_struct data[2];
+  TEST_COMPARE (syscall (SYS_capget, &header, data), 0);
+  data[0].effective &= ~((1u << CAP_DAC_OVERRIDE) | (1u << CAP_DAC_READ_SEARCH));
+  data[0].permitted &= ~((1u << CAP_DAC_OVERRIDE) | (1u << CAP_DAC_READ_SEARCH));
+  data[0].inheritable &= ~((1u << CAP_DAC_OVERRIDE)
+			   | (1u << CAP_DAC_READ_SEARCH));
+  TEST_COMPARE (syscall (SYS_capset, &header, data), 0);
+
+  /* capset leaves the bounding set untouched, and a namespace-root
+     execve re-grants permitted = bounding | inheritable (effective
+     too), so the exec'd loader would regain CAP_DAC_OVERRIDE and could
+     open the execute-only file by path.  Drop the caps from the
+     bounding set as well so file permissions bind across the exec.  */
+  TEST_COMPARE (prctl (PR_CAPBSET_DROP, CAP_DAC_OVERRIDE, 0, 0, 0), 0);
+  TEST_COMPARE (prctl (PR_CAPBSET_DROP, CAP_DAC_READ_SEARCH, 0, 0, 0), 0);
+}
+
+static struct support_capture_subprocess
+run_prog_copy (const char *arg1, const char *arg2, bool trace)
+{
+  char *argv[] = { prog_copy, (char *) arg1, (char *) arg2, NULL };
+  char *envp[3] = { libpath_env, NULL, NULL };
+  if (trace)
+    envp[1] = (char *) "LD_TRACE_LOADED_OBJECTS=1";
+  return support_capture_subprogram (prog_copy, argv, envp);
+}
+
+static int
+do_test (void)
+{
+  /* The namespace's uid/gid 0 must be mapped (support_become_root
+     identity-maps the original ids instead): the binfmt_misc inodes
+     are owned by the namespace's uid 0, and an unmapped owner cannot
+     pass the permission (or capability) checks for writing to the
+     register file.  */
+  if (!support_become_ns_root ())
+    FAIL_UNSUPPORTED ("cannot create user+mount namespace with uid 0");
+
+  /* A binfmt_misc instance mounted in a user namespace is private to
+     it: registrations neither affect nor require anything from the
+     host (Linux >= 6.7).  */
+  binfmt_dir = support_create_temp_directory ("tst-rtld-execfd-binfmt-");
+  if (mount ("binfmt_misc", binfmt_dir, "binfmt_misc", 0, NULL) != 0)
+    {
+      if (errno == ENODEV || errno == ENOENT || errno == ENOSYS
+	  || errno == EPERM || errno == EACCES || errno == EINVAL)
+	FAIL_UNSUPPORTED ("cannot mount binfmt_misc: %m");
+      FAIL_EXIT1 ("mount binfmt_misc: %m");
+    }
+  atexit (unmount_binfmt);
+
+  /* Register the dynamic linker under test as an extension handler
+     with the 'O' flag, so the kernel keeps the executed binary open
+     and passes it in AT_EXECFD.  */
+  {
+    char *reg = xasprintf (":tst-rtld-execfd:E::" EXT "::%s:O",
+			   support_objdir_elf_ldso);
+    char *regpath = xasprintf ("%s/register", binfmt_dir);
+    support_write_file_string (regpath, reg);
+    free (regpath);
+    free (reg);
+  }
+
+  /* The handler execs ld.so without options, so the helper must find
+     the build-tree libraries through the environment.  */
+  libpath_env = xasprintf ("LD_LIBRARY_PATH=%s:%s/elf",
+			   support_objdir_root, support_objdir_root);
+
+  char *prog = xasprintf ("%s/elf/tst-rtld-execfd-prog",
+			  support_objdir_root);
+  char *tmpdir = support_create_temp_directory ("tst-rtld-execfd-");
+  prog_copy = xasprintf ("%s/prog." EXT, tmpdir);
+  support_copy_file (prog, prog_copy);
+  add_temp_file (prog_copy);
+  free (tmpdir);
+  free (prog);
+
+  /* Execute the helper through the handler.  The application must
+     observe exactly what a direct execution would produce: the argv
+     the kernel spliced for the interpreter is consumed by ld.so as
+     usual, and the descriptor is loaded from and neutralized.  */
+  {
+    struct support_capture_subprocess cap
+      = run_prog_copy ("first-arg", "second-arg", false);
+    support_capture_subprocess_check (&cap, "execfd", 0, sc_allow_stdout);
+    char *expected = xasprintf ("argc=3\n"
+				"argv[0]=%s\n"
+				"argv[1]=first-arg\n"
+				"argv[2]=second-arg\n"
+				"AT_EXECFD=0 errno=%d\n"
+				"AT_EXECFN=%s\n",
+				prog_copy, ENOENT, prog_copy);
+    TEST_COMPARE_STRING (cap.out.buffer, expected);
+    free (expected);
+    support_capture_subprocess_free (&cap);
+  }
+
+  /* LD_TRACE_LOADED_OBJECTS (ldd) must work for a descriptor-loaded
+     main program.  */
+  {
+    struct support_capture_subprocess cap
+      = run_prog_copy (NULL, NULL, true);
+    support_capture_subprocess_check (&cap, "execfd trace", 0,
+				      sc_allow_stdout);
+    TEST_VERIFY (strstr (cap.out.buffer, "libc.so") != NULL);
+    support_capture_subprocess_free (&cap);
+  }
+
+  /* A program whose spliced path begins with "--" must be run, not
+     mistaken for a dynamic-linker option: the kernel copies the
+     execve() pathname into the interpreter's argument vector verbatim,
+     so a program named like a loader option would otherwise divert or
+     abort the loader.  A relative path is required for the kernel to
+     hand ld.so an argument starting with "--"; an absolute path always
+     begins with "/".  */
+  {
+    const char *dashrel = "--library-path." EXT;
+    char *dashdir = support_create_temp_directory ("tst-rtld-execfd-dash-");
+    char *dashabs = xasprintf ("%s/%s", dashdir, dashrel);
+    support_copy_file (prog_copy, dashabs);
+    add_temp_file (dashabs);
+
+    xchdir (dashdir);
+    char *argv[] = { (char *) dashrel, (char *) "tail", NULL };
+    char *envp[2] = { libpath_env, NULL };
+    struct support_capture_subprocess cap
+      = support_capture_subprogram (dashrel, argv, envp);
+    xchdir ("/");
+    support_capture_subprocess_check (&cap, "execfd dashname", 0,
+				      sc_allow_stdout);
+    char *expected = xasprintf ("argc=2\n"
+				"argv[0]=%s\n"
+				"argv[1]=tail\n"
+				"AT_EXECFD=0 errno=%d\n"
+				"AT_EXECFN=%s\n",
+				dashrel, ENOENT, dashrel);
+    TEST_COMPARE_STRING (cap.out.buffer, expected);
+    free (expected);
+    support_capture_subprocess_free (&cap);
+    free (dashabs);
+    free (dashdir);
+  }
+
+  /* The headline capability: an execute-only binary cannot be opened
+     by path, but execve() permits it and the descriptor the kernel
+     passes is readable.  Give up the DAC-override capabilities first,
+     otherwise this (namespace-root) process could open it anyway.
+     This must be the last subtest: the capabilities are gone.  */
+  {
+    TEST_COMPARE (chmod (prog_copy, 0111), 0);
+    drop_dac_capabilities ();
+
+    /* Control: the path really is unopenable now.  This is what any
+       path-based re-open in ld.so would run into.  */
+    errno = 0;
+    TEST_COMPARE (open (prog_copy, O_RDONLY), -1);
+    TEST_COMPARE (errno, EACCES);
+
+    struct support_capture_subprocess cap
+      = run_prog_copy ("x-only", NULL, false);
+    support_capture_subprocess_check (&cap, "execfd execute-only", 0,
+				      sc_allow_stdout);
+    TEST_VERIFY (strstr (cap.out.buffer, "argv[1]=x-only") != NULL);
+    TEST_VERIFY (strstr (cap.out.buffer, "AT_EXECFD=0 errno=2") != NULL);
+    support_capture_subprocess_free (&cap);
+  }
+
+  return 0;
+}
+
+#include <support/test-driver.c>

-- 
2.53.0



More information about the Libc-alpha mailing list