LD_PRELOAD explained
LD_PRELOAD is an environment variable on Unix-like operating systems that allows users to specify a shared library to be loaded before others when a program is executed. This can be used to override functions in existing libraries or to inject custom code into applications. This functionality is provided by system dynamic linker and works only for dynamically linked executables. Statically linked binaries skip this entirely. This post will be focused on Linux and BSDs. The dynamic linker First, let's see what the dynamic linker is. When a dynamically linked program is launched, before even calling the main function of the program a few things are done. One of the first things that the operating system does is that it reads the path of the dynamic linker from the executable image and then execute that as a program. This has to succeed or the whole program execution fails. When dynamic loader is executed, it loads the program image and all the dynamically loaded shared libraries that the executed program needs, and then starts the execution. So basically, the dynamic linker is another program that handles execution of all other dynamically executed programs on the system. On Linux the dynamic linker will be something like ld-linux.so, e.g. on my system it's /lib64/ld-linux-x86-64.so.2. On FreeBSD it will be something like /libexec/ld-elf.so.1 LD_PRELOAD So, how does LD_PRELOAD work? It basically adds a list of shared libraries to be loaded by the dynamic linker after the program image is loaded, but before its shared object dependencies are loaded. This way, when a particular function symbol name is looked for, it will be found in preloaded shared object first. This is how function overriding is achieved. Examples Zlibc Zlibc, also known as uncompress.so - facilitates transparent decompression when used through the LD_PRELOAD. It is therefore possible to read compressed (gzipped) files data as if they were uncompressed, essentially adding support for reading compressed files to any application. Overriding malloc The following is a simple example of a library that overrides malloc function. It uses dlsym(RTLD_NEXT, "malloc") to look up the real malloc that is being overridden and counts the number of all allocations. #define _GNU_SOURCE #include #include #include size_t malloc_count = 0; void *malloc(size_t size) { static void *(*real_malloc)(size_t) = NULL; if (!real_malloc) { real_malloc = dlsym(RTLD_NEXT, "malloc"); } void *p = real_malloc(size); malloc_count++; return p; } Note that you need to be careful when adding custom code to malloc, not to call itself recursively, which might be easier than you think. This example is not very exciting, so let's take a look at something more interesting. Overriding read and write Another example would be a library that overrides read and write calls to add a sleep before doing the actual work: This example comes from crawlio This time the actual implementation of read and write is loaded in the constructor function. This is a function that is executed just after the library is loaded. This way the overloaded functions are loaded only once. #define _GNU_SOURCE #include #include #include typedef ssize_t (*orig_read_t)(int fd, void *buf, size_t count); typedef ssize_t (*orig_write_t)(int fd, const void *buf, size_t count); static orig_read_t original_read = NULL; static orig_write_t original_write = NULL; static void sleep_ms(unsigned int ms) { if (ms > 0) { struct timespec ts; ts.tv_sec = ms / 1000; ts.tv_nsec = (ms % 1000) * 1000000; nanosleep(&ts, NULL); } } __attribute__((constructor)) static void init() { original_read = (orig_read_t)dlsym(RTLD_NEXT, "read"); if (!original_read) { fprintf(stderr, "Error loading original read function: %s\n", dlerror()); } original_write = (orig_write_t)dlsym(RTLD_NEXT, "write"); if (!original_write) { fprintf(stderr, "Error loading original write function: %s\n", dlerror()); } } ssize_t read(int fd, void *buf, size_t count) { sleep_ms(200); return original_read(fd, buf, count); } ssize_t write(int fd, const void *buf, size_t count) { sleep_ms(200); return original_write(fd, buf, count); } First, compile the code into a shared library: $ gcc -shared -fPIC -o my_readwrite.so my_readwrite.c -ldl Then run a program with the library preloaded: LD_PRELOAD=./my_readwrite.so find /tmp You should notice that find prints results slower than normally. Use Cases testing - functions can be overridden to introduce arbitrary slow downs or random failures. hot-fixes - shared library can be selectively overridden to change particular behaviour without rebuilding. observability - this technique ca

