Linkingimplementation

Symbol Resolution Order

When several objects define the same name, the loader picks one, and the rule is positional rather than semantic. `LD_PRELOAD` weaponises that deliberately, which makes the search path and the scope order a real security surface.

The question

When two loaded libraries define the same symbol, which one wins — and who decides?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The process's loaded objects as an ordered search scope. Resolution is a linear scan of that list: the first object defining a name satisfies every reference to it, from everywhere. That representation exists because the dynamic symbol namespace is flat — there is no qualified name to disambiguate with, so position in the list is the only tiebreaker available.

What this phase may assume or do

The loader may bind a reference to the first definition in the search scope, and it must apply the scope order consistently: the executable first, then its needed objects in breadth-first order, then anything loaded later. Preloaded objects are inserted ahead of the needed objects, before the executable's own dependencies get a chance. What makes an interposition *safe* rather than merely permitted is that the replacing definition must satisfy the same contract as the one it displaces — same signature, same semantics, same allocator, same error behavior — which nothing checks and which is why interposition is a debugging and instrumentation technique rather than a general extension mechanism.

Key points

  • Resolution scans an ordered scope and takes the first definition: executable, then preloads, then needed libraries breadth-first, then dlopened objects.
  • Binding is per-process, not per-caller: the winning definition serves every object in the process, including ones tested against a different one.
  • The file search order is separate: RPATH, LD_LIBRARY_PATH, RUNPATH, the cache, then system directories — and RPATH outranks the environment while RUNPATH does not.
  • $ORIGIN in a RUNPATH is the standard way to ship an application with a relocatable private library directory.
  • Interposition is a first-class debugging and instrumentation technique and simultaneously a code-execution vector when an attacker controls the environment or a search directory.
  • Accidental interposition between unrelated libraries sharing a common symbol name is a real and hard bug class; hidden visibility is the main defence.

The scope is a list, and the first match wins

implementationThis ordering is the glibc dynamic loader on ELF. musl implements the same model with minor differences. macOS's two-level namespace changes it fundamentally: each import records its expected library, so a matching name in a different library does not interpose at all, and DYLD_INSERT_LIBRARIES is both restricted by system integrity protection and disabled for hardened or entitled binaries. Windows resolves through per-DLL import tables with a documented search order and no global scope.

The dynamic loader builds an ordered list of objects and resolves every symbol by scanning it front to back. The order is: the executable itself, then anything named in LD_PRELOAD, then the executable's needed libraries in breadth-first order of the dependency graph, then anything loaded later with dlopen.

The consequence is global and often surprising: the first object defining malloc supplies malloc to *every* object in the process, including libraries that were built and tested against a different one. Resolution is not per-caller and not per-library — it is per-process, decided once, by position.

Note that the executable comes first. A program that defines its own malloc gets its own, and so does every library it loaded, whether they expected that or not. This is how allocator replacement works — link jemalloc into the executable and the whole process uses it — and it is also how a program can accidentally break a library by defining a common name at global scope.

The search scope, in the order it is consultedimplementation
  1. The executableload time
    The main program's own dynamic symbol table.
    First refusal on every name in the process, including ones it never meant to claim.
  2. Preloaded objectsload time
    Objects named by LD_PRELOAD, in the order given.
    Deliberate interposition ahead of every real dependency.
  3. Needed librariesload time
    The dependency graph, breadth-first from the executable.
    The intended definitions — for any name not already claimed above.
  4. Their dependenciesload time
    Transitive needs, still breadth-first.
    Deeper definitions, which lose to any shallower one with the same name.
  5. `dlopen`ed objectsrun time
    Objects loaded during execution.
    New definitions that do not displace already-bound ones, and a local scope of their own.
    Any expectation of a stable global order, since the scope now depends on execution history.

Read it asRead the last row as the reason plugin systems get strange: symbols already resolved stay resolved, so whether a plugin's definition is used depends on whether anything had already needed that name — which depends on what ran before it loaded. Two runs of the same program can bind differently.

Where the loader looks for the file

Before it can resolve symbols, the loader must find the files. That search has its own order, and the order matters because it decides which copy of a library a program gets.

On glibc the sequence is: DT_RPATH recorded in the binary (deprecated, and notably it takes precedence over LD_LIBRARY_PATH), then LD_LIBRARY_PATH from the environment, then DT_RUNPATH recorded in the binary (which is checked *after* the environment variable, which is the whole reason it replaced RPATH), then the cache in /etc/ld.so.cache, then the default system directories.

