[v3] dlfcn: Fix dlclose crash in atexit handler after thread_local destructor (BZ 33598)

Message ID 20260505191542.3901203-1-adhemerval.zanella@linaro.org (mailing list archive)
State New
Headers
Series [v3] dlfcn: Fix dlclose crash in atexit handler after thread_local destructor (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

Commit Message

Adhemerval Zanella May 5, 2026, 7:14 p.m. UTC
  If a dynamically loaded shared library uses thread_local and calls
dlclose() on another library from its __cxa_atexit-registered global
destructor, glibc will crash because dlclose() unmaps both the caller
library and its target, and control from dlclose() is returned to an
unmapped page.

For instance:

  $ cat <<EOF > main.c
  #include <dlfcn.h>
  #include <assert.h>
  int main()
  {
    void *h = dlopen ("libt1.so", RTLD_NOW);
    assert (h);
    dlclose (h);
  }
  EOF
  $ cat <<EOF > t1.cpp
  #include <dlfcn.h>
  #include <assert.h>
  #include <memory>
  thread_local std::unique_ptr<int> tlp;
  static struct c1 {
    void *h;
    c1 () {
      h = dlopen ("libm.so", RTLD_NOW);
      assert (h);
      tlp = std::make_unique<int> ();
    }
    ~c1 () { dlclose (h); }
  } c1;
  EOF
  $ g++ -Wall -g -fPIC -shared -o libt1.so t1.cpp -ldl
  $ gcc -Wall -g -fPIC main.c -ldl -o main
  $ LD_LIBRARY_PATH=. ./main
  Segmentation fault

When main() calls dlclose(libt1.so), _dl_close_worker finds that lib1
has l_tls_dtor_count > 0 (the TLS destructor for tlp has not yet run),
so lib1 is kept alive and no library is unmapped.

The crash occurs when the process exits, where __run_exit_handlers
calls __call_tls_dtors, which runs the TLS destructor for tlp and
decrements lib1's l_tls_dtor_count to zero.  The __run_exit_handlers
then processes atexit callbacks in reverse registration order: ~c1()
(registered via __cxa_atexit when lib1 was dlopen'd) runs before
_dl_fini (registered at program startup by __libc_start_main).  At
that point both lib1 and lib2 have l_direct_opencount == 0 and
l_tls_dtor_count == 0, so the dlclose(h) call inside ~c1() causes
_dl_close_worker to treat both libraries as unloadable and unmap them.
The code then returns from dlclose() into the now-unmapped lib1, causing
the crash.  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.

The fix inhibits unmapping of libraries when dlclose() is called from
within exit handler processing, by treating all loaded libraries as
still in use for the duration of __run_exit_handlers.  This is safe
because the process is exiting, so physical unmapping provides no
benefit, and failing to unmap prevents use-after-unmap crashes.  It is
highly unlikely that a dlclose() call from an atexit or __cxa_atexit
handler relies on the DSO actually being unmapped.

This is accomplished with a new rtld_global flag, _dl_at_exit, set at
the start of __run_exit_handlers.  When _dl_at_exit is set, all
libraries are marked as still in use inside _dl_close_worker, so no
unmapping occurs.

An exception is made for the internal do_dlclose path in dl-libc.c
(used by __libc_freeres for malloc debug cleanup), which passes
ignore_at_exit=true to _dl_close to ensure libraries are still properly
unmapped for leak detection purposes.

Checked on aarch64-linux-gnu, x86_64-linux-gnu, and i686-linux-gnu.
--
Changes from v2:
* Rebased against master.
* Added tst-thrlocal-dlclose2 on unsupported if C++ is not present.
---
 dlfcn/Makefile                      | 25 ++++++++++++++
 dlfcn/dlclose.c                     |  8 ++++-
 dlfcn/tst-thrlocal-dlclose1-lib1.cc | 40 ++++++++++++++++++++++
 dlfcn/tst-thrlocal-dlclose1-lib2.cc | 19 +++++++++++
 dlfcn/tst-thrlocal-dlclose1.c       | 29 ++++++++++++++++
 dlfcn/tst-thrlocal-dlclose2-lib1.cc | 53 +++++++++++++++++++++++++++++
 dlfcn/tst-thrlocal-dlclose2-lib2.c  | 33 ++++++++++++++++++
 dlfcn/tst-thrlocal-dlclose2.c       | 29 ++++++++++++++++
 elf/dl-close.c                      | 17 ++++++---
 elf/dl-libc.c                       |  2 +-
 elf/dl-open.c                       |  2 +-
 elf/dl-support.c                    |  2 ++
 elf/rtld.c                          |  2 +-
 include/dlfcn.h                     |  6 ++--
 stdlib/exit.c                       |  4 +++
 sysdeps/generic/ldsodefs.h          |  5 ++-
 16 files changed, 264 insertions(+), 12 deletions(-)
 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
  

Comments

Florian Weimer May 5, 2026, 7:36 p.m. UTC | #1
* Adhemerval Zanella:

> @@ -46,6 +47,9 @@ __run_exit_handlers (int status, struct exit_function_list **listp,
>    /* The exit should never return, so there is no need to unlock it.  */
>    __libc_lock_lock_recursive (__exit_lock);
>  
> +  /* Disable unmap objects through dlclose by TLS destructor (BZ 33598).  */
> +  GL(dl_at_exit) = true;
> +
>    /* First, call the TLS destructors.  */
>    if (run_dtors)
>      call_function_static_weak (__call_tls_dtors);
> diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
> index 15c46598539..236419c593c 100644
> --- a/sysdeps/generic/ldsodefs.h
> +++ b/sysdeps/generic/ldsodefs.h
> @@ -441,6 +441,9 @@ struct rtld_global
>    /* Generation counter for the dtv.  */
>    EXTERN size_t _dl_tls_generation;
>  
> +  /* Disable unmap objects during __run_exit_handlers.  */
> +  EXTERN bool _dl_at_exit;
> +
>    /* Scopes to free after next THREAD_GSCOPE_WAIT ().  */
>    EXTERN struct dl_scope_free_list
>    {

Would it be simpler to do what the comment says?  Just guard the
munmap call that is part of dlclose?  This could even be considered an
optimization.
  
Adhemerval Zanella May 5, 2026, 8:45 p.m. UTC | #2
On 05/05/26 16:36, Florian Weimer wrote:
> * Adhemerval Zanella:
> 
>> @@ -46,6 +47,9 @@ __run_exit_handlers (int status, struct exit_function_list **listp,
>>    /* The exit should never return, so there is no need to unlock it.  */
>>    __libc_lock_lock_recursive (__exit_lock);
>>  
>> +  /* Disable unmap objects through dlclose by TLS destructor (BZ 33598).  */
>> +  GL(dl_at_exit) = true;
>> +
>>    /* First, call the TLS destructors.  */
>>    if (run_dtors)
>>      call_function_static_weak (__call_tls_dtors);
>> diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
>> index 15c46598539..236419c593c 100644
>> --- a/sysdeps/generic/ldsodefs.h
>> +++ b/sysdeps/generic/ldsodefs.h
>> @@ -441,6 +441,9 @@ struct rtld_global
>>    /* Generation counter for the dtv.  */
>>    EXTERN size_t _dl_tls_generation;
>>  
>> +  /* Disable unmap objects during __run_exit_handlers.  */
>> +  EXTERN bool _dl_at_exit;
>> +
>>    /* Scopes to free after next THREAD_GSCOPE_WAIT ().  */
>>    EXTERN struct dl_scope_free_list
>>    {
> 
> Would it be simpler to do what the comment says?  Just guard the
> munmap call that is part of dlclose?  This could even be considered an
> optimization.

It should work, the main difference is with this approach (assume_in_use = false),
_dl_close_worker during exit marks every object IDX_STILL_USED and returns 
immediately, so fo finalizers run. The _dl_fini handles every library in its
own sorted pass later.

With the DL_UNMAP approach, _dl_close_worker runs normally and unloadable libraries
get their finalizers called by _dl_call_fini inside _dl_close_worker.

I think it makes sense to use your suggestion. I will send a new version.
  
Adhemerval Zanella May 5, 2026, 8:55 p.m. UTC | #3
On 05/05/26 17:45, Adhemerval Zanella Netto wrote:
> 
> 
> On 05/05/26 16:36, Florian Weimer wrote:
>> * Adhemerval Zanella:
>>
>>> @@ -46,6 +47,9 @@ __run_exit_handlers (int status, struct exit_function_list **listp,
>>>    /* The exit should never return, so there is no need to unlock it.  */
>>>    __libc_lock_lock_recursive (__exit_lock);
>>>  
>>> +  /* Disable unmap objects through dlclose by TLS destructor (BZ 33598).  */
>>> +  GL(dl_at_exit) = true;
>>> +
>>>    /* First, call the TLS destructors.  */
>>>    if (run_dtors)
>>>      call_function_static_weak (__call_tls_dtors);
>>> diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
>>> index 15c46598539..236419c593c 100644
>>> --- a/sysdeps/generic/ldsodefs.h
>>> +++ b/sysdeps/generic/ldsodefs.h
>>> @@ -441,6 +441,9 @@ struct rtld_global
>>>    /* Generation counter for the dtv.  */
>>>    EXTERN size_t _dl_tls_generation;
>>>  
>>> +  /* Disable unmap objects during __run_exit_handlers.  */
>>> +  EXTERN bool _dl_at_exit;
>>> +
>>>    /* Scopes to free after next THREAD_GSCOPE_WAIT ().  */
>>>    EXTERN struct dl_scope_free_list
>>>    {
>>
>> Would it be simpler to do what the comment says?  Just guard the
>> munmap call that is part of dlclose?  This could even be considered an
>> optimization.
> 
> It should work, the main difference is with this approach (assume_in_use = false),
> _dl_close_worker during exit marks every object IDX_STILL_USED and returns 
> immediately, so fo finalizers run. The _dl_fini handles every library in its
> own sorted pass later.
> 
> With the DL_UNMAP approach, _dl_close_worker runs normally and unloadable libraries
> get their finalizers called by _dl_call_fini inside _dl_close_worker.
> 
> I think it makes sense to use your suggestion. I will send a new version.

However, analyzing this a bit more I am not sure. With DL_UNMAP the link map is 
still removed from the namespace, but the pages remain mapped and the DSO is gone
from GL(dl_ns[nsid]._ns_loaded), _dl_loaded_lock, and l_initfini chains. This 
creates an inconsistent state where:

* This approach guarantees that _dl_fini runs all fini/fini_array callbacks in a
  single topologically-sorted pass, where DL_UNMAP _dl_close_worker's _dl_call_fini 
  may run destructors for a library during exit, and then _dl_fini may encounter 
  it again (or not, if the link map was removed).

* dl_iterate_phdr and dladdr — used by profilers and alike (ASan/TSan) will miss 
  libraries whose pages are still live.

* LD_AUDIT (la_objclose callbacks) fires for each dlclose during exit. 

This approach makes exit-time dlclose semantically a no-op, although a bit more
complex.

So I leaning a bit more for the proposed approach.
  
Florian Weimer May 5, 2026, 9:35 p.m. UTC | #4
* Adhemerval Zanella Netto:

> However, analyzing this a bit more I am not sure. With DL_UNMAP the
> link map is still removed from the namespace, but the pages remain
> mapped and the DSO is gone from GL(dl_ns[nsid]._ns_loaded),
> _dl_loaded_lock, and l_initfini chains. This creates an inconsistent
> state where:
>
> * This approach guarantees that _dl_fini runs all fini/fini_array
> callbacks in a single topologically-sorted pass, where DL_UNMAP
> _dl_close_worker's _dl_call_fini may run destructors for a library
> during exit, and then _dl_fini may encounter it again (or not, if
> the link map was removed).

I'm not sure the explanation in the commit message that this prevents
unmapping only is correct.  I think it does alter destructor ordering
in some cases.

We likely have applications that depend on dlclose actually running
destructors during exit.  These regressions would only concern
application exit, so hopefully they are not critical, but the change
still seems rather invasive.  My past attempts to tweak the
destruction order have surfaced lots of issues, so I think we should
avoid that if we can.

I need to think more about this.  Maybe I'm misunderstanding the
nature of this bug.
  
Adhemerval Zanella May 5, 2026, 10:54 p.m. UTC | #5
On 05/05/26 18:35, Florian Weimer wrote:
> * Adhemerval Zanella Netto:
> 
>> However, analyzing this a bit more I am not sure. With DL_UNMAP the
>> link map is still removed from the namespace, but the pages remain
>> mapped and the DSO is gone from GL(dl_ns[nsid]._ns_loaded),
>> _dl_loaded_lock, and l_initfini chains. This creates an inconsistent
>> state where:
>>
>> * This approach guarantees that _dl_fini runs all fini/fini_array
>> callbacks in a single topologically-sorted pass, where DL_UNMAP
>> _dl_close_worker's _dl_call_fini may run destructors for a library
>> during exit, and then _dl_fini may encounter it again (or not, if
>> the link map was removed).
> 
> I'm not sure the explanation in the commit message that this prevents
> unmapping only is correct.  I think it does alter destructor ordering
> in some cases.
> 
> We likely have applications that depend on dlclose actually running
> destructors during exit.  These regressions would only concern
> application exit, so hopefully they are not critical, but the change
> still seems rather invasive.  My past attempts to tweak the
> destruction order have surfaced lots of issues, so I think we should
> avoid that if we can.
> 
> I need to think more about this.  Maybe I'm misunderstanding the
> nature of this bug.

I have not considered keeping the destructor ordering the same, but I think
it should be feasible. I am not sure which is the expected behavior, this is
really an implementation detail where static class destructor interfere with
DSO destructor.

Let me check if it would be possible to keep the destructor ordering, I really
think we should not hack the unmap way to avoid potential semantic changes
on debugger/analyzers/sanitizers.
  

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/dlclose.c b/dlfcn/dlclose.c
index 75dfe66e9b4..b42ac7cc230 100644
--- a/dlfcn/dlclose.c
+++ b/dlfcn/dlclose.c
@@ -20,6 +20,12 @@ 
 #include <ldsodefs.h>
 #include <shlib-compat.h>
 
+static void
+dlclose_doit (void *handle)
+{
+  GLRO (dl_close) (handle, false);
+}
+
 int
 __dlclose (void *handle)
 {
@@ -28,7 +34,7 @@  __dlclose (void *handle)
     return GLRO (dl_dlfcn_hook)->dlclose (handle);
 #endif
 
-  return _dlerror_run (GLRO (dl_close), handle) ? -1 : 0;
+  return _dlerror_run (dlclose_doit, handle) ? -1 : 0;
 }
 versioned_symbol (libc, __dlclose, dlclose, GLIBC_2_34);
 
diff --git a/dlfcn/tst-thrlocal-dlclose1-lib1.cc b/dlfcn/tst-thrlocal-dlclose1-lib1.cc
new file mode 100644
index 00000000000..0e87d611a99
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose1-lib1.cc
@@ -0,0 +1,40 @@ 
+/* 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;
+
+  c1 ()
+  {
+    h = dlopen ("tst-thrlocal-dlclose1-lib2.so", RTLD_NOW);
+    assert (h != NULL);
+    tlp = std::make_unique<int>();
+  }
+
+  ~c1 ()
+  {
+    assert (dlclose (h) == 0);
+  }
+} c1;
diff --git a/dlfcn/tst-thrlocal-dlclose1-lib2.cc b/dlfcn/tst-thrlocal-dlclose1-lib2.cc
new file mode 100644
index 00000000000..88d0f767bf1
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose1-lib2.cc
@@ -0,0 +1,19 @@ 
+/* 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/>.  */
+
+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..c582f0037b6
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose2-lib1.cc
@@ -0,0 +1,53 @@ 
+/* 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);
+
+  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);
+
+    init ();
+
+    tlp = std::make_unique<int>();
+  }
+
+  ~c1 ()
+  {
+    cleanup ();
+    assert (dlclose (h) == 0);
+  }
+} c1;
diff --git a/dlfcn/tst-thrlocal-dlclose2-lib2.c b/dlfcn/tst-thrlocal-dlclose2-lib2.c
new file mode 100644
index 00000000000..ebb337f319c
--- /dev/null
+++ b/dlfcn/tst-thrlocal-dlclose2-lib2.c
@@ -0,0 +1,33 @@ 
+/* 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;
+
+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..4aaf5e00103 100644
--- a/elf/dl-close.c
+++ b/elf/dl-close.c
@@ -111,7 +111,7 @@  remove_slotinfo (size_t idx, struct dtv_slotinfo_list *listp, size_t disp,
 }
 
 void
-_dl_close_worker (struct link_map *map, bool force)
+_dl_close_worker (struct link_map *map, bool force, bool ignore_at_exit)
 {
   /* One less direct use.  */
   --map->l_direct_opencount;
@@ -144,6 +144,14 @@  _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];
+  /* Assume all objects are still in use during process exit to avoid
+     potential issues where an object is unmapped while still in use
+     (BZ 33598).
+     Also handle special cases where libc requires the object to be unmapped,
+     and not doing so would report a leak issue (__libc_freeres, used by
+     malloc trace).  For this case, libc knows it is safe to unmap the
+     object.  */
+  bool assume_in_use = ignore_at_exit || !GL(dl_at_exit);
 
   /* Run over the list and assign indexes to the link maps and enter
      them into the MAPS array.  */
@@ -185,7 +193,8 @@  _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
+	  && assume_in_use)
 	continue;
 
       /* We need this object and we handle it now.  */
@@ -759,7 +768,7 @@  _dl_close_worker (struct link_map *map, bool force)
 
 
 void
-_dl_close (void *_map)
+_dl_close (void *_map, bool ignore_at_exit)
 {
   struct link_map *map = _map;
 
@@ -795,7 +804,7 @@  _dl_close (void *_map)
       _dl_signal_error (0, map->l_name, NULL, N_("shared object not open"));
     }
 
-  _dl_close_worker (map, false);
+  _dl_close_worker (map, false, ignore_at_exit);
 
   __rtld_lock_unlock_recursive (GL(dl_load_lock));
 }
diff --git a/elf/dl-libc.c b/elf/dl-libc.c
index ef8439c56d6..ef610d563a3 100644
--- a/elf/dl-libc.c
+++ b/elf/dl-libc.c
@@ -122,7 +122,7 @@  do_dlvsym (void *ptr)
 static void
 do_dlclose (void *ptr)
 {
-  GLRO(dl_close) ((struct link_map *) ptr);
+  GLRO(dl_close) ((struct link_map *) ptr, true);
 }
 
 #ifndef SHARED
diff --git a/elf/dl-open.c b/elf/dl-open.c
index ee25d4d42b4..e585565d6d4 100644
--- a/elf/dl-open.c
+++ b/elf/dl-open.c
@@ -929,7 +929,7 @@  no more namespaces available for dlmopen()"));
 	 state if relocation failed, for example.  */
       if (args.map)
 	{
-	  _dl_close_worker (args.map, true);
+	  _dl_close_worker (args.map, true, false);
 
 	  /* All l_nodelete_pending objects should have been deleted
 	     at this point, which is why it is not necessary to reset
diff --git a/elf/dl-support.c b/elf/dl-support.c
index 0508d6113b5..faa04dfd4bf 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;
 
+bool _dl_at_exit = false;
+
 #if __PTHREAD_NPTL
 list_t _dl_stack_used;
 list_t _dl_stack_user;
diff --git a/elf/rtld.c b/elf/rtld.c
index e926ec73e49..3e4ff1a824e 100644
--- a/elf/rtld.c
+++ b/elf/rtld.c
@@ -886,7 +886,7 @@  unload_audit_module (struct link_map *map, int original_tls_idx)
 #ifndef NDEBUG
   Lmid_t ns = map->l_ns;
 #endif
-  _dl_close (map);
+  _dl_close (map, true);
 
   /* Make sure the namespace has been cleared entirely.  */
   assert (GL(dl_ns)[ns]._ns_loaded == NULL);
diff --git a/include/dlfcn.h b/include/dlfcn.h
index a44420fa374..8f76eb6648f 100644
--- a/include/dlfcn.h
+++ b/include/dlfcn.h
@@ -68,11 +68,11 @@  extern int _dl_addr (const void *address, Dl_info *info,
 struct link_map;
 
 /* Close an object previously opened by _dl_open.  */
-extern void _dl_close (void *map) attribute_hidden;
+extern void _dl_close (void *map, bool ignore_at_exit) attribute_hidden;
 /* Same as above, but without locking and safety checks for user
    provided map arguments.  */
-extern void _dl_close_worker (struct link_map *map, bool force)
-    attribute_hidden;
+extern void _dl_close_worker (struct link_map *map, bool force,
+			      bool ignore_at_exit) attribute_hidden;
 
 /* Look up NAME in shared object HANDLE (which may be RTLD_DEFAULT or
    RTLD_NEXT).  WHO is the calling function, for RTLD_NEXT.  Returns
diff --git a/stdlib/exit.c b/stdlib/exit.c
index 114657a8fb8..a3d26ceb3ae 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
@@ -46,6 +47,9 @@  __run_exit_handlers (int status, struct exit_function_list **listp,
   /* The exit should never return, so there is no need to unlock it.  */
   __libc_lock_lock_recursive (__exit_lock);
 
+  /* Disable unmap objects through dlclose by TLS destructor (BZ 33598).  */
+  GL(dl_at_exit) = true;
+
   /* First, call the TLS destructors.  */
   if (run_dtors)
     call_function_static_weak (__call_tls_dtors);
diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
index 15c46598539..236419c593c 100644
--- a/sysdeps/generic/ldsodefs.h
+++ b/sysdeps/generic/ldsodefs.h
@@ -441,6 +441,9 @@  struct rtld_global
   /* Generation counter for the dtv.  */
   EXTERN size_t _dl_tls_generation;
 
+  /* Disable unmap objects during __run_exit_handlers.  */
+  EXTERN bool _dl_at_exit;
+
   /* Scopes to free after next THREAD_GSCOPE_WAIT ().  */
   EXTERN struct dl_scope_free_list
   {
@@ -646,7 +649,7 @@  struct rtld_global_ro
 				   struct link_map *);
   void *(*_dl_open) (const char *file, int mode, const void *caller_dlopen,
 		     Lmid_t nsid, int argc, char *argv[], char *env[]);
-  void (*_dl_close) (void *map);
+  void (*_dl_close) (void *map, bool);
   /* libdl in a secondary namespace (after dlopen) must use
      _dl_catch_error from the main namespace, so it has to be
      exported in some way.  */