[v4] dlfcn: Preserve destructor order when dlclose is called from atexit handler (BZ 33598)

Message ID 20260506202903.691892-1-adhemerval.zanella@linaro.org (mailing list archive)
State New
Headers
Series [v4] dlfcn: Preserve destructor order when dlclose is called from atexit handler (BZ 33598) |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
redhat-pt-bot/TryBot-32bit success Build for i686
linaro-tcwg-bot/tcwg_glibc_build--master-arm success Build passed
linaro-tcwg-bot/tcwg_glibc_build--master-aarch64 success Build passed
linaro-tcwg-bot/tcwg_glibc_check--master-aarch64 success Test passed
linaro-tcwg-bot/tcwg_glibc_check--master-arm success Test passed
redhat-pt-bot/TryBot-still_applies warning Patch no longer applies to master

Commit Message

Adhemerval Zanella Netto May 6, 2026, 8:28 p.m. UTC
  When a shared library loaded via dlopen has both a thread_local variable
and a C++ static global destructor that calls dlclose on another library,
the process crashes with a SIGSEGV during exit.

The cause it due __run_exit_handlers calling __call_tls_dtors first,
decrementing l_tls_dtor_count to zero for all libraries.   atexit
callbacks then fire in reverse registration order: library destructors
registered via __cxa_atexit at dlopen time run before _dl_fini, which was
registered at startup by __libc_start_main.

Using the example provided by BZ 33598, when lib1's destructor (~c1) calls
dlclose(lib2), _dl_close_worker scans the namespace and finds both lib1
and lib2 with l_direct_opencount==0 and l_tls_dtor_count==0.  Both are
marked for unloading and unmapped and the code then returns from dlclose
into the now-unmapped lib1 (note that _dl_fini would have prevented this
by bumping l_direct_opencount before calling finalizers, but it runs too
late in the atexit order).

A new rtld_global field, _dl_exit_cxa_dso_handle (void *), is set in
__run_exit_handlers to f->func.cxa.dso_handle before each ef_cxa callback
fires and cleared to NULL after it returns.

In _dl_close_worker, the marking loop reads this value once as cxa_dso
(under dl_load_lock).  For each candidate map with l_direct_opencount==0
and l_tls_dtor_count==0, if cxa_dso falls within [l_map_start, l_map_end)
the map is given IDX_STILL_USED.  All other maps are subject to the normal
reference-count rules.

The __libc_freeres path (do_dlclose) needs no special treatment: it runs
outside any __cxa_atexit callback, so _dl_exit_cxa_dso_handle is NULL and
_dl_close_worker behaves normally, performing real unmaps for valgrind
leak detection.  This scheme preserves the destructor ordering for both
dclose call during destructors and .initfini calls.

Two tests are added.  Each loads lib1 (which has a thread_local and a
static destructor that calls dlclose on lib2) and verifies:

  - No crash (the original bug): dlclose from a __cxa_atexit destructor
    that was preceded by __call_tls_dtors does not segfault.

  - Destructor ordering: lib2 exports set_fini_flag(int *p); its
    destructor writes *p=1 through the stored pointer.  lib1 registers
    the address of a local flag after dlopen, then asserts the flag is
    set immediately after dlclose returns.  This confirms that lib2's
    destructor ran during the dlclose call itself, not deferred to
    _dl_fini.

tst-thrlocal-dlclose2 extends the scenario to lib2 itself opening a
third library (libm) during init and closing it during cleanup, covering
the case of nested dynamic dependencies being released from an atexit
handler.

Checked on aarch64-linux-gnu, x86_64-linux-gnu, and i686-linux-gnu.
---
 dlfcn/Makefile                      | 25 ++++++++++++
 dlfcn/tst-thrlocal-dlclose1-lib1.cc | 50 +++++++++++++++++++++++
 dlfcn/tst-thrlocal-dlclose1-lib2.cc | 36 +++++++++++++++++
 dlfcn/tst-thrlocal-dlclose1.c       | 29 ++++++++++++++
 dlfcn/tst-thrlocal-dlclose2-lib1.cc | 62 +++++++++++++++++++++++++++++
 dlfcn/tst-thrlocal-dlclose2-lib2.c  | 47 ++++++++++++++++++++++
 dlfcn/tst-thrlocal-dlclose2.c       | 29 ++++++++++++++
 elf/dl-close.c                      | 11 ++++-
 elf/dl-support.c                    |  2 +
 stdlib/exit.c                       |  8 ++++
 sysdeps/generic/ldsodefs.h          |  5 +++
 11 files changed, 303 insertions(+), 1 deletion(-)
 create mode 100644 dlfcn/tst-thrlocal-dlclose1-lib1.cc
 create mode 100644 dlfcn/tst-thrlocal-dlclose1-lib2.cc
 create mode 100644 dlfcn/tst-thrlocal-dlclose1.c
 create mode 100644 dlfcn/tst-thrlocal-dlclose2-lib1.cc
 create mode 100644 dlfcn/tst-thrlocal-dlclose2-lib2.c
 create mode 100644 dlfcn/tst-thrlocal-dlclose2.c
  