The RPATH-versus-RUNPATH distinction is the practically important one. RPATH outranks the environment, so a binary with an RPATH cannot be redirected by LD_LIBRARY_PATH — which is either a security property or an operational obstruction depending on who you ask. RUNPATH is consulted after the environment, so it acts as a default that can be overridden. Modern linkers emit RUNPATH by default and --disable-new-dtags restores the old behavior.

Both support $ORIGIN, which expands to the directory containing the object. -Wl,-rpath,'$ORIGIN/../lib' is the standard way to ship an application with its own libraries in a relocatable directory tree, and it is what almost every bundled desktop application does.

The library search order on glibc, and how to inspect it
11. DT_RPATH in the binary (deprecated; beats LD_LIBRARY_PATH)
22. LD_LIBRARY_PATH (environment)
33. DT_RUNPATH in the binary (modern; loses to LD_LIBRARY_PATH)
44. /etc/ld.so.cache (built by ldconfig)
55. /lib, /usr/lib, and the arch-specific variants
6
7$ readelf -d app | grep -E 'RPATH|RUNPATH'
8 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/../lib]
9
10$ LD_DEBUG=libs ./app 2>&1 | head
11 find library=libfoo.so.1 [0]; searching
12 search path=/opt/app/bin/../lib (RUNPATH from file=./app)
13 trying file=/opt/app/bin/../lib/libfoo.so.1
14 ...

LD_DEBUG=libs is the definitive answer to "which copy is it actually loading" and it prints every directory tried, in order, with the reason each was searched. Reasoning about the search order from documentation is much slower and less reliable than one run with this variable set.

Interposition: the feature and the attack

typicalThat the loader drops LD_PRELOAD and LD_LIBRARY_PATH for set-user-ID binaries is typical of glibc and musl on Linux and is a narrow mitigation covering only privilege transitions. It does nothing for a daemon whose environment is set by a configuration file an attacker can edit, and macOS additionally disables insertion for hardened runtimes and SIP-protected binaries, which is broader but still not universal.

Because the scope is scanned front to back and LD_PRELOAD inserts near the front, you can replace any function in any library without recompiling anything. This is a legitimately excellent technique. Memory debuggers replace malloc and free. Profilers wrap pthread_create. faketime replaces gettimeofday. Sanitizer runtimes, ltrace, and countless one-off debugging shims all work this way.

It is also, viewed from a different angle, arbitrary code execution in someone else's process controlled by an environment variable. If an attacker can set the environment of a process they cannot otherwise control — through a service manager's configuration, a CGI environment, a container spec, a sudo rule that preserves the environment — they can inject a library that runs their code in that process. The loader ignores LD_PRELOAD and LD_LIBRARY_PATH for set-user-ID binaries for exactly this reason, and that mitigation is narrow: it protects privilege *transitions*, not processes that are simply more privileged than the attacker's shell.

The same reasoning applies to search paths. A writable directory early in a program's RUNPATH, or a relative path in it, is a place an attacker can put a library that will be loaded in preference to the real one. This is a routine finding in security reviews of packaged applications, and it is the native-code analogue of a dependency-confusion attack: the resolution mechanism prefers something an attacker controls over the thing you meant.

The general defensive posture: do not put writable or relative directories on any search path, prefer RUNPATH with $ORIGIN over ambient environment variables, use full RELRO so the GOT cannot be rewritten after load, and on platforms that offer it prefer a two-level namespace so that a matching name in the wrong library is not a match at all.

  • LD_PRELOAD=./shim.so ./app inserts a library ahead of every dependency; every reference in the process binds to its definitions first.
  • Set-user-ID and set-group-ID binaries ignore the unsafe environment variables, which protects privilege transitions and nothing else.
  • A writable or relative directory on a search path is a code-execution vector, and is a standard finding in reviews of packaged software.
  • Full RELRO (-Wl,-z,relro,-z,now) resolves everything at load and makes the GOT read-only, removing the post-load hijack surface.
  • macOS's two-level namespace and Windows' per-DLL import tables both make accidental interposition structurally much harder.

When the wrong definition wins by accident

The failure mode that is not an attack is more common and harder to spot. Two unrelated libraries define a symbol with the same name — init, parse, log_message, hash are all real examples — and the flat namespace resolves both to whichever came first. One library then calls the other's function, with the other's expectations about arguments and state.

