[PATCH 1/3] elf: load the main program from AT_EXECFD when run as a binfmt interpreter

Christian Brauner brauner@kernel.org
Wed Jul 15 10:13:03 GMT 2026


A Linux binfmt_misc entry registered with the 'O' (open-binary) or 'C'
(credentials) flag keeps the executed binary open across the handler
dispatch and passes the descriptor to the interpreter in the AT_EXECFD
auxiliary vector entry.  The semantics are SVR4's: AT_EXECFD is the
"file descriptor of program to load", the alternative to AT_PHDR.
FreeBSD's rtld consumes it, qemu-user has consumed it since 2013 - and
rtld never has ("We also do not handle AT_EXECFD even if it would be
passed up"), so handlers dispatching to ld.so had the descriptor
ignored, leaked into the application, and the program re-opened by
path.

Consume the descriptor, gated on the existing rtld_is_main detection:
when ld.so is the main program and AT_EXECFD is present, load the main
object from the descriptor via open_verify/_dl_map_object_from_fd
instead of re-opening rtld_progname by path.  Argument processing is
entirely unchanged: every producer of AT_EXECFD splices the interpreter
and the binary path into the argument vector, so the program name
argument is consumed exactly as before and the application-visible
argument vector is identical.  What changes is that the loaded file is
now the very struct file that execve() access-checked (no re-open
race), and that no path-based open happens at all, so execute-only
(--x) binaries that execve() permits but ld.so previously failed to
open with EACCES now run.  The canonical name for $ORIGIN is derived
from the descriptor by the existing __RTLD_OPENEXEC handling.

The descriptor is consumed and closed while mapping, and the on-stack
auxv entry is neutralized to AT_IGNORE so the program does not observe
a dangling descriptor number.  (FreeBSD's rtld leaves the stale entry
behind; qemu synthesizes a clean guest auxv; we edit the vector in
place like the existing AT_PHDR/AT_ENTRY rewrites.)  If the descriptor
arrived on a standard descriptor slot - the kernel installs it on the
lowest free one - it is first moved above the standard range and the
startup standard-descriptor check is re-run for secure processes,
because that check ran while the descriptor still occupied the slot.

Chain loading is not attempted for a descriptor-loaded program:
re-executing the spliced path would run the process through the very
binfmt handler that chose this dynamic linker again, looping forever
(no recursion depth accumulates across separate execve() calls), and
there may be no path that can be executed in the first place.
Statically linked binaries are refused with a clear error instead,
otherwise they would crash in-process again (bug 28648).  Trace mode
(LD_TRACE_LOADED_OBJECTS) never chain-loaded and behaves exactly as
the explicit-loader invocation does today, including listing the
dependencies of a descriptor-loaded dynamic executable.

When rtld runs as the PT_INTERP of someone else's kernel-loaded main
program (e.g. a dynamically linked qemu-user registered with 'O'),
rtld_is_main is false and AT_EXECFD is addressed to that program, so
nothing changes there.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
---
 NEWS                       |   9 ++++
 elf/dl-load.c              |  47 +++++++++++++++++
 elf/rtld.c                 | 125 ++++++++++++++++++++++++++++++++++++++++++---
 sysdeps/generic/ldsodefs.h |   7 +++
 4 files changed, 181 insertions(+), 7 deletions(-)

diff --git a/NEWS b/NEWS
index f9d90c5194..cacd3f8be6 100644
--- a/NEWS
+++ b/NEWS
@@ -9,6 +9,15 @@ Version 2.44
 
 Major new features:
 
+* When the dynamic linker is executed as a binfmt interpreter (for
+  example through a Linux binfmt_misc entry registered with the 'O'
+  flag) and the kernel passes the executed program as an open
+  descriptor in the AT_EXECFD auxiliary vector entry, the dynamic
+  linker now loads the program from that descriptor instead of
+  re-opening it by path.  Execute-only (--x) binaries work under such
+  handlers, and the descriptor refers to the file the kernel actually
+  access-checked, eliminating the re-open race.
+
 * A new tunable, glibc.elf.thp, is added to map read-only segments with
   Transparent Huge Pages (THP) if THP isn't disable in kernel.  When
   glibc.elf.thp is set to 1, malloc uses the actual kernel THP mode
diff --git a/elf/dl-load.c b/elf/dl-load.c
index 95404adae9..7c1794579b 100644
--- a/elf/dl-load.c
+++ b/elf/dl-load.c
@@ -2241,6 +2241,53 @@ _dl_map_object (struct link_map *loader, const char *name,
   return _dl_map_new_object (loader, name, type, trace_mode, mode, nsid);
 }
 
+/* Map in the main executable, already opened on FD.  The descriptor
+   comes from the kernel (AT_EXECFD, from a binfmt interpreter dispatch
+   that kept the executed binary open) or from an explicit loader
+   invocation.  NAME is the name the program is known by and is only
+   used for diagnostics; the canonical name used for $ORIGIN is derived
+   from the descriptor itself (__RTLD_OPENEXEC).  There may be no path
+   the program could be opened by: the descriptor is readable even for
+   an execute-only binary, and it refers to the very file the kernel
+   access-checked, so no path re-open takes its place.  */
+struct link_map *
+_dl_map_object_execfd (int fd, const char *name)
+{
+  struct filebuf fb;
+  bool found_other_class = false;
+
+  /* The kernel hands over the descriptor with the file position at
+     zero, but an explicit loader invocation need not; the header check
+     in open_verify reads sequentially.  */
+  __lseek (fd, 0, SEEK_SET);
+
+  fd = open_verify (name, fd, &fb, NULL, 0, __RTLD_OPENEXEC,
+		    &found_other_class, false);
+  if (__glibc_unlikely (fd == -1))
+    {
+      if (found_other_class)
+	_dl_signal_error (0, name, NULL,
+			  ELFW(CLASS) == ELFCLASS32
+			  ? N_("wrong ELF class: ELFCLASS64")
+			  : N_("wrong ELF class: ELFCLASS32"));
+      else
+	_dl_signal_error (errno, name, NULL,
+			  N_("cannot load main program from descriptor"));
+    }
+
+  char *realname = __strdup (name);
+  if (realname == NULL)
+    {
+      __close_nocancel (fd);
+      _dl_signal_error (ENOMEM, name, NULL,
+			N_("cannot allocate name record"));
+    }
+
+  return _dl_map_object_from_fd (name, NULL, fd, &fb, realname, NULL,
+				 lt_executable, __RTLD_OPENEXEC,
+				 __libc_stack_end, LM_ID_BASE);
+}
+
 
 struct add_path_state
 {
diff --git a/elf/rtld.c b/elf/rtld.c
index e5ba71fef1..9d29cc8f64 100644
--- a/elf/rtld.c
+++ b/elf/rtld.c
@@ -1079,6 +1079,36 @@ rtld_chain_load (struct link_map *main_map, char *argv0)
 		     rtld_soname, pathname, errcode);
 }
 
