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

Adhemerval Zanella Netto <[email protected]>
Newsgroups gmane.comp.lib.glibc.alpha
Organization Linaro
Message-ID <[email protected]>

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) <[email protected]>
> ---
>  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
>
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.