[PATCH v2] elf: Release dl_load_lock before running dlopen constructors (BZ 15686)

temap@mail.ru temap@mail.ru
Mon Jul 13 22:53:03 GMT 2026


From: Artem Proskurnev <temap@mail.ru>

This addresses one instance of the long-standing class of deadlocks
described in BZ #15686: ELF constructors and destructors invoked by
the dynamic loader run with dl_load_lock held, so any code path in
those constructors that itself needs dl_load_lock deadlocks.

dl_open_worker holds dl_load_lock across the entire _dl_open call,
including the call to call_dl_init that runs the new objects'
constructors.  If one of those constructors spawns a thread whose
first access to a thread_local object triggers
__cxa_thread_atexit_impl, the new thread blocks trying to acquire
dl_load_lock -- which is held by the dlopen thread -- deadlocking
the process.  The same deadlock arises when the spawned thread calls
a function that triggers NSS module loading through _dl_open, or any
other code path that needs dl_load_lock.

The blocking site is __cxa_thread_atexit_impl at
stdlib/cxa_thread_atexit_impl.c.  BZ #28357 was a partial fix for
the wider BZ #15686 problem: it moved dl_open_worker_begin and
_dl_close_worker to the finer-grained dl_load_tls_lock (commit
024a7640ab) and used that new lock in pthread_create and
__tls_get_addr.  __cxa_thread_atexit_impl, however, still takes
dl_load_lock to protect its DSO lookup (_dl_find_dso_for_object)
against a racing dlclose, and that path is not covered by the
BZ #28357 fix.  Moving it to dl_load_tls_lock is not straightforward
because _dl_find_dso_for_object walks _ns_loaded, which is protected
by dl_load_lock rather than dl_load_tls_lock.

This patch takes the alternative approach of releasing dl_load_lock
during constructor execution.  At the point where call_dl_init runs,
the following invariants hold:

  * All link_map structures for the newly loaded DSO and its
    dependencies are fully initialized and immutable.
  * The DSO has l_direct_opencount == 1 (incremented in
    dl_open_worker_begin), so a concurrent dlclose cannot unload it:
    _dl_close_worker short-circuits when the count is non-zero.
  * Implicit dependencies are protected by the l_map_used marking in
    _dl_close_worker, which transitively marks the l_initfini chain
    of any map with non-zero l_direct_opencount.
  * dl_iterate_phdr uses dl_load_write_lock rather than dl_load_lock
    and is unaffected by the unlock.  Other threads calling
    dl_iterate_phdr during the constructor may observe the DSO before
    its constructor has run; this is consistent with POSIX, which
    does not guarantee atomic appearance of dlopen'd objects, and is
    equivalent to dlsym from inside a constructor observing
    partially-initialized main-executable symbols.
  * Recursive dlopen from a constructor re-acquires dl_load_lock
    normally in _dl_open and proceeds serially.

The lock is re-acquired immediately after constructors complete,
before add_to_global_update and the lock/unlock pairing expected by
_dl_open.

Exception safety: call_dl_init is invoked via
_dl_catch_exception (NULL, ...) so that lazy binding failures are
fatal (the process exits through _dl_fatal_printf); therefore the
re-lock is not required on the error path.  C++ exceptions thrown
from constructors are a separate, pre-existing concern: dl exception
handling uses setjmp/longjmp rather than C++ unwinding, so a thrown
exception may leave locks in any state regardless of this patch.
Releasing the lock is strictly safer than holding it in that case.

Minimal reproducer: a DSO whose constructor calls
gdk_pixbuf_new_from_file on a system where the glycin image loader
is wired in via gdk-pixbuf reaches a sandboxed loader process spawn,
which in turn calls std::thread::spawn; the spawned thread's first
thread_local access (__cxa_thread_atexit_impl) blocks on dl_load_lock
held by the dlopen caller.  The same hang reproduces with any
constructor that spawns a thread touching thread_local state or
triggering NSS module loads.