The symptom is a crash or a wrong result inside a library that has nothing wrong with it, appearing only when a particular combination of dependencies is loaded, and often only in a particular order. Add an unrelated dependency and the problem appears or disappears. This is genuinely one of the hardest bug classes in native software, and the tell is that LD_DEBUG=bindings shows a symbol binding to an object nobody expected.

The fixes are all about not participating in the shared namespace. Hidden visibility keeps internal symbols out of the dynamic table entirely, which is the single most effective measure — see [[shared-libraries]]. -Bsymbolic or -Bsymbolic-functions makes a library bind its own references to its own definitions at link time, so it cannot be interposed against itself. A version script exports only what is intended. And prefixing public symbols with a library-specific prefix is the C answer to namespaces, unglamorous and effective.

How it works

The steps, in the order the compiler takes them.

  • The loader builds the search scope: the executable, then preloaded objects, then the dependency graph breadth-first.
  • For each library to be loaded, it searches DT_RPATH, then LD_LIBRARY_PATH, then DT_RUNPATH, then /etc/ld.so.cache, then the default directories, expanding $ORIGIN where present.
  • Each loaded object's exported dynamic symbols are added to the global scope in load order.
  • For each undefined symbol, the loader scans the scope front to back and binds to the first definition it finds, recording the result in the GOT.
  • Under lazy binding this scan happens on first call rather than at load, using the PLT stub as the hook.
  • An object loaded later with dlopen gets its own local scope and is appended to the global one, so it can supply new definitions but cannot displace bindings already made.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A program picks up the wrong copy of a library because a directory earlier in the search order contains a stale one, and the version it reports is not the version it is running.
  • Two unrelated libraries export the same common name, one binds to the other's definition, and a crash appears inside library code that is not at fault — reproducing only with a particular set of dependencies loaded.
  • A shim set via LD_PRELOAD in a shell profile is inherited by unrelated processes, and something breaks in a service that nobody connected to a debugging session weeks earlier.
  • A binary with an RPATH cannot be redirected with LD_LIBRARY_PATH and the operator concludes the variable is broken, when the binary is simply outranking it.
  • A packaged application ships a RUNPATH containing a world-writable or relative directory, and any local user can get code loaded into it.
  • A plugin loaded with dlopen provides a definition that is never used, because everything that needed that symbol had already been bound before the plugin arrived.
  • Behavior differs between two machines with identical binaries because one has LD_LIBRARY_PATH set in a profile nobody remembers writing.

When it helps

  • Debugging and instrumentation without rebuilding: allocator replacement, syscall tracing, fault injection, time faking, sanitizer runtimes.
  • Diagnosing "it works on my machine": the resolution order is a complete explanation for a large class of these, and LD_DEBUG produces it in one run.
  • Shipping an application with private copies of its libraries, using $ORIGIN so the tree can be installed anywhere.

When it hurts

  • As a production extension mechanism. Interposition depends on load order and an unchecked contract, and works until something else in the process claims the same name.
  • In any environment where the search path or the environment is not fully controlled, since the binding then depends on state that is not part of the artifact.

What it costs

Every one of these is paid by something.

  • A flat global namespace buys interposition, allocator replacement and instrumentation without rebuilding, and costs accidental collisions between unrelated libraries plus a real code-execution surface.
  • RPATH outranking the environment buys resistance to redirection and costs the operator's ability to substitute a library for legitimate reasons; RUNPATH makes the opposite trade, which is why it replaced it.
  • Hidden visibility and -Bsymbolic buy immunity from accidental interposition and cost the ability to interpose deliberately — including the debugging techniques you may later want.
  • Full RELRO buys a read-only GOT and removes the post-load hijack surface, and costs eager resolution of every symbol at start-up whether it is used or not.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A two-level namespace, as macOS uses: each import records its expected library, so a matching name elsewhere is not a match. Accidental interposition largely disappears and deliberate interposition needs explicit support.
  • Per-DLL import tables, as Windows uses: resolution is per-consumer with no global scope, so collisions between unrelated DLLs cannot occur.
  • Symbol prefixing and version scripts, which reduce the surface participating in the shared namespace to exactly the intended API.
  • Static linking, which removes the question entirely at the cost of everything in [[static-linking]]'s bill.
  • Isolated loading — dlmopen on glibc, separate namespaces — which loads an object into its own link namespace so its dependencies do not share the main scope. Powerful, rarely used, and full of sharp edges around allocators and thread-local storage.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Which file was chosen and why: LD_DEBUG=libs ./app prints every directory searched and the reason it was searched.
  • Which definition won: LD_DEBUG=bindings ./app prints every symbol binding with the providing object — this is the definitive answer to accidental interposition.
  • What paths are recorded in the binary: readelf -d app | grep -E 'RPATH|RUNPATH', and chrpath or patchelf to change them.
  • What is exported and could therefore collide: nm -DC lib.so, and compare before and after -fvisibility=hidden.
  • Hardening posture: checksec --file=app for RELRO and PIE. On macOS, DYLD_PRINT_LIBRARIES=1 and otool -l for the load commands and rpaths.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "LD_LIBRARY_PATH always wins." DT_RPATH outranks it. That is the entire reason DT_RUNPATH was introduced and is now the default.
  • "Symbol collisions cause a link error." Only for two strong definitions in a static link. At load time the first in scope simply wins, silently.
  • "LD_PRELOAD only affects the program I run it on." It is inherited by every child process, which is why setting it in a shell profile causes bugs weeks later in unrelated services.
  • "Dropping privileges protects against preloading." The loader drops the unsafe variables only across a set-user-ID transition. A daemon whose environment an attacker can edit gets no such protection.

