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

Christian Brauner <[email protected]>
Newsgroups gmane.comp.lib.glibc.alpha
Message-ID <[email protected]>
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) <[email protected]>
---
 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
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.