[PATCH v2 2/3] elf: test AT_EXECFD consumption through a binfmt_misc 'O' handler
Christian Brauner
brauner@kernel.org
Thu Jul 16 14:55:30 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.
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
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
More information about the Libc-alpha
mailing list