[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