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_PATHis ignored.LD_PRELOADentries 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_DEBUGandLD_PROFILEare removed or restricted. - glibc also strips other variables that could redirect file loading (locale and iconv paths, for example).
$ORIGINexpansion 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 useltrace, or attach eBPF uprobes. Root can do all of this directly, with no loader trick needed. Running a setuid binary under an unprivilegedstracedeliberately 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.