[PATCH v2 2/3] 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.

Signed-off-by: Christian Brauner (Amutable) <[email protected]>
---
 sysdeps/unix/sysv/linux/Makefile               |   5 +
 sysdeps/unix/sysv/linux/tst-rtld-execfd-prog.c |  43 ++++
 sysdeps/unix/sysv/linux/tst-rtld-execfd.c      | 291 +++++++++++++++++++++++++
 3 files changed, 339 insertions(+)

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..cea4b91732
--- /dev/null
+++ b/sysdeps/unix/sysv/linux/tst-rtld-execfd.c
@@ -0,0 +1,291 @@
+/* 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 <sched.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/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;
+
+static void
+write_string_to_file (const char *path, const char *str)
+{
+  int fd = xopen (path, O_WRONLY, 0);
+  ssize_t len = strlen (str);
+  if (write (fd, str, len) != len)
+    FAIL_EXIT1 ("write (\"%s\", \"%s\"): %m", path, str);
+  xclose (fd);
+}
+
+/* Create a user and mount namespace with uid/gid 0 mapped to the
+   original ids.  Unlike support_become_root, which identity-maps the
+   original uid, the namespace's uid 0 must be mapped here: the
+   binfmt_misc inodes are owned by it, and an unmapped owner cannot
+   pass the permission (or capability) checks for writing to the
+   register file.  */
+static bool
+become_ns_root (void)
+{
+  uid_t orig_uid = getuid ();
+  gid_t orig_gid = getgid ();
+
+  if (unshare (CLONE_NEWUSER | CLONE_NEWNS) != 0)
+    return false;
+
+  char *buf = xasprintf ("0 %llu 1\n", (unsigned long long) orig_uid);
+  write_string_to_file ("/proc/self/uid_map", buf);
+  free (buf);
+  write_string_to_file ("/proc/self/setgroups", "deny\n");
+  buf = xasprintf ("0 %llu 1\n", (unsigned long long) orig_gid);
+  write_string_to_file ("/proc/self/gid_map", buf);
+  free (buf);
+
+  /* Keep mount operations away from the host.  */
+  if (mount ("none", "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0)
+    return false;
+
+  return getuid () == 0;
+}
+
+static void
+copy_file (const char *from, const char *to, mode_t mode)
+{
+  int fd_from = xopen (from, O_RDONLY, 0);
+  int fd_to = xopen (to, O_WRONLY | O_CREAT | O_TRUNC, 0700);
+  char buf[65536];
+  ssize_t nread;
+  while ((nread = read (fd_from, buf, sizeof (buf))) > 0)
+    {
+      ssize_t total = 0;
+      while (total < nread)
+	{
+	  ssize_t nwritten = write (fd_to, buf + total, nread - total);
+	  TEST_VERIFY_EXIT (nwritten > 0);
+	  total += nwritten;
+	}
+    }
+  TEST_COMPARE (nread, 0);
+  TEST_COMPARE (fchmod (fd_to, mode), 0);
+  xclose (fd_from);
+  xclose (fd_to);
+}
+
+/* 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)
+{
+  if (!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");
+    }
+
+  /* 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);
+    write_string_to_file (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);
+  copy_file (prog, prog_copy, 0755);
+  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);
+    copy_file (prog_copy, dashabs, 0755);
+    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);
+  }
+
+  umount2 (binfmt_dir, MNT_DETACH);
+  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.