LD_PRELOAD is an environment variable on Unix-like operating systems that allows users to specify a shared library to be loaded before others when a program is executed. This can be used to override functions in existing libraries or to inject custom code into applications. This functionality is provided by system dynamic linker and works only for dynamically linked executables. Statically linked binaries skip this entirely.
This post will be focused on Linux and BSDs.
The dynamic linker
First, let's see what the dynamic linker is.
When a dynamically linked program is launched, before even calling the main
function of the program a few things are done. One of the first things that the operating system does is that it reads the path of the dynamic linker from the executable image and then execute that as a program. This has to succeed or the whole program execution fails. When dynamic loader is executed, it loads the program image and all the dynamically loaded shared libraries that the executed program needs, and then starts the execution.
So basically, the dynamic linker is another program that handles execution of all other dynamically executed programs on the system.
On Linux the dynamic linker will be something like ld-linux.so
, e.g. on my system it's /lib64/ld-linux-x86-64.so.2
.
On FreeBSD it will be something like /libexec/ld-elf.so.1
LD_PRELOAD
So, how does LD_PRELOAD
work?
It basically adds a list of shared libraries to be loaded by the dynamic linker after the program image is loaded, but before its shared object dependencies are loaded. This way, when a particular function symbol name is looked for, it will be found in preloaded shared object first. This is how function overriding is achieved.
Examples
Zlibc
Zlibc, also known as uncompress.so - facilitates transparent decompression when used through the LD_PRELOAD. It is therefore possible to read compressed (gzipped) files data as if they were uncompressed, essentially adding support for reading compressed files to any application.
Overriding malloc
The following is a simple example of a library that overrides malloc
function.
It uses dlsym(RTLD_NEXT, "malloc")
to look up the real malloc that is being overridden and counts the number of all allocations.
#define _GNU_SOURCE
#include
#include
#include
size_t malloc_count = 0;
void *malloc(size_t size) {
static void *(*real_malloc)(size_t) = NULL;
if (!real_malloc) {
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
void *p = real_malloc(size);
malloc_count++;
return p;
}
Note that you need to be careful when adding custom code to malloc
, not to call itself recursively, which might be easier than you think.
This example is not very exciting, so let's take a look at something more interesting.
Overriding read and write
Another example would be a library that overrides read
and write
calls to add a sleep before doing the actual work:
This example comes from crawlio
This time the actual implementation of read
and write
is loaded in the constructor function. This is a function that is executed just after the library is loaded. This way the overloaded functions are loaded only once.
#define _GNU_SOURCE
#include
#include
#include
typedef ssize_t (*orig_read_t)(int fd, void *buf, size_t count);
typedef ssize_t (*orig_write_t)(int fd, const void *buf, size_t count);
static orig_read_t original_read = NULL;
static orig_write_t original_write = NULL;
static void sleep_ms(unsigned int ms) {
if (ms > 0) {
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000000;
nanosleep(&ts, NULL);
}
}
__attribute__((constructor))
static void init() {
original_read = (orig_read_t)dlsym(RTLD_NEXT, "read");
if (!original_read) {
fprintf(stderr, "Error loading original read function: %s\n", dlerror());
}
original_write = (orig_write_t)dlsym(RTLD_NEXT, "write");
if (!original_write) {
fprintf(stderr, "Error loading original write function: %s\n", dlerror());
}
}
ssize_t read(int fd, void *buf, size_t count) {
sleep_ms(200);
return original_read(fd, buf, count);
}
ssize_t write(int fd, const void *buf, size_t count) {
sleep_ms(200);
return original_write(fd, buf, count);
}
First, compile the code into a shared library:
$ gcc -shared -fPIC -o my_readwrite.so my_readwrite.c -ldl
Then run a program with the library preloaded:
LD_PRELOAD=./my_readwrite.so find /tmp
You should notice that find
prints results slower than normally.
Use Cases
- testing - functions can be overridden to introduce arbitrary slow downs or random failures.
- hot-fixes - shared library can be selectively overridden to change particular behaviour without rebuilding.
- observability - this technique can be used to non-intrusively gather all sort of metrics and statistics.
- alternative implementations - shared library can provide alternative implementations of overridden functions, e.g.
malloc
. - injecting custom code - preloaded shared libraries can inject arbitrary code on load time via constructor functions.
Other ways to preload a library (Linux only)
- The
LD_PRELOAD
environment variable. What was described in this post above. - The
--preload
command-line option when invoking the dynamic linker directly. The dynamic linker is a normal executable file so it can executed manually like any other program. E.g. instead of executingls
one can do the same by executing dynamic linker and passing the executable as an argument:/lib64/ld-linux-x86-64.so.2 /bin/ls
(full path tols
in required in this case). And this is what actually is being done by the system every time a dynamically linked executable is run. The dynamic linker accepts a few flags, one of them being--preload
and it works the same asLD_PRELOAD
environment variable. - The /etc/ld.so.preload file. This configuration file contains a list of white-space separated full paths to shared libraries that should be automatically preloaded by the dynamic linker. The big difference here is that this method preloads libraries for all processes whereas for the previous methods you could preload libraries only for selected processes.
Security implications
It's important to understand security implications of LD_PRELOAD
. It can be used to override any system call, like open
or execve
and therefore can be used to hijack execution flow or hide malicious behaviour.
Linux provides some mitigations against that.
if the dynamic linker determines that a binary should be run in secure-execution mode (e.g. when executing a set-user-ID or set-group-ID program) preloading is very limited. E.g. pathnames containing slashes are ignored and shared objects are preloaded only from the standard search directories and only if they have set-user-ID mode bit enabled.
On FreeBSD LD_PRELOAD
is ignored altogether for set-user-ID and set-group-ID programs.
Alternatives
Some of the uses described above might be achieved with the use of eBPF, but not al of them. eBPF is much more restricted, but also doesn't introduce the possible security issues of injecting arbitrary code.