A regression test is added in sysdeps/pthread/tst-create2.c with its
DSO in tst-create2mod.c.  The DSO constructor spawns a worker thread
that calls __cxa_thread_atexit_impl and then joins it; under the
pre-fix locking model the join deadlocks and the test framework
times out.  The test follows the layout of tst-create1 (BZ #28357),
which covers the pthread_create leg of the same bug class.

Releasing the lock introduces a data race on l_init_called in
call_init (elf/dl-init.c): two threads
performing concurrent dlopen of the same DSO could both pass the
l_init_called check and run the constructor in parallel.  This is
addressed by adding per-DSO init serialisation using __pthread_once.
A new l_init_once field in struct link_map (zero from calloc,
matching PTHREAD_ONCE_INIT) ensures exactly one thread runs the
constructor; concurrent callers block until it completes.  Different
DSOs can initialise concurrently.

The __pthread_once path is only used in the libc.so build of
dl-init.c; the rtld (ld.so) startup path is single-threaded and
preserves the existing inline call_init logic.  Even within the
libc.so build, single-threaded processes (static startup, or an
early dlopen before any threads exist) take the inline path via a
RTLD_SINGLE_THREAD_P check, avoiding __pthread_once before the
pthread infrastructure is initialised.

Tested on x86_64-linux-gnu. Verified both directions of the regression
test: sysdeps/pthread/tst-create2 deadlocks (times out after 10 s)
when run against the unpatched tree, and passes (exit 0) with this
patch applied. No regressions in the full glibc test suite (make check).

Co-authored-by: Alexander Pevzner <pzz@apevzner.com>
Signed-off-by: Artem Proskurnev <temap@mail.ru>
Signed-off-by: Alexander Pevzner <pzz@apevzner.com>
---
 elf/dl-init.c                    | 92 +++++++++++++++++++++++++-------
 elf/dl-open.c                    | 27 ++++++++++
 include/link.h                   |  6 +++
 sysdeps/pthread/Makefile         |  7 +++
 sysdeps/pthread/tst-create2.c    | 48 +++++++++++++++++
 sysdeps/pthread/tst-create2mod.c | 62 +++++++++++++++++++++
 6 files changed, 224 insertions(+), 18 deletions(-)
 create mode 100644 sysdeps/pthread/tst-create2.c
 create mode 100644 sysdeps/pthread/tst-create2mod.c

diff --git a/elf/dl-init.c b/elf/dl-init.c
index bd85bacdc1..250f7cc175 100644
--- a/elf/dl-init.c
+++ b/elf/dl-init.c
@@ -21,27 +21,24 @@
 #include <ldsodefs.h>
 #include <elf-initfini.h>
 
+#if !IS_IN (rtld)
+# include <stdint.h>
+/* pthread_once_t is int; we use the l_init_once field in struct
+   link_map (also int, zero from calloc matches PTHREAD_ONCE_INIT).  */
+extern int __pthread_once (int *once_control, void (*init_routine) (void));
+
+/* Argument for call_init_once_cb.  Set by call_init before calling
+   __pthread_once, which invokes the callback synchronously (either
+   immediately, or after blocking).  */
+static __thread struct link_map *call_init_once_arg;
+
+static void call_init_once_cb (void);
+#endif
+
 
 static void
-call_init (struct link_map *l, int argc, char **argv, char **env)
+call_init_body (struct link_map *l, int argc, char **argv, char **env)
 {
-  /* Do not run constructors for proxy objects.  */
-  if (l != l->l_real)
-    return;
-
-  /* If the object has not been relocated, this is a bug.  The
-     function pointers are invalid in this case.  (Executables do not
-     need relocation.)  */
-  assert (l->l_relocated || l->l_type == lt_executable);
-
-  if (l->l_init_called)
-    /* This object is all done.  */
-    return;
-
-  /* Avoid handling this constructor again in case we have a circular
-     dependency.  */
-  l->l_init_called = 1;
-
   /* Check for object which constructors we do not run here.  */
   if (__builtin_expect (l->l_name[0], 'a') == '\0'
       && l->l_type == lt_executable)
@@ -76,6 +73,65 @@ call_init (struct link_map *l, int argc, char **argv, char **env)
 }
 
 
+#if !IS_IN (rtld)
+/* __pthread_once callback.  call_init_once_arg has been set by
+   call_init to the link_map whose constructors need to run.  */
+static void
+call_init_once_cb (void)
+{
+  struct link_map *l = call_init_once_arg;
+  /* This callback is only reached from the libc.so (runtime dlopen)
+     path.  The ld.so startup path uses the IS_IN (rtld) branch in
+     call_init, which passes the real argc/argv/env.  For dlopen,
+     _dl_init is called with (0, (char **) -1, NULL).  */
+  call_init_body (l, 0, (char **) -1, NULL);
+}
+#endif
+
+
+static void
+call_init (struct link_map *l, int argc, char **argv, char **env)
+{
+  /* Do not run constructors for proxy objects.  */
+  if (l != l->l_real)
+    return;
+
+  /* If the object has not been relocated, this is a bug.  The
+     function pointers are invalid in this case.  (Executables do not
+     need relocation.)  */
+  assert (l->l_relocated || l->l_type == lt_executable);
+
+  if (l->l_init_called)
+    /* This object is all done.  */
+    return;
+
+  /* Avoid handling this constructor again in case we have a circular
+     dependency.  */
+  l->l_init_called = 1;
+
+#if IS_IN (rtld)
+  /* rtld (ld.so) startup path: only one thread exists, no
+     concurrency possible.  Run the constructor inline.  */
+  call_init_body (l, argc, argv, env);
+#else
+  /* libc.so path.  When single-threaded (static startup, or dlopen
+     before any threads exist), run the constructor inline to avoid
+     pulling in __pthread_once before pthread is initialised.  When
+     multi-threaded, use __pthread_once for per-DSO serialisation:
+     the first caller runs the constructor; any concurrent caller
+     blocks until it completes.  Different DSOs can initialise
+     concurrently.  */
+  if (RTLD_SINGLE_THREAD_P)
+    call_init_body (l, argc, argv, env);
+  else
+    {
+      call_init_once_arg = l;
+      __pthread_once (&l->l_init_once, call_init_once_cb);
+    }
+#endif
+}
+
+
 void
 _dl_init (struct link_map *main_map, int argc, char **argv, char **env)
 {
diff --git a/elf/dl-open.c b/elf/dl-open.c
index cf4749694f..bcaf4e1ff8 100644
--- a/elf/dl-open.c
+++ b/elf/dl-open.c
@@ -792,11 +792,38 @@ dl_open_worker (void *a)
   int mode = args->mode;
   struct link_map *new = args->map;
 
+  /* The link_map structures are fully initialized at this point.  We
+     can release dl_load_lock so that constructors can safely spawn
+     threads without deadlocking on dl_load_lock (e.g. if the new thread
+     accesses a thread_local variable and calls __cxa_thread_atexit_impl,
+     or performs operations that trigger NSS module loading).
+
+     The DSO has l_direct_opencount == 1 at this point, so it cannot be
+     unloaded by a concurrent dlclose -- _dl_close_worker skips objects
+     with nonzero opencount, and l_map_used marking transitively protects
+     implicit dependencies.
+
+     Concurrent dlopen of the same DSO is safe: call_init in dl-init.c
+     uses __pthread_once for per-DSO serialisation -- exactly one thread
+     runs the constructor, the other blocks until it completes.  Different
+     DSOs can initialise concurrently.
+
+     Notably, dl_iterate_phdr (which uses dl_load_write_lock, not
+     dl_load_lock) can observe the DSO before its constructor has run.
+     This is consistent with POSIX, which does not guarantee that a
+     dlopen'd DSO appears atomically.  */
+
+  __rtld_lock_unlock_recursive (GL(dl_load_lock));
+
   /* Run the initializer functions of new objects.  Temporarily
      disable the exception handler, so that lazy binding failures are
      fatal.  */
   _dl_catch_exception (NULL, call_dl_init, args);
 
+  /* Re-acquire dl_load_lock for the final global scope update and for
+     the lock/unlock pairing expected by _dl_open.  */
+  __rtld_lock_lock_recursive (GL(dl_load_lock));
+
   /* Now we can make the new map available in the global scope.  */
   if (mode & RTLD_GLOBAL)
     add_to_global_update (new);
diff --git a/include/link.h b/include/link.h
index 8f851d2212..e539535a44 100644
--- a/include/link.h
+++ b/include/link.h
@@ -346,6 +346,12 @@ struct link_map
     size_t l_relro_size;
 
     unsigned long long int l_serial;
+
+    /* Per-DSO once-initialization control for constructor execution.
+       Used as pthread_once_t (PTHREAD_ONCE_INIT == 0, matching
+       calloc).  Only accessed from the libc.so build of dl-init.c
+       (IS_IN (rtld) path is single-threaded).  */
+    int l_init_once;
   };
 
 #include <dl-relocate-ld.h>
diff --git a/sysdeps/pthread/Makefile b/sysdeps/pthread/Makefile
index d0f3cd59ac..9451a5c25c 100644
--- a/sysdeps/pthread/Makefile
+++ b/sysdeps/pthread/Makefile
@@ -349,6 +349,7 @@ tests += \
   tst-atfork3 \
   tst-atfork4 \
   tst-create1 \
+  tst-create2 \
   tst-fini1 \
   tst-pt-tls4 \
   # tests
@@ -365,6 +366,7 @@ modules-names += \
   tst-atfork3mod \
   tst-atfork4mod \
   tst-create1mod \
+  tst-create2mod \
   tst-fini1mod \
   tst-stack2-mod \
   tst-tls4moda \
@@ -377,6 +379,7 @@ tst-atfork2mod.so-no-z-defs = yes
 tst-atfork3mod.so-no-z-defs = yes
 tst-atfork4mod.so-no-z-defs = yes
 tst-create1mod.so-no-z-defs = yes
+tst-create2mod.so-no-z-defs = yes
 
 ifeq ($(build-shared),yes)
 # Build all the modules even when not actually running test programs.
@@ -540,6 +543,10 @@ LDFLAGS-tst-create1 = -Wl,-export-dynamic
 $(objpfx)tst-create1: $(shared-thread-library)
 $(objpfx)tst-create1.out: $(objpfx)tst-create1mod.so
 
+LDFLAGS-tst-create2 = -Wl,-export-dynamic
+$(objpfx)tst-create2: $(shared-thread-library)
+$(objpfx)tst-create2.out: $(objpfx)tst-create2mod.so
+
 $(objpfx)tst-stack2.out: $(objpfx)tst-stack2-mod.so
 $(objpfx)tst-stack2-mod.so: $(shared-thread-library)
 LDFLAGS-tst-stack2-mod.so = -Wl,-z,execstack
diff --git a/sysdeps/pthread/tst-create2.c b/sysdeps/pthread/tst-create2.c
new file mode 100644
index 0000000000..d7df8aafc5
--- /dev/null
+++ b/sysdeps/pthread/tst-create2.c
@@ -0,0 +1,48 @@
+/* Verify that a thread spawned by a dlopen constructor can register a
+   TLS destructor via __cxa_thread_atexit_impl without deadlocking on
+   dl_load_lock held by the dlopen caller (BZ 15686).
+   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; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+/* Reproducer for one instance of the deadlock class described in
+   BZ 15686.
+
+   thread 1: dlopen -> ctor -> pthread_create(worker) -> pthread_join(worker)
+   thread 2 (worker): __cxa_thread_atexit_impl -> tries to lock dl_load_lock
+
+   Before the fix in elf/dl-open.c, dl_load_lock is held across
+   call_dl_init, so thread 2 blocks on a lock that thread 1 will only
+   release after pthread_join returns -- a deadlock that the
+   test-driver timeout surfaces as a failure.  After the fix,
+   dl_load_lock is released before constructors run and reacquired
+   afterwards, so thread 2 makes progress and the dlopen call returns.  */
+
+#include <stdio.h>
+#include <support/xdlfcn.h>
+
+static int
+do_test (void)
+{
+  dprintf (1, "main: dlopen tst-create2mod.so\n");
+  void *h = xdlopen ("tst-create2mod.so", RTLD_NOW);
+  dprintf (1, "main: dlopen done\n");
+  xdlclose (h);
+  dprintf (1, "main: dlclose done\n");
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/sysdeps/pthread/tst-create2mod.c b/sysdeps/pthread/tst-create2mod.c
new file mode 100644
index 0000000000..d8254f729f
--- /dev/null
+++ b/sysdeps/pthread/tst-create2mod.c
@@ -0,0 +1,62 @@
+/* Verify that a thread spawned by a dlopen constructor can register a
+   TLS destructor via __cxa_thread_atexit_impl without deadlocking on
+   dl_load_lock held by the dlopen caller (BZ 15686).
+   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; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+#include <pthread.h>
+#include <stdlib.h>
+#include <dso_handle.h>
+
+typedef struct { void *val; } A;
+
+static void
+A_dtor (void *obj)
+{
+  ((A *) obj)->val = obj;
+}
+
+/* Acquire dl_load_lock via __cxa_thread_atexit_impl.  Called from the
+   worker thread spawned by the constructor below; if the constructor
+   runs with dl_load_lock held, this blocks and pthread_join in the
+   constructor never returns.  */
+static void
+reg_dtor (void)
+{
+  static __thread A b;
+  __cxa_thread_atexit_impl (A_dtor, &b, __dso_handle);
+}
+
+static void *
+worker (void *arg)
+{
+  reg_dtor ();
+  return NULL;
+}
+
+static void __attribute__ ((constructor))
+do_init (void)
+{
+  pthread_t t;
+  if (pthread_create (&t, NULL, worker, NULL) != 0)
+    abort ();
+  /* Blocks until worker has completed its __cxa_thread_atexit_impl
+     call; under the pre-fix locking model that call deadlocks on
+     dl_load_lock held by the dlopen caller running this ctor.  */
+  if (pthread_join (t, NULL) != 0)
+    abort ();
+}
-- 
2.51.0


More information about the Libc-alpha mailing list