Patch

diff --git a/dlfcn/Makefile b/dlfcn/Makefile
index 00341dd476f..fcfdda2b9b0 100644
--- a/dlfcn/Makefile
+++ b/dlfcn/Makefile
@@ -68,9 +68,19 @@  tests = \
   tst-dladdr \
   tst-dlinfo \
   tst-rec-dlopen \
+  tst-thrlocal-dlclose1 \
+  tst-thrlocal-dlclose2 \
   tstatexit \
   tstcxaatexit \
   # tests
+
+ifneq ($(have-cxx-thread_local),yes)
+tests-unsupported += \
+  tst-thrlocal-dlclose1 \
+  tst-thrlocal-dlclose2
+  # tests-unsupported
+endif
+
 endif
 modules-names = \
   bug-atexit1-lib \
@@ -90,8 +100,13 @@  modules-names = \
   modcxaatexit \
   moddummy1 \
   moddummy2 \
+  tst-thrlocal-dlclose1-lib1 \
+  tst-thrlocal-dlclose1-lib2 \
+  tst-thrlocal-dlclose2-lib1 \
+  tst-thrlocal-dlclose2-lib2 \
   # modules-names
 
+
 failtestmod.so-no-z-defs = yes
 glreflib2.so-no-z-defs = yes
 errmsg1mod.so-no-z-defs = yes
@@ -197,3 +212,13 @@  $(objpfx)bug-dl-leaf.out: $(objpfx)bug-dl-leaf-lib-cb.so
 $(objpfx)bug-dl-leaf-lib-cb.so: $(objpfx)bug-dl-leaf-lib.so
 
 $(objpfx)tst-rec-dlopen.out: $(objpfx)moddummy1.so $(objpfx)moddummy2.so
