ELF Symbol Interposition: Decide Whether a Shared Library Must Bind to Its Own State

Two shared libraries can define a global with the same name and still have both libraries’ internal references resolve to one provider. Before applying a binding flag, decide whether that name is an intentional process-wide interface or accidental private-state exposure. The repair changes semantics, not just the export list.

This guide uses GCC 14.2, GNU BFD Binutils 2.44 and the glibc 2.41 host loader. An executed two-library fixture compared default binding, hidden data, -Bsymbolic and -Bsymbolic-functions. Its fixed dependency order is part of the result.

Write the state-ownership contract

Suppose library A defines shared_state initialized to 1, and library B defines the same name initialized to 2. Each exports a uniquely named reader. If the state is private, the intended outputs are 1 and 2. If the state is intentionally shared, the design instead needs one documented owner and a defined initialization policy.

Do not infer privacy from a source file boundary. Externally visible ELF symbols participate in link and loader rules. A library’s own source-level reference can be preemptible even when the definition appears beside the reader function.

Build a small policy table for exported functions, exported data, internal helpers, RTTI and allocation hooks. State which may be replaced and which must bind locally. A blanket option can affect interfaces that were never involved in the reported collision.

Two policy panels compare default and functions-only interposition with symbolic and hidden-data local binding, showing the tested reader values for two colliding libraries.
Original data-binding comparison. All four host policy outcomes were executed.

Distinguish visibility from reference binding

The GCC 14.2 visibility documentation explains the public/private linkage boundary. Marking genuinely private data hidden removes it from ordinary external symbol lookup and gives the compiler/linker a stronger local-binding contract.

The BFD 2.44 linker manual defines -Bsymbolic as binding a shared library’s references to its own global definitions when available. -Bsymbolic-functions narrows that policy to function symbols. The latter does not establish private binding for a global data object.

These mechanisms answer different questions. A symbol can remain exported while the library’s internal references are bound locally. An export inventory alone therefore cannot prove that outside callers and internal users observe one shared state instance.

Inspect the actual reference path

Preserve dynamic symbol tables and relocation tables for both libraries. Identify the colliding raw symbol name, binding, visibility and the relocations used by each reader. The Binutils inspection tools let the review distinguish a public definition from a reference that still requires runtime resolution.

Also preserve the executable’s DT_NEEDED order and runtime search environment. A different dependency order, preload or plugin scope can change which candidate participates. A test without those inputs is difficult to reproduce even if every library has the same filename.

Use unique exported reader names in a reduced fixture so only the data symbol collides. If both reader functions and data collide simultaneously, the result may not reveal whether the wrong function ran or the expected function read a preempted variable.

Read the executed four-policy matrix

The fixture linked an executable to A followed by B. Both DSOs were compiled as PIC at O0, and the launch environment removed LD_* variables. The main program called read_a and read_b without directly referencing shared_state.

Policy applied to both libraries Observed read_a / read_b
Default-visible data, ordinary shared link 1 / 1
Default-visible data, -Bsymbolic 1 / 2
Default-visible data, -Bsymbolic-functions 1 / 1
Only shared_state marked hidden 1 / 2

The first result showed B reading the selected shared provider rather than its own initializer’s intended private state. The functions-only case preserved that data behavior. The hidden-data and symbolic cases produced separate internal values in this fixture.

No target, LD_PRELOAD scenario, executable data copy relocation or C++ type boundary was tested. The result does not promise that every default lookup order produces A’s value or that either local-binding policy is universally preferable.

Select the smallest semantic correction

If the variable was never part of the public API, make its internal ownership explicit in source and the exported interface policy. Hidden visibility can express that intention without changing every public reference in the DSO. Rename private globals where useful, but retain a visibility rule so future accidental names do not reopen the problem.

If consumers intentionally interpose an API, local binding can break a supported extension mechanism. Test that contract before adopting -Bsymbolic. Do not sell the option as a performance-only change: internal calls or data references can stop observing external replacements.

For exported data, consider whether an accessor API with one clear owner would be easier to maintain. That is an ABI design change requiring a migration plan, not an emergency flag to apply silently to a released library.

Review C++ and allocation boundaries separately

Runtime type information, exception classes, new/delete and library-owned objects can depend on cross-DSO identity or ownership assumptions. A successful C data-collision fixture does not qualify those cases. The linker supports more selective dynamic-list controls, but their contents must follow a reviewed interface contract.

Likewise, RTLD_LOCAL is a loader-scope choice, not a general guarantee that a plugin’s internal references cannot encounter existing global definitions. Scope and preemption need an actual loading fixture for the product rather than a name-based interpretation of “local.”

Make changed behavior visible in acceptance

Retest the intended provider order, supported plugin scopes and deliberate override mechanisms. Include a negative collision control that clearly distinguishes two state values. Preserve the symbol/relocation changes alongside the runtime outputs so a reviewer can see why the result changed.

The release decision should state which references are intentionally preemptible and which bind to library-owned definitions. Keep that policy with the exported ABI manifest. Then a later collision is an ownership violation that can be diagnosed, rather than a mysterious consequence of whichever shared library happened to load first.

Similar Posts