Say you have a program that calls sleep(1). You don’t have the source, and you don’t want to rebuild it, but you want it to sleep for five seconds instead.
You can do that, and it takes about ten lines.
The program
cat > sleeper.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main() {
printf("before\n");
sleep(1);
printf("after\n");
return 0;
}
EOF
Compile it into a program called sleeper, then run it.
$ gcc -o sleeper sleeper.c
$ time ./sleeper
before
after
real 0m1.024s
user 0m0.012s
sys 0m0.005s
It prints its two lines and takes about a second. time measures how long a command takes; the number to watch is real, wall clock time, the one a stopwatch would show.
The replacement
Now a second file. This one is not a program, it’s a shared library, and it defines a single function: a sleep with the same name and signature as the real one.
cat > fake.c <<'EOF'
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
unsigned int sleep(unsigned int seconds) {
fprintf(stderr, "[intercepted] sleep(%u) -> sleeping %u\n", seconds, seconds * 5);
unsigned int (*real)(unsigned int) = dlsym(RTLD_NEXT, "sleep");
return real(seconds * 5);
}
EOF
The important line is the dlsym one. dlsym(RTLD_NEXT, "sleep") means “keep searching past me and find the next function called sleep.”
You need it because you can’t just call sleep(...) from inside your own sleep. That name now means your function, so it would call itself forever. RTLD_NEXT skips your definition and finds the real one, so you can still delegate to it after changing the argument.
Build it as a shared library.
gcc -shared -fPIC -o fake.so fake.c -ldl
-shared produces a .so instead of a program, so there’s no main and nothing to run on its own. -fPIC compiles it as position-independent code, which shared libraries require because they get loaded at a different address every time. -ldl links the library that provides dlsym, though on recent systems dlsym has moved into libc and this flag does nothing.
Running it
Now run the same program, with fake.so loaded in front of it.
$ time LD_PRELOAD=./fake.so ./sleeper
before
[intercepted] sleep(1) -> sleeping 5
after
real 0m5.050s
user 0m0.017s
sys 0m0.019s
Five seconds. The program was not rebuilt and sleeper.c was not edited.
LD_PRELOAD isn’t a command. It’s an environment variable the dynamic linker reads when a program starts. It says: load this library first, and search it first when looking up function names.
So when sleeper starts, the dynamic linker resolves sleep to an address. It searches in load order, finds mine before it gets to libc, and stops there. The real sleep is still loaded, still present in the process. It’s just further down the list.
That’s the whole mechanism. First match wins, and LD_PRELOAD puts you first.
The loader matches on name alone, so the matching signature isn’t what makes it find your function. The signature matters for what happens after that: same arguments in, same type out, so the calling code still works.
Where it stops working
None of this touches the program’s code. It works because the address of sleep was never in the code to begin with. When sleeper was built, the linker only recorded that it needs sleep from libc. The actual address gets filled in at startup, by the same dynamic linker that reads LD_PRELOAD.
So if the address is decided at startup, something can change what goes there. That something is LD_PRELOAD.
Which suggests the obvious test: build the same program with everything baked in at compile time, and see whether the trick still works.
$ gcc -static -o sleeper-static sleeper.c
$ ldd sleeper-static
not a dynamic executable
-static tells the linker to copy the library code into the binary instead of leaving a reference to it. ldd lists the shared libraries a program needs at runtime, and this one needs none.
Now run the same interception against it.
$ time LD_PRELOAD=./fake.so ./sleeper-static
before
after
real 0m1.013s
One second. No intercepted line, no delay. Same library, same command, only the program changed.
The reason is that sleep’s address is inside the instruction. The linker wrote it there when the program was built, and the program never asks anyone where sleep is. There’s no lookup at startup, so there’s nothing for LD_PRELOAD to get in front of.
That’s the boundary. If the address is decided when the program starts, you can change it. If it was decided when the program was built, you can’t.
I wrote about what “decided when the program was built” actually looks like, byte by byte, in What the Compiler Leaves Blank.