[PATCH 2/3] elf: test AT_EXECFD consumption through a binfmt_misc 'O' handler
Adhemerval Zanella Netto
adhemerval.zanella@linaro.org
Thu Jul 16 14:57:14 GMT 2026
On 15/07/26 07:13, Christian Brauner wrote:
> 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 | 282 +++++++++++++++++++++++++
> 3 files changed, 330 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..3974a87b0d
> --- /dev/null
> +++ b/sysdeps/unix/sysv/linux/tst-rtld-execfd.c
> @@ -0,0 +1,282 @@
> +/* 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/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);
We have xwrite as well. This also could be a nice addition to support/
> + 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;
> +}
Should we add a support_become_ns_root to abstract it?
> +
> +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);
> +}
We have support_copy_file for that.
> +
> +/* 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);
> +}
> +
> +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);
I think we should move it either an atexit handler or add some support on
test-container, otherwise if the test exits in an unexpected way the mount
point won't be cleared out.
> + return 0;
> +}
> +
> +#include <support/test-driver.c>
>
More information about the Libc-alpha
mailing list