thoroughly
back to writing
2026 · 08 · 11 · 4 min read · compilersdynamic-linking

Replacing a Function You Didn't Write: How LD_PRELOAD Gets There First

Using LD_PRELOAD to make a program sleep five times longer without touching its source, and why the same trick fails the moment the binary is statically linked.

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.