Articles / Linux

  • linux
  • setuid
  • ld-preload
  • dynamic-linking
  • security
  • glibc

Why a Setuid Binary Ignores LD_PRELOAD, and What Replaces It

You set LD_PRELOAD, run a setuid program, and your library never loads. No error, no warning; it carries on as if the variable did not exist. That silence is deliberate, and the reason is the whole security model of setuid in one small mechanism.

Seeing it happen

Here is a tiny library whose only job is to announce itself when it loads.

/* hook.c: build with  gcc -shared -fPIC -o hook.so hook.c */
#include <unistd.h>

__attribute__((constructor))
static void hello(void) {
    static const char msg[] = "hook.so loaded\n";
    write(2, msg, sizeof msg - 1);
}

Try it on an ordinary binary, then on a setuid one (passwd is setuid root on most distributions; check with ls -l /usr/bin/passwd).

$ LD_PRELOAD=./hook.so true
hook.so loaded
$ LD_PRELOAD=./hook.so passwd --help
(usage text, no "hook.so loaded")

The loader skipped your library entirely. Had it not, you could run arbitrary code as root by writing a ten-line library and typing one command.

Why the environment is the problem

A preloaded library runs inside the target process, with that process's privileges, before main. For a normal program that only lets you attack yourself. For a setuid-root program, the process has root's identity but the environment came from you, the unprivileged caller.

So the dynamic loader treats the environment as hostile whenever the privileges changed at exec time. The same logic covers LD_LIBRARY_PATH, LD_AUDIT, and a pile of other variables that change where code or data is loaded from.

Who decides: the kernel, not the loader

The loader does not look at the file mode itself. When the kernel execs a binary whose credentials will change (setuid, setgid, or file capabilities), it sets AT_SECURE in the auxiliary vector it hands to the new process. The loader reads that and enters what ld.so(8) calls secure-execution mode.

Quick detour, because this is neat: you can see the value on any unprivileged run.

$ LD_SHOW_AUXV=1 /bin/true | grep SECURE
AT_SECURE:       0

Your own code can ask too, with getauxval(AT_SECURE). glibc's own secure_getenv() uses the same flag, which is why a well-written library calls that instead of getenv() for anything path-like. Back to the main thread.

What secure-execution mode actually does

Going by ld.so(8), the behaviour is roughly this:

  • LD_LIBRARY_PATH is ignored.
  • LD_PRELOAD entries containing a slash are ignored; bare names are searched only in the standard directories, and the library must itself be setuid to be accepted.
  • Variables such as LD_AUDIT, LD_DEBUG and LD_PROFILE are removed or restricted.
  • glibc also strips other variables that could redirect file loading (locale and iconv paths, for example).
  • $ORIGIN expansion in RPATH is restricted to trusted directories.

Note the one exception. A library in a standard directory with the setuid bit is accepted, but only root can put one there, so that is root choosing to trust it, not you.

What does not stop working

This only applies to a process that started privileged. A process that is root from the beginning, such as a daemon you run with sudo, honours LD_PRELOAD fine, because nothing changed at exec. Equally, /etc/ld.so.preload still applies to setuid programs: it is root-owned, so it is trusted.

Also worth knowing: a statically linked binary has no loader to preload into, so LD_PRELOAD does nothing there either, setuid or not. Go binaries with cgo disabled are the common case.

What to use instead

It depends on why you wanted the preload.

  • Debugging or tracing a setuid tool: run it as root (sudo strace, sudo gdb), or use ltrace, or attach eBPF uprobes. Root can do all of this directly, with no loader trick needed. Running a setuid binary under an unprivileged strace deliberately ignores the setuid bit.
  • A deliberate system-wide hook: list the library in /etc/ld.so.preload. It is root-controlled, so the trust boundary is respected. It affects every dynamically linked program, so test carefully; a broken entry there can make the whole system noisy or unusable.
  • Replacing a function in a program you build: link-time wrapping with -Wl,--wrap=symbol, or simply link your own implementation. No environment variable involved.
  • A library in a non-standard place: bake the path in at link time with RUNPATH, or install to a standard directory, instead of LD_LIBRARY_PATH.
  • Fake-root style tools (fakeroot and friends): these preload into unprivileged processes, which works. Do not expect them to intercept a genuinely setuid helper.

Testing your own setuid code

If you write a setuid helper, assume the loader's filtering is a backstop, not your only defence. Clear or rebuild the environment yourself before running anything, avoid system() and PATH lookups, and prefer capabilities or a small privileged daemon to a setuid binary where you can. The loader protects you from LD_PRELOAD; it does nothing about PATH, IFS, or the arguments you were handed.