[PATCH v3 1/4] elf: load the main program from AT_EXECFD when run as a binfmt interpreter
Christian Brauner
brauner@kernel.org
Fri Jul 17 11:11:58 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. If the descriptor arrived on a standard
descriptor slot (the kernel installs it on the lowest free one),
consuming it closes that slot, so for secure processes the startup
standard-descriptor check is re-run once the program is mapped, because
it originally ran while the descriptor still occupied the slot. The
descriptor is deliberately not moved out of the standard range before
mapping: it is closed either way, so moving it would only change which
descriptor number ends up closed.
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, 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). The static-versus-dynamic classification
is rtld_chain_load's, factored into a shared helper.
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 | 65 +++++++++++++++-
elf/rtld.c | 125 ++++++++++++++++++++++++++----
sysdeps/generic/dl-standard-fds.h | 34 ++++++++
sysdeps/generic/ldsodefs.h | 7 ++
sysdeps/mips/dl-machine-reject-phdr.h | 4 +-
sysdeps/unix/sysv/linux/dl-standard-fds.h | 33 ++++++++
7 files changed, 259 insertions(+), 18 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..f2f14dd6f3 100644
--- a/elf/dl-load.c
+++ b/elf/dl-load.c
@@ -1625,11 +1625,14 @@ open_verify (const char *name, int fd,
__set_errno (0);
fbp->len = 0;
assert (sizeof (fbp->buf) > sizeof (ElfW(Ehdr)));
- /* Read in the header. */
+ /* Read the header with pread: an executable descriptor handed to
+ the loader may be shared, so its file position must be neither
+ relied upon nor disturbed. */
do
{
- ssize_t retlen = __read_nocancel (fd, fbp->buf + fbp->len,
- sizeof (fbp->buf) - fbp->len);
+ ssize_t retlen = __pread64_nocancel (fd, fbp->buf + fbp->len,
+ sizeof (fbp->buf) - fbp->len,
+ fbp->len);
if (retlen <= 0)
break;
fbp->len += retlen;
@@ -2241,6 +2244,62 @@ _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;
+
+ /* open_verify reads with pread and ignores the file position, so only
+ a descriptor that is not open at all needs rejecting (an explicit
+ loader invocation can be handed one). */
+ if (__fcntl64_nocancel (fd, F_GETFD) == -1)
+ _dl_signal_error (errno, name, NULL,
+ N_("cannot load main program from descriptor"));
+
+ 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 if (errno == ENOENT)
+ /* The descriptor is open (checked above), so ENOENT here is
+ open_verify's report of an incompatible ELF machine, not a
+ missing file. */
+ _dl_signal_error (0, name, NULL,
+ N_("cannot load main program from descriptor:"
+ " incompatible ELF machine"));
+ 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..3d383ae3b9 100644
--- a/elf/rtld.c
+++ b/elf/rtld.c
@@ -54,6 +54,7 @@
#include <dl-audit-check.h>
#include <dl-call_tls_init_tp.h>
#include <dl-exec-post.h>
+#include <dl-standard-fds.h>
#include <assert.h>
@@ -1044,10 +1045,11 @@ load_audit_modules (struct link_map *main_map, struct audit_list *audit_list)
}
}
-/* Check if the executable is not actually dynamically linked, and
- invoke it directly in that case. */
-static void
-rtld_chain_load (struct link_map *main_map, char *argv0)
+/* Diagnose the dynamic loader run against itself (fatal), and return
+ whether the main executable is dynamically linked: it has DT_NEEDED
+ dependencies or a program interpreter. */
+static bool
+rtld_main_map_is_dynamic (struct link_map *main_map)
{
/* The dynamic loader run against itself. */
const char *rtld_soname = l_soname (&_dl_rtld_map);
@@ -1058,14 +1060,26 @@ rtld_chain_load (struct link_map *main_map, char *argv0)
/* With DT_NEEDED dependencies, the executable is dynamically
linked. */
if (__glibc_unlikely (main_map->l_info[DT_NEEDED] != NULL))
- return;
+ return true;
/* If the executable has 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;
+ return true;
+
+ return false;
+}
+/* Check if the executable is not actually dynamically linked, and
+ invoke it directly in that case. */
+static void
+rtld_chain_load (struct link_map *main_map, char *argv0)
+{
+ if (rtld_main_map_is_dynamic (main_map))
+ return;
+
+ const char *rtld_soname = l_soname (&_dl_rtld_map);
const char *pathname = _dl_argv[0];
if (argv0 != NULL)
_dl_argv[0] = argv0;
@@ -1079,6 +1093,20 @@ 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)
+{
+ if (!rtld_main_map_is_dynamic (main_map))
+ _dl_fatal_printf ("%s: cannot execute a statically linked binary"
+ " from a descriptor\n", l_soname (&_dl_rtld_map));
+}
+
/* 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 +1410,50 @@ 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;
+ /* 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;
+ }
+#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)
@@ -1507,6 +1569,17 @@ dl_main (const ElfW(Phdr) *phdr,
else
break;
+ /* A descriptor on a standard slot is consumed and closed while
+ the program is mapped below, and a secure process must not run
+ with a silently closed standard descriptor: the startup check
+ ran while the descriptor still occupied the slot. Re-run it
+ once the program is mapped and the slot is free. Moving the
+ descriptor out of the standard range instead would only change
+ which descriptor number ends up closed. */
+ bool recheck_standard_fds
+ = (execfd >= 0 && execfd <= STDERR_FILENO
+ && __glibc_unlikely (__libc_enable_secure));
+
if (__glibc_unlikely (state.mode == rtld_mode_list_tunables))
{
__tunables_print ();
@@ -1581,16 +1654,29 @@ 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);
+ else
+ _dl_map_object (NULL, rtld_progname, lt_executable, 0,
+ __RTLD_OPENEXEC, LM_ID_BASE);
rtld_timer_stop (&load_time, start);
}
+ /* Fill the standard slot freed by closing the program
+ descriptor (see recheck_standard_fds above). */
+ if (__glibc_unlikely (recheck_standard_fds))
+ _dl_recheck_standard_fds ();
+
/* Now the map for the main executable is available. */
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 != -1)
+ rtld_execfd_check (main_map);
+ else
+ rtld_chain_load (main_map, argv0);
+ }
phdr = main_map->l_phdr;
phnum = main_map->l_phnum;
@@ -1622,6 +1708,13 @@ dl_main (const ElfW(Phdr) *phdr,
case AT_EXECFN:
av->a_un.a_val = (uintptr_t) _dl_argv[0];
break;
+ case AT_EXECFD:
+ /* An AT_EXECFD entry is present only when the kernel
+ dispatched us, and the program is then always loaded and
+ closed from the descriptor, so do not leave a dangling
+ number behind. */
+ av->a_type = AT_IGNORE;
+ break;
}
#endif
@@ -1650,8 +1743,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/dl-standard-fds.h b/sysdeps/generic/dl-standard-fds.h
new file mode 100644
index 0000000000..d6c3589ffe
--- /dev/null
+++ b/sysdeps/generic/dl-standard-fds.h
@@ -0,0 +1,34 @@
+/* Re-check the standard file descriptors in the dynamic linker. Generic.
+ 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/>. */
+
+#ifndef _DL_STANDARD_FDS_H
+#define _DL_STANDARD_FDS_H
+
+/* Re-run the startup standard-descriptor check after the dynamic
+ linker closed a descriptor in the standard range: a secure process
+ must not start with a silently closed standard descriptor. The
+ generic version is a no-op: ports without the check in the dynamic
+ linker heal the standard descriptors during libc initialization
+ instead (the Hurd does from its _hurd_fd_subinit hook, and does not
+ define __libc_check_standard_fds for shared builds). */
+static inline void
+_dl_recheck_standard_fds (void)
+{
+}
+
+#endif
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
diff --git a/sysdeps/mips/dl-machine-reject-phdr.h b/sysdeps/mips/dl-machine-reject-phdr.h
index e64494c198..88d0a3cd6a 100644
--- a/sysdeps/mips/dl-machine-reject-phdr.h
+++ b/sysdeps/mips/dl-machine-reject-phdr.h
@@ -209,8 +209,8 @@ elf_machine_reject_phdr_p (const struct dl_machine_phdr_info *info,
if (ph->p_filesz < size)
REJECT (" contains malformed PT_MIPS_ABIFLAGS\n");
- __lseek (fd, ph->p_offset, SEEK_SET);
- if (__libc_read (fd, (void *) &mips_abiflags, size) != size)
+ if (__pread64_nocancel (fd, (void *) &mips_abiflags, size,
+ ph->p_offset) != size)
REJECT (" unable to read PT_MIPS_ABIFLAGS\n");
if (__glibc_unlikely (mips_abiflags.flags2 != 0))
diff --git a/sysdeps/unix/sysv/linux/dl-standard-fds.h b/sysdeps/unix/sysv/linux/dl-standard-fds.h
new file mode 100644
index 0000000000..207af0ab0b
--- /dev/null
+++ b/sysdeps/unix/sysv/linux/dl-standard-fds.h
@@ -0,0 +1,33 @@
+/* Re-check the standard file descriptors in the dynamic linker. Linux.
+ 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/>. */
+
+#ifndef _DL_STANDARD_FDS_H
+#define _DL_STANDARD_FDS_H
+
+#include <unistd.h>
+
+/* Linux runs the startup standard-descriptor check inside the dynamic
+ linker (dl-sysdep.c), so the re-check after closing a program
+ descriptor runs there as well. */
+static inline void
+_dl_recheck_standard_fds (void)
+{
+ __libc_check_standard_fds ();
+}
+
+#endif
--
2.53.0
More information about the Libc-alpha
mailing list