+
+CFLAGS-tst-thrlocal-dlclose1-lib1.o += -std=gnu++11
+LDLIBS-tst-thrlocal-dlclose1-lib1.so = -lstdc++
+$(objpfx)tst-thrlocal-dlclose1.out: $(objpfx)tst-thrlocal-dlclose1-lib1.so \
+				    $(objpfx)tst-thrlocal-dlclose1-lib2.so
+
+CFLAGS-tst-thrlocal-dlclose2-lib1.o += -std=gnu++11
+LDLIBS-tst-thrlocal-dlclose2-lib1.so = -lstdc++
+$(objpfx)tst-thrlocal-dlclose2.out: $(objpfx)tst-thrlocal-dlclose2-lib1.so \
+				    $(objpfx)tst-thrlocal-dlclose2-lib2.so
diff --git a/dlfcn/tst-thrlocal-dlclose1-lib1.cc b/dlfcn/tst-thrlocal-dlclose1-lib1.cc
new file mode 100644
index 00000000000..50e7450f535
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose1-lib1.cc
@@ -0,0 +1,50 @@ 
+/* Module for tst-thrlocal-dlclose test.
+   Copyright (C) 2025 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 <assert.h>
+#include <memory>
+#include <dlfcn.h>
+
+thread_local std::unique_ptr<int> tlp;
+
+static struct c1
+{
+  void *h;
+  int lib2_fini_ran = 0;
+
+  c1 ()
+  {
+    h = dlopen ("tst-thrlocal-dlclose1-lib2.so", RTLD_NOW);
+    assert (h != NULL);
+
+    void (*set_fini_flag)(int *) =
+      (void (*)(int *)) dlsym (h, "set_fini_flag");
+    assert (set_fini_flag != NULL);
+    set_fini_flag (&lib2_fini_ran);
+
+    tlp = std::make_unique<int>();
+  }
+
+  ~c1 ()
+  {
+    assert (dlclose (h) == 0);
+    /* Verify lib2's destructor ran during dlclose, not deferred to
+       _dl_fini.  */
+    assert (lib2_fini_ran == 1);
+  }
+} c1;
diff --git a/dlfcn/tst-thrlocal-dlclose1-lib2.cc b/dlfcn/tst-thrlocal-dlclose1-lib2.cc
new file mode 100644
index 00000000000..fecd1895038
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose1-lib2.cc
@@ -0,0 +1,36 @@ 
+/* Module for tst-thrlocal-dlclose test.
+   Copyright (C) 2025 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/>.  */
+
+static int *fini_flag;
+
+extern "C" void
+set_fini_flag (int *p)
+{
+  fini_flag = p;
+}
+
+static struct lib2_dtor
+{
+  ~lib2_dtor ()
+  {
+    if (fini_flag != nullptr)
+      *fini_flag = 1;
+  }
+} lib2_dtor_obj;
+
+int foo (void) { return 42; }
diff --git a/dlfcn/tst-thrlocal-dlclose1.c b/dlfcn/tst-thrlocal-dlclose1.c
new file mode 100644
index 00000000000..602e4ac32c9
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose1.c
@@ -0,0 +1,29 @@ 
+/* Check if thread local destructor dclose does not fail (BZ 33598)
+   Copyright (C) 2025 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 <support/xdlfcn.h>
+
+int
+do_test (void)
+{
+  xdlclose (xdlopen ("tst-thrlocal-dlclose1-lib1.so", RTLD_NOW));
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/dlfcn/tst-thrlocal-dlclose2-lib1.cc b/dlfcn/tst-thrlocal-dlclose2-lib1.cc
new file mode 100644
index 00000000000..62c31873b6c
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose2-lib1.cc
@@ -0,0 +1,62 @@ 
+/* Module for tst-thrlocal-dlclose test.
+   Copyright (C) 2025 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 <assert.h>
+#include <memory>
+#include <dlfcn.h>
+
+thread_local std::unique_ptr<int> tlp;
+
+static struct c1
+{
+  void *h;
+
+  void (*init)(void);
+  void (*cleanup)(void);
+  int lib2_fini_ran = 0;
+
+  c1 ()
+  {
+    h = dlopen ("tst-thrlocal-dlclose2-lib2.so", RTLD_NOW);
+    assert (h != NULL);
+
+    init = (void (*)(void)) dlsym (h, "init");
+    assert (init);
+
+    cleanup = (void (*)(void)) dlsym (h, "cleanup");
+    assert (cleanup);
+
+    void (*set_fini_flag)(int *) =
+      (void (*)(int *)) dlsym (h, "set_fini_flag");
+    assert (set_fini_flag != NULL);
+    set_fini_flag (&lib2_fini_ran);
+
+    init ();
+
+    tlp = std::make_unique<int>();
+  }
+
+  ~c1 ()
+  {
+    cleanup ();
+    assert (dlclose (h) == 0);
+    /* Verify lib2's destructor ran during dlclose, not deferred to
+       _dl_fini.  */
+    assert (lib2_fini_ran == 1);
+  }
+} c1;
diff --git a/dlfcn/tst-thrlocal-dlclose2-lib2.c b/dlfcn/tst-thrlocal-dlclose2-lib2.c
new file mode 100644
index 00000000000..9f688935d7b
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose2-lib2.c
@@ -0,0 +1,47 @@ 
+/* Module for tst-thrlocal-dlclose test.
+   Copyright (C) 2025 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 <dlfcn.h>
+#include <assert.h>
+
+static void *h;
+static int *fini_flag;
+
+void
+set_fini_flag (int *p)
+{
+  fini_flag = p;
+}
+
+static void __attribute__ ((destructor))
+lib2_fini (void)
+{
+  if (fini_flag != NULL)
+    *fini_flag = 1;
+}
+
+void init (void)
+{
+  h = dlopen ("libm.so.6", RTLD_NOW);
+  assert(h);
+}
+
+void cleanup (void)
+{
+  dlclose (h);
+}
diff --git a/dlfcn/tst-thrlocal-dlclose2.c b/dlfcn/tst-thrlocal-dlclose2.c
new file mode 100644
index 00000000000..ad1802df42f
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose2.c
@@ -0,0 +1,29 @@ 
+/* Check if thread local destructor dclose does not fail (BZ 33598)
+   Copyright (C) 2025 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 <support/xdlfcn.h>
+
+int
+do_test (void)
+{
+  xdlclose (xdlopen ("tst-thrlocal-dlclose2-lib1.so", RTLD_NOW));
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/elf/dl-close.c b/elf/dl-close.c
index 92bce07c7a4..00c9f565ac3 100644
--- a/elf/dl-close.c
+++ b/elf/dl-close.c
@@ -144,6 +144,10 @@  _dl_close_worker (struct link_map *map, bool force)
   bool any_tls = false;
   const unsigned int nloaded = ns->_ns_nloaded;
   struct link_map *maps[nloaded];
+  /* If a __cxa_atexit callback is currently running, its library must not
+     be unloaded while still on the call stack.  Get the address of the DSO
+     handle to identify which map to protect in the loop below.  */
+  ElfW(Addr) cxa_dso = (ElfW(Addr)) GL(dl_exit_cxa_dso_handle);
 
   /* Run over the list and assign indexes to the link maps and enter
      them into the MAPS array.  */