+/* The main executable was loaded from a descriptor and cannot be
+   chain-loaded through execve: there may be no path it can be executed
+   by, and re-executing it would run the process through the binfmt
+   handler that chose this dynamic linker in the first place, again.
+   Refuse the cases rtld_chain_load would have chained instead of
+   crashing on them later (bug 28648).  */
+static void
+rtld_execfd_check (struct link_map *main_map)
+{
+  /* The dynamic loader run against itself.  */
+  const char *rtld_soname = l_soname (&_dl_rtld_map);
+  if (l_soname (main_map) != NULL
+      && strcmp (rtld_soname, l_soname (main_map)) == 0)
+    _dl_fatal_printf ("%s: loader cannot load itself\n", rtld_soname);
+
+  /* With DT_NEEDED dependencies, the executable is dynamically
+     linked.  */
+  if (__glibc_likely (main_map->l_info[DT_NEEDED] != NULL))
+    return;
+
+  /* If the executable has a program interpreter, it is dynamically
+     linked.  */
+  for (size_t i = 0; i < main_map->l_phnum; ++i)
+    if (main_map->l_phdr[i].p_type == PT_INTERP)
+      return;
+
+  _dl_fatal_printf ("%s: cannot execute a statically linked binary"
+		    " from a descriptor\n", rtld_soname);
+}
+
 /* Called to complete the initialization of the link map for the main
    executable.  Returns true if there is a PT_INTERP segment.  */
 static bool
