Follow @Openwall on Twitter for new release announcements and other news
[<prev] [day] [month] [year] [list]
Message-ID: <CADpJO7wMD+YutqS8Qv8Hw=3-xQuJ-J+ESsib93=CtCTQWRTnAg@mail.gmail.com>
Date: Mon, 19 Oct 2026 17:38:53 -0400
From: Aliaksey Kandratsenka <alkondratenko@...il.com>
To: libc-coord@...ts.openwall.com
Cc: fweimer@...hat.com, 
	Adhemerval Zanella Netto <adhemerval.zanella@...aro.org>, dalias@...ifal.cx, i@...kray.me, 
	riastradh@...bsd.org
Subject: RFC: dl_events_subscribe for ELF dynamic loaders

Hi libc-coord members,

In my aw-backtrace project
(https://github.com/gperftools/aw-backtrace) I currently use glibc's
_dl_find_object API, which works great. However, I also need a
reliable cache-invalidation signal on module unloads, as well as a way
to support _dl_find_object semantics on non-glibc systems without
requiring every libc to maintain RCU/seqlock data structures
internally (or resorting to fragile dlopen/dlclose interposition).

I would like to propose a small dynamic linker extension,
dl_events_subscribe, to solve this. As far as I can see, it also
cleanly addresses several long-standing dynamic linker pain points in
other projects (such as libbacktrace and BDWGC).

I have working prototype patches for glibc, musl, and NetBSD, along
with a prototype user-space _dl_find_object and async-signal-safe
snapshot dl_iterate_phdr implementation built on top of this API:
https://github.com/alk/dl-find-object

Below is the design document (also available as a Google Doc at
https://tinyurl.com/dl-events-subscribe-design; some people may prefer
to comment there). Feedback on the concept and API shape is very
welcome. I am also reaching out to the libbacktrace and BDWGC
maintainers about the use cases described below.

I am posting here so that we can focus on the API rather than on my
specific patches, but the code is available for anyone interested.

Musl prototype patch can be checked here:
https://github.com/alk/dl-find-object/blob/main/doc/0001-rfc-prototype-of-dl_events_subscribe-for-musl.patch

NetBSD patch is at:
https://github.com/alk/dl-find-object/blob/main/doc/0001-rfc-ld.elf_so-prototype-of-dl_events_subscribe-for-n.patch

Glibc patch is here:
https://github.com/alk/dl-find-object/blob/main/doc/glibc/0004-rfc-elf-prototype-of-dl_events_subscribe-for-glibc.patch
(depends on 0003 in the series that moves some code). Patches
0001-0003 in that directory are independent bug fixes that I am
posting to libc-alpha separately; only 0004 is the RFC.

---

dl_events_subscribe for ELF dynamic loaders
===========================================

Aliaksei Kandratsenka, alkondratenko@...il.com
Oct 13 2026

Self link: https://tinyurl.com/dl-events-subscribe-design

**TL;DR:** I am proposing a new dynamic linker API endpoint to
register callbacks invoked on module load and unload events. It can be
used to implement features like _dl_find_object with arbitrary
extensions (e.g., cache invalidation or additional metadata fields)
and resolves several long-standing issues with dl_iterate_phdr.

_dl_find_object is great, but as of this writing it remains
glibc-only. Not every libc is eager to maintain the RCU-like
synchronization required to make it async-signal-safe—which is the
primary reason to have it in the first place.

I propose adding dl_events_subscribe(callback, callback_data), where
callback is a function pointer of type void (*)(struct dl_phdr_info
*info, size_t info_size, void *callback_data, int is_unload).  This
matches the dl_iterate_phdr callback signature, except with an
additional is_unload parameter and a void return type.

One way to think of this is as a “streaming dl_iterate_phdr”. The
callback is invoked once for each currently loaded module upon
registration, and subsequently from inside dlopen and dlclose as
modules are loaded and unloaded. For newly loaded modules, callbacks
are invoked before module constructors run, under the same loader
locks that are held (or dropped) around constructors (for example,
musl and NetBSD drop loader locks around constructors, whereas glibc
holds its load lock). For unloaded modules, callbacks are invoked
after module destructors finish. The core invariant is that a module’s
existence is announced before any of its code can run, and its removal
is announced immediately after its destructors complete and before its
address range can be reused.

The intended use of this API is to allow user code to maintain its own
snapshots of loaded module state. It can be used both to implement a
portable equivalent of _dl_find_object and to maintain an
async-signal-safe snapshot of loaded modules (e.g., for conservative
garbage collectors scanning GC roots). Unload events can also drive
cache invalidation (a primary use case for aw-backtrace, and equally
applicable to libbacktrace,
https://github.com/ianlancetaylor/libbacktrace).

Subscriptions allow moving non-trivial tracking and per-module setup
to module load/unload time, rather than performing that work at much
trickier execution points (such as during stop-the-world GC, inside
SIGPROF handlers, or when capturing and symbolizing backtraces from
fatal signal handlers).

Module load and unload events are rare in most programs, whereas
queries against the currently loaded module set are often frequent
(for instance, capturing a backtrace on every malloc call). Moving
precomputation and cache invalidation work to load and unload time is
therefore a clear performance win.

As is standard for low-level runtime APIs, the high-level design
provides the weakest guarantees that still get the job done. Proposed
specification guarantees (individual implementations may be stricter,
but this defines the broad contract):

- dl_events_subscribe invokes the newly registered callback for all
  currently loaded modules across all dynamic linker namespaces.
- Callbacks may observe “spurious” unload events (an unload event
  without a prior matching load event).
- Load events are announced before the corresponding module is
  initialized (before its constructors run).
- Unload events are announced after the module’s destructors have
  completed and before the module is unmapped.
- Unload events are always announced after their matching load events
  (aside from spurious unloads as noted above).
- Load events may be duplicated (e.g., if a concurrent dlopen races
  with the initial enumeration in dl_events_subscribe).
- Registered callbacks for a single event are invoked sequentially in an
  unspecified order.
- Unrelated events may invoke callbacks concurrently on different
  threads without internal serialization.
- Whether the module currently being loaded or unloaded is visible to
  _dl_find_object or dl_iterate_phdr during the callback is
  unspecified.
- Callbacks must not exit non-locally (e.g., via longjmp or by
  throwing C++ exceptions across the callback boundary).

Invoking user callbacks from inside the dynamic loader naturally
raises synchronization concerns, such as deadlocks. The current
prototype implementations enforce a “mutation ban” on the thread
executing callbacks: a callback (and anything it invokes) is forbidden
from calling dynamic linker mutation endpoints (dlopen, dlmopen,
dlclose, dl_events_subscribe). This prevents the most common
re-entrancy hazards and limits Hyrum’s Law dependencies.

Common plugin architectures that load arbitrary code (such as PAM or
NSS modules) already place very few restrictions on what locks may be
held around calls to dlopen or dlclose—and, by extension, around
subscription callbacks—because module constructors themselves are
largely unrestricted.

Occasionally, however, code loads specially constructed modules under
stricter assumptions (for example, assuming a data-only module has no
initializers, or that its initializers only run tightly constrained
code). Running arbitrary subscriber callbacks in those contexts could
theoretically interact poorly with caller locks. In practice,
subscription callbacks only need to invoke primitives already used
internally by the dynamic loader (such as malloc) or acquire “leaf
locks” (locks under which no other locks are acquired), which is more
than sufficient for the intended use cases.

Lack of unsubscribe
~~~~~~~~~~~~~~~~~~~

The current prototypes do not provide an unsubscribe API. Instead,
subscriptions are tied to the module containing the callback
function’s address. When that module is unloaded via dlclose, its
subscriptions are automatically removed before its destructors
run. This is similar to how __cxa_atexit and pthread_atfork are
managed, though the prototype implementation is simpler. Keeping the
API surface small reduces both implementation complexity and the risk
of misuse.

Common use-cases
----------------

_dl_find_object implementation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

In glibc, _dl_find_object hooks into essentially the same internal
add/remove points. Re-implementing it on top of dl_events_subscribe
using a two-version seqlock and async-signal-safe revalidating lookups
is straightforward. At https://github.com/alk/dl-find-object, I have
an alternative implementation that stores module metadata in a B-tree;
lookups similarly validate that the data version remains unchanged
after each access.

libbacktrace: reacting to dlopen and dlclose
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Currently, libbacktrace’s elf.c gathers DSOs only once during state
initialization. There is a long-standing FIXME in dwarf.c (used by the
ELF backend) noting the lack of updates for dlopen-loaded modules (and
a corresponding issue when modules are unloaded). On Windows,
libbacktrace’s pecoff.c already solves this cleanly using a very
similar OS facility (LdrRegisterDllNotification) to update debug
info. Updating elf.c to use dl_events_subscribe would be a small,
natural change, and synchronizing via a lock with signals masked is
straightforward.

BDWGC: deadlock-free stop-the-world module iteration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

To scan static data roots safely, BDWGC
(https://github.com/bdwgc/bdwgc) needs to stop all threads and then
enumerate all loaded modules. dl_events_subscribe solves this cleanly
without calling dl_iterate_phdr while threads are stopped:

- In the subscription callback, acquire a dedicated BDWGC snapshot lock
  and update an internal snapshot of loaded modules.
- When stopping the world:

  - First, acquire the BDWGC snapshot lock.
  - Next, stop all threads.
  - Finally, scan the snapshot of loaded modules for GC roots (and
    release the lock after resuming threads).

Because the GC uses its own independent lock and never touches
internal dynamic linker locks during collection, this works safely
even if a suspended thread happens to be stopped while holding
internal dynamic linker locks. It eliminates the deadlocks and fragile
dlopen interposition workarounds currently required with
dl_iterate_phdr.

Alternatives
------------

- **Async-signal-safe generation counters (dlpi_adds / dlpi_subs):**
  Purely for aw-backtrace’s cache-invalidation needs, one alternative
  is an async-signal-safe API to read the counters currently exposed
  via dlpi_adds and dlpi_subs across all loader namespaces (since
  dl_iterate_phdr only reports the caller’s namespace). If fast
  enough, this would cover cache invalidation for aw-backtrace and
  potentially libbacktrace (if paired with _dl_find_object and lazy
  DWARF loading), but it is a much less flexible primitive.

- **_r_debug:** Not viable for libraries. _r_debug is designed for
  external debuggers that can place breakpoints and halt all
  threads. An in-process library like aw-backtrace cannot safely use
  it.

- **LD_AUDIT:** Not viable for general-purpose libraries. It requires
  wrapper scripts or users manually launching binaries with
  environment variables set (LD_AUDIT=...), which works for standalone
  profiling runs but not for libraries linked or loaded into normal
  applications.

- **No libc changes (interposing dlopen/dlclose + /proc/self/maps):**
  A rough equivalent of _dl_find_object can be built by wrapping
  dlopen/dlclose for cache invalidation and parsing /proc/pid/maps (or
  getdents + readlink on /proc/pid/map_files) to locate mapped
  modules. This will likely serve as aw-backtrace’s fallback on older
  or non-Linux systems, but it has major drawbacks: (a) reading
  /proc/pid/maps scales poorly and can self-DDoS under heavy
  contention; (b) cleanly interposing dlopen/dlclose across all
  linking models (including static linking) is fragile; and (c)
  /proc/pid/maps is non-portable and may not be mounted in chroots or
  containers.

- **Async-signal-safe, lockless dl_iterate_phdr:** Libcs could expose
  a snapshot-based or lockless variant of dl_iterate_phdr (for
  example, by delaying module unmapping). This could be a reasonable
  fallback for musl, which does not unload modules on dlclose:
  dlpi_adds already provides a cache-invalidation signal, so safe
  lockless iteration only requires an atomic tail pointer to fully
  loaded DSOs. Note that musl already drops its loader lock around
  invocations of the dl_iterate_phdr callback, which breaks tools that
  assume dl_iterate_phdr serializes against dynamic linker activity
  (see, for example, LSan’s assumptions in LLVM review D67738,
  https://reviews.llvm.org/D67738).

Prior Art: Windows and macOS
----------------------------

Both Windows and macOS provide similar notification APIs:

- **macOS** provides _dyld_register_func_for_add_image and
  _dyld_register_func_for_remove_image
  (https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dyld.3.html).
Its
  documentation notes that callbacks are invoked with the dynamic
  linker lock held, and registration immediately invokes the callback
  for already-loaded images.

- **Windows** provides LdrRegisterDllNotification and
  LdrUnregisterDllNotification
  (https://learn.microsoft.com/en-us/windows/win32/devnotes/ldrdllnotification).
While
  not explicitly documented by Microsoft, internet gossip indicates
  that its callbacks are likewise invoked under the loader lock.

Potential questions
-------------------

- **Should there be an explicit unsubscribe API?** And if so, should
  it be restricted (e.g., to module destructors)? Currently
  implemented automatic cleanup on module unload avoids race
  conditions between concurrent callback execution and
  unregistration. A smaller API surface means fewer risks of abusing
  it. While JIT-compiled or libffi-trampoline callbacks might not map
  to a standard DSO, those cases can simply remain registered for the
  lifetime of the process.

- **Should subscription callbacks be able to “veto” (fail) a load or
  unload?** In glibc, _dl_find_object_update can cause dlopen to fail
  if it runs out of memory. One could argue a third-party
  _dl_find_object-style subscriber might want to do the same.
  However, supporting veto-capable callbacks—especially on the unload
  path, where user implementations may also allocate
  memory—significantly complicates loader rollback logic. Some
  prototypes invoke load callbacks after the loader’s “point of no
  return”. Even in glibc, while _dl_find_object_dlclose avoids
  allocation by atomically zeroing the address range, scope updates
  during dlclose still call malloc with imperfect OOM handling.
  Keeping callbacks void and non-failing keeps loader integration much
  simpler.

- **Aborted dlopen and error-recovery paths:** Allowing spurious
  unload events (as well as load-then-immediate-unload sequences)
  simplifies error handling inside the dynamic loader. If a dlopen
  fails partway through (e.g., due to a relocation failure or an OOM
  during internal bookkeeping like _dl_find_object_update) and runs
  its internal dlclose cleanup path on partially loaded objects, the
  loader does not need to track whether each object already fired a
  load callback. It can unconditionally fire unload callbacks during
  cleanup: modules that already reported a load event are cleanly
  rolled back, and modules that failed prior to the load event simply
  emit a benign spurious unload event.

- **Should dl_events_subscribe replay currently loaded modules, or
  only report deltas?** Windows reports only deltas, requiring callers
  to enumerate existing modules separately. We could do the same, but
  because dl_iterate_phdr in glibc only enumerates the caller’s
  namespace, a delta-only API would also require introducing a new
  dl_iterate_phdr_all function. Replaying currently loaded modules
  upon subscription keeps the API surface down to a single function.

- **Dynamic linker namespaces (dlmopen):** Currently a glibc-specific
  feature (see man dlmopen). Whereas dl_iterate_phdr only visits DSOs
  in the caller’s namespace, _dl_find_object operates globally across
  all namespaces.  dl_events_subscribe is likewise global across all
  namespaces to match _dl_find_object.

- **What arguments should be passed to subscription callbacks?** The
  current prototypes mirror dl_iterate_phdr (passing struct
  dl_phdr_info with dlpi_adds and dlpi_subs set to 0) plus
  is_unload. Reusing struct dl_phdr_info is familiar and sufficient to
  match loads and unloads, though extensions (such as exposing the
  namespace ID or struct link_map) are possible.

- **Process exit behavior:** In the glibc and NetBSD prototypes,
  starting process shutdown (_dl_fini) disables new subscriptions and
  cancels all existing subscriptions before running global ELF
  destructors. This matches dlclose semantics and avoids firing
  redundant unload events during process termination. Note that if
  dl_events_subscribe is called after process shutdown has begun—or
  with a callback belonging to a module currently being unloaded by
  dlclose—the implementation still invokes the callback for all
  currently loaded modules to honor the base contract, and then
  silently drops the subscription.

- **Reporting errors from dl_events_subscribe:** The current patches
  have dl_events_subscribe return 0 on success and a positive errno
  value (ENOMEM, EDEADLK) on failure, matching modern pthread APIs
  rather than using the awkward dlerror facility.

Powered by blists - more mailing lists

Confused about mailing lists and their use? Read about mailing lists on Wikipedia and check out these guidelines on proper formatting of your messages.