@@ -185,7 +189,12 @@  _dl_close_worker (struct link_map *map, bool force)
 	  /* See CONCURRENCY NOTES in cxa_thread_atexit_impl.c to know why
 	     acquire is sufficient and correct.  */
 	  && atomic_load_acquire (&l->l_tls_dtor_count) == 0
-	  && !l->l_map_used)
+	  && !l->l_map_used
+	  /* Protect the library whose __cxa_atexit callback is currently
+	     executing: its pages must not be unmapped while still in use.  */
+	  && (cxa_dso == 0
+	      || cxa_dso < l->l_map_start
+	      || cxa_dso >= l->l_map_end))
 	continue;
 
       /* We need this object and we handle it now.  */
diff --git a/elf/dl-support.c b/elf/dl-support.c
index 0508d6113b5..50d3bbf5897 100644
--- a/elf/dl-support.c
+++ b/elf/dl-support.c
@@ -169,6 +169,8 @@  fpu_control_t _dl_fpu_control = _FPU_DEFAULT;
 /* Required flags used for stack allocation.  */
 int _dl_stack_prot_flags = DEFAULT_STACK_PROT_PERMS;
 
+void *_dl_exit_cxa_dso_handle = NULL;
+
 #if __PTHREAD_NPTL
 list_t _dl_stack_used;
 list_t _dl_stack_user;
diff --git a/stdlib/exit.c b/stdlib/exit.c
index 114657a8fb8..eb37a9eae3a 100644
--- a/stdlib/exit.c
+++ b/stdlib/exit.c
@@ -21,6 +21,7 @@ 
 #include <pointer_guard.h>
 #include <libc-lock.h>
 #include <set-freeres.h>
+#include <ldsodefs.h>
 #include "exit.h"
 
 /* Initialize the flag that indicates exit function processing
@@ -113,10 +114,17 @@  __run_exit_handlers (int status, struct exit_function_list **listp,
 	      arg = f->func.cxa.arg;
 	      PTR_DEMANGLE (cxafct);
 
+	      /* Track the DSO handle of the currently-executing callback so
+		 that _dl_close_worker can protect it from being unloaded if a
+		 destructor calls dlclose on another library (BZ 33598).  */
+	      GL(dl_exit_cxa_dso_handle) = f->func.cxa.dso_handle;
+
 	      /* Unlock the list while we call a foreign function.  */
 	      __libc_lock_unlock (__exit_funcs_lock);
 	      cxafct (arg, status);
 	      __libc_lock_lock (__exit_funcs_lock);
+
+	      GL(dl_exit_cxa_dso_handle) = NULL;
 	      break;
 	    }
 
diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
index 15c46598539..fb378c1aec4 100644
--- a/sysdeps/generic/ldsodefs.h
+++ b/sysdeps/generic/ldsodefs.h
@@ -441,6 +441,11 @@  struct rtld_global
   /* Generation counter for the dtv.  */
   EXTERN size_t _dl_tls_generation;
 
+  /* DSO handle of the __cxa_atexit callback currently executing, or NULL.
+     Used in _dl_close_worker to protect the executing library from being
+     unloaded while its destructor is still on the call stack (BZ 33598).  */
+  EXTERN void *_dl_exit_cxa_dso_handle;
+
   /* Scopes to free after next THREAD_GSCOPE_WAIT ().  */
   EXTERN struct dl_scope_free_list
   {