[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