@@ -1382,16 +1412,70 @@ dl_main (const ElfW(Phdr) *phdr,
 	 like that.  We just load it and use its entry point; we don't
 	 pay attention to its PT_INTERP command (we are the interpreter
 	 ourselves).  This is an easy way to test a new ld.so before
-	 installing it.  */
+	 installing it.
+
+	 This also happens when the kernel executes us on behalf of a
+	 binfmt interpreter dispatch (binfmt_misc): the handler splices
+	 our path and the program's path into the argument vector, so
+	 the arguments are processed just the same.  If the handler
+	 also kept the program open ('O' and 'C' entries), AT_EXECFD
+	 carries the descriptor and the program is loaded from it
+	 instead of re-opening the path (see below).  */
       rtld_is_main = true;
 
       char *argv0 = NULL;
       char **orig_argv = _dl_argv;
+      int execfd = -1;
+      bool execfd_consumed = false;
+      /* True if the kernel dispatched us as a binfmt interpreter
+	 (AT_EXECFD is present).  */
+      bool from_execfd = false;
 
       /* Note the place where the dynamic linker actually came from.  */
       _dl_rtld_map.l_name = rtld_progname;
 
-      while (_dl_argc > 1)
+#ifdef HAVE_AUX_VECTOR
+      /* A binfmt interpreter dispatch that keeps the executed binary
+	 open passes the descriptor in AT_EXECFD.  Load the program
+	 from it: the path spliced into the argument vector may not be
+	 openable again (execute-only binaries), and the descriptor
+	 refers to the very file the kernel access-checked, so no
+	 re-open races against it.  The raw vector must be scanned
+	 because a valid descriptor 0 and an absent entry cannot be
+	 told apart in the parsed values.  */
+      for (ElfW(auxv_t) *av = auxv; av->a_type != AT_NULL; av++)
+	if (av->a_type == AT_EXECFD)
+	  {
+	    execfd = av->a_un.a_val;
+	    from_execfd = true;
+	    break;
+	  }
+
+      /* Move the descriptor out of the standard range: it is closed
+	 once the program is mapped, and a secure process must not
+	 start with a silently closed standard descriptor.  The
+	 standard descriptor check at startup ran while the descriptor
+	 still occupied the slot, so run it again once the slot is
+	 free.  */
+      if (execfd >= 0 && execfd <= STDERR_FILENO)
+	{
+	  int movedfd = __fcntl64_nocancel (execfd, F_DUPFD,
+					    STDERR_FILENO + 1);
+	  if (movedfd >= 0)
+	    {
+	      __close_nocancel (execfd);
+	      execfd = movedfd;
+	      if (__glibc_unlikely (__libc_enable_secure))
+		__libc_check_standard_fds ();
+	    }
+	}
+#endif
+
+      /* When the kernel dispatches us as a binfmt interpreter, argv[1]
+	 is the spliced program path, not a loader option.  It is
+	 attacker-controlled and may begin with "--" (e.g. a program
+	 named "--preload"), so do not parse it as an option.  */
+      while (!from_execfd && _dl_argc > 1)
 	if (! strcmp (_dl_argv[1], "--list"))
 	  {
 	    if (state.mode != rtld_mode_help)
@@ -1581,8 +1665,14 @@ dl_main (const ElfW(Phdr) *phdr,
 #ifdef HAVE_THP
 	  _dl_get_thp_config ();
 #endif
-	  _dl_map_object (NULL, rtld_progname, lt_executable, 0,
-			  __RTLD_OPENEXEC, LM_ID_BASE);
+	  if (execfd != -1)
+	    {
+	      _dl_map_object_execfd (execfd, rtld_progname);
+	      execfd_consumed = true;
+	    }
+	  else
+	    _dl_map_object (NULL, rtld_progname, lt_executable, 0,
+			    __RTLD_OPENEXEC, LM_ID_BASE);
 	  rtld_timer_stop (&load_time, start);
 	}
 
@@ -1590,7 +1680,12 @@ dl_main (const ElfW(Phdr) *phdr,
       main_map = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
 
       if (__glibc_likely (state.mode == rtld_mode_normal))
-	rtld_chain_load (main_map, argv0);
+	{
+	  if (execfd_consumed)
+	    rtld_execfd_check (main_map);
+	  else
+	    rtld_chain_load (main_map, argv0);
+	}
 
       phdr = main_map->l_phdr;
       phnum = main_map->l_phnum;
@@ -1622,6 +1717,16 @@ dl_main (const ElfW(Phdr) *phdr,
 	  case AT_EXECFN:
 	    av->a_un.a_val = (uintptr_t) _dl_argv[0];
 	    break;
+	  case AT_EXECFD:
+	    /* The descriptor was consumed and closed while loading the
+	       main program; do not leave a dangling number behind.  */
+	    if (execfd_consumed)
+	      av->a_type = AT_IGNORE;
+	    else
+	      /* Not consumed (e.g. --verify), but possibly moved out
+		 of the standard descriptor range above.  */
+	      av->a_un.a_val = execfd;
+	    break;
 	  }
 #endif
 
@@ -1650,8 +1755,14 @@ dl_main (const ElfW(Phdr) *phdr,
 
       /* At this point we are in a bit of trouble.  We would have to
 	 fill in the values for l_dev and l_ino.  But in general we
-	 do not know where the file is.  We also do not handle AT_EXECFD
-	 even if it would be passed up.
+	 do not know where the file is.  AT_EXECFD is deliberately not
+	 consumed here: it carries the file the kernel did *not* load
+	 and is addressed to the main program the kernel *did* load -
+	 the registered binfmt interpreter (e.g. a dynamically linked
+	 qemu-user), whose PT_INTERP we merely are.  Only when the
+	 registered interpreter is ld.so itself is ld.so that main
+	 program, and then the descriptor is consumed in the
+	 rtld-as-command branch above.
 
 	 We leave the values here defined to 0.  This is normally no
 	 problem as the program code itself is normally no shared
diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
index f94247ad9f..b97282aaac 100644
--- a/sysdeps/generic/ldsodefs.h
+++ b/sysdeps/generic/ldsodefs.h
@@ -933,6 +933,13 @@ struct link_map *_dl_map_new_object (struct link_map *loader,
 				     int type, int trace_mode, int mode,
 					     Lmid_t nsid) attribute_hidden;
 
+/* Map in the main executable from the already-open descriptor FD
+   (AT_EXECFD or an explicit loader invocation).  NAME is the name the
+   program is known by; the canonical name used for $ORIGIN is derived
+   from FD.  FD is consumed.  */
+extern struct link_map *_dl_map_object_execfd (int fd, const char *name)
+     attribute_hidden;
+
 
 /* Call _dl_map_object on the dependencies of MAP, and set up
    MAP->l_searchlist.  PRELOADS points to a vector of NPRELOADS previously

-- 
2.53.0



More information about the Libc-alpha mailing list