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

Adhemerval Zanella Netto adhemerval.zanella@linaro.org
Thu Jul 16 14:53:13 GMT 2026



On 15/07/26 07:13, Christian Brauner wrote:
> 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);

Do we test for the case where the file descriptor is not at the start of
the file? The elf/tst-rtld-program-fd.c always 'xlseek (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;

This is shared with rtld_chain_load, wouldn't be better to consolidate both
on only one function?
> +
> +  _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;

I think execfd_consumed is exactly equivalent to execfd != -1, so I am not
sure if it helps adding another tracking variable.

> +      /* 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;

Since execfd_consumed is 'execfd != -1', and execfd is only != -1 for the
case the kernel provides an auxv entry, I think this else branch here will
never be taken.

Also, I think the whole idea is if AT_EXECFD is provided, it will be always
be consumed and closed while mapping the main program.

> +	    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
> 



More information about the Libc-alpha mailing list