Misconceptions

The claim, and what is actually true.

Each library gets its own copy of the functions it uses.
Resolution is per-process. The first definition in scope serves every object, including libraries that were never tested against it.
Interposition requires special support in the target library.
It requires nothing from the target at all — that is precisely what makes it both useful and dangerous on a flat namespace.
A dlopened plugin overrides earlier definitions.
It is appended to the scope. Symbols already bound stay bound, so whether its definitions are used depends on execution history.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

When a program starts, the loader collects all the libraries into one list and, for each name it needs, takes the first definition it finds in that list. Nothing checks whether that was the intended one. LD_PRELOAD uses this on purpose to slip a library in front of everything else, which is how memory debuggers and profilers work — and is also why a program's behavior can depend on an environment variable.

practical

When a program picks up the wrong library or the wrong function, run it once with LD_DEBUG=libs,bindings and read the answer directly. For your own libraries, build with -fvisibility=hidden so internal names cannot collide, and set RUNPATH with $ORIGIN rather than relying on LD_LIBRARY_PATH. Never put a relative or writable directory on a search path, and treat any LD_PRELOAD in a shell profile as a bug waiting for a different process to find.

advanced

The security shape of this is worth naming precisely, because it recurs far beyond linking: a resolution mechanism that prefers something an attacker can influence over the thing the author intended is a code-execution primitive, whether the mechanism is a library search path, a package registry, a PATH lookup or an import resolver. The native-code version has been understood for decades and the mitigations are mature — drop unsafe variables across privilege transitions, refuse relative paths, prefer per-consumer resolution over a global scope. The same mistake is being made again in every new package ecosystem, which is why dependency confusion keeps working. The structural fix, in every case, is the one macOS and Windows already made here: resolve against a named source rather than against whatever happens to be first.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationThe scope order, the RPATH/LD_LIBRARY_PATH/RUNPATH sequence and LD_DEBUG are glibc specifics, with musl close but not identical. macOS uses a two-level namespace with @rpath, @executable_path and @loader_path and restricts insertion under SIP and hardened runtimes; Windows uses a documented DLL search order with no global symbol scope. None of the mitigation advice transfers directly.
typicalThat set-user-ID binaries ignore LD_PRELOAD and LD_LIBRARY_PATH is typical of Linux loaders and covers only privilege transitions. It does not protect a service whose environment comes from a unit file, a container spec or a CGI configuration an attacker can influence, which is where this surface is usually exploited in practice.
targetAccidental interposition between unrelated libraries is an ELF-specific hazard created by the flat namespace. On Mach-O each import names its library and on PE each import comes from a named DLL, so two libraries exporting init simply do not interfere. A codebase that is correct on Windows can be subtly wrong on Linux for exactly this reason.

If you were asked this in an interview

  • Two loaded libraries define hash. Which one does a third library get, and what decides it?
  • Why does LD_LIBRARY_PATH sometimes have no effect on which library is loaded?
  • Explain how LD_PRELOAD works, and why it is both a debugging tool and a security concern.

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Environment as configuration, and what it means for a process to be reproducible
    A binding decided by an environment variable is a configuration input that no artifact records. Whether the deployment makes that environment explicit and immutable decides whether a bug found in production can be reproduced anywhere else.