Preloading Shared Libraries LD_PRELOAD · Function Interposition · /etc/ld.so.preload

 

42.5 — Preloading Shared Libraries
LD_PRELOAD · Function Interposition · /etc/ld.so.preload | Chapter 42 · TLPI
EmbeddedPathashala.com — Free Embedded & Linux Tutorials

What Is LD_PRELOAD?

The dynamic linker on Linux supports an environment variable called LD_PRELOAD that lists one or more shared libraries to be loaded before all other libraries — even before libc. Because the dynamic linker resolves symbols in load order (first match wins), any function you define in a preloaded library overrides (interposes on) the same-named function in all other libraries.

This powerful mechanism lets you change the behavior of any program without modifying its source code, recompiling it, or even having root access to the system (for per-process use). It is widely used for:

  • Debugging: logging all malloc/free calls to track memory usage
  • Testing: replacing network calls with mock implementations
  • Security research: auditing system calls or library usage
  • Performance profiling: measuring time spent in specific library functions
  • Bug workarounds: patching a buggy library function without recompiling
Key Concepts

LD_PRELOAD /etc/ld.so.preload Symbol Interposition RTLD_NEXT Function Wrapping setuid restriction Load Order malloc override -ldl in preload lib

How LD_PRELOAD Works — Symbol Resolution Order

The dynamic linker searches for symbols in this order:

1. LD_PRELOAD libraries (searched FIRST)
↓ if not found
2. Main program’s own symbols
↓ if not found
3. Libraries listed in the program’s ELF DT_NEEDED
↓ if not found
4. libc.so, libm.so, and other standard libraries

Since LD_PRELOAD libraries are searched first, any function they define shadows the same function in libc or any other library. The original function is still accessible via RTLD_NEXT.

Two ways to specify LD_PRELOAD:

# Per-process: set for one command only
LD_PRELOAD=./mywrapper.so ./my_program

# Per-process: set in the shell environment (affects all subsequent commands)
export LD_PRELOAD=./mywrapper.so
./my_program
unset LD_PRELOAD

# Multiple libraries: colon or space separated
LD_PRELOAD="./lib1.so:./lib2.so" ./my_program

# System-wide: affects ALL programs on the system (root access needed)
echo "/path/to/mywrapper.so" >> /etc/ld.so.preload
Security restriction: The dynamic linker completely ignores LD_PRELOAD for setuid or setgid programs. This is a critical security measure — without it, any user could override malloc() or security-checking functions in a setuid root binary to escalate privileges.

Code Example 1: Tracking malloc/free Calls

This is a classic LD_PRELOAD use case — intercept every malloc and free call to log them for debugging:

/* malloc_trace.c — intercepts malloc and free for debugging */
#define _GNU_SOURCE          /* Required for RTLD_NEXT */
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

/* Pointers to the real malloc and free from libc */
static void *(*real_malloc)(size_t) = NULL;
static void  (*real_free)(void *)   = NULL;

/* Initialize pointers to real functions on first use */
static void init_real_funcs(void) {
    if (!real_malloc) {
        dlerror();
        *(void **)(&real_malloc) = dlsym(RTLD_NEXT, "malloc");
        if (dlerror()) {
            fprintf(stderr, "[TRACE] Cannot find real malloc!\n");
            _exit(1);
        }
    }
    if (!real_free) {
        dlerror();
        *(void **)(&real_free) = dlsym(RTLD_NEXT, "free");
        if (dlerror()) {
            fprintf(stderr, "[TRACE] Cannot find real free!\n");
            _exit(1);
        }
    }
}

/* Our malloc override */
void *malloc(size_t size) {
    init_real_funcs();
    void *ptr = real_malloc(size);
    /* Use write() instead of printf to avoid malloc recursion */
    fprintf(stderr, "[TRACE] malloc(%zu) = %p\n", size, ptr);
    return ptr;
}

/* Our free override */
void free(void *ptr) {
    init_real_funcs();
    fprintf(stderr, "[TRACE] free(%p)\n", ptr);
    real_free(ptr);
}
# Build as a shared library
gcc -fPIC -shared -o malloc_trace.so malloc_trace.c -ldl

# Test: run ls with malloc/free tracing
LD_PRELOAD=./malloc_trace.so ls /tmp 2>&1 | head -20

# Sample output:
# [TRACE] malloc(472) = 0x55a1b2c3d4e0
# [TRACE] malloc(120) = 0x55a1b2c3f5a0
# [TRACE] malloc(1024) = 0x55a1b2c40010
# ... (many more)
# (actual ls output appears after)
Important: Be careful about calling printf() or fprintf() inside a malloc override — printf itself calls malloc internally, which would cause infinite recursion! In production code, use a static buffer and write() instead, or track a “in_trace” flag to detect and skip recursive calls.

Code Example 2: Timing Wrapper for Any Function

/* time_open.c — measures how long open() takes */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <fcntl.h>
#include <time.h>
#include <stdarg.h>

static int (*real_open)(const char *, int, ...) = NULL;

static void init_real_open(void) {
    if (!real_open) {
        dlerror();
        *(void **)(&real_open) = dlsym(RTLD_NEXT, "open");
        if (dlerror()) _exit(1);
    }
}

/* Override open() to measure its duration */
int open(const char *pathname, int flags, ...) {
    init_real_open();

    mode_t mode = 0;
    if (flags & O_CREAT) {
        va_list ap;
        va_start(ap, flags);
        mode = va_arg(ap, int);
        va_end(ap);
    }

    struct timespec t1, t2;
    clock_gettime(CLOCK_MONOTONIC, &t1);

    int fd = real_open(pathname, flags, mode);

    clock_gettime(CLOCK_MONOTONIC, &t2);

    long ns = (t2.tv_sec - t1.tv_sec) * 1000000000L + (t2.tv_nsec - t1.tv_nsec);
    fprintf(stderr, "[TIMER] open(\"%s\") took %ld ns, fd=%d\n", pathname, ns, fd);

    return fd;
}
gcc -fPIC -shared -o time_open.so time_open.c -ldl
LD_PRELOAD=./time_open.so cat /etc/hostname

# Output (stderr):
# [TIMER] open("/etc/hostname") took 12453 ns, fd=3
# (then hostname is printed on stdout)

Code Example 3: Mock connect() for Unit Testing

/* mock_connect.c — replaces connect() with a mock for testing */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>

static int (*real_connect)(int, const struct sockaddr *, socklen_t) = NULL;

static void init(void) {
    if (!real_connect) {
        dlerror();
        *(void **)(&real_connect) = dlsym(RTLD_NEXT, "connect");
    }
}

/*
 * Mock connect(): For testing purposes, pretend every connection
 * to 192.168.1.100 succeeds instantly (useful in CI/CD environments
 * where the real server is not available).
 */
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
    init();

    if (addr->sa_family == AF_INET) {
        struct sockaddr_in *in = (struct sockaddr_in *)addr;
        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &in->sin_addr, ip, sizeof(ip));
        int port = ntohs(in->sin_port);

        if (in->sin_addr.s_addr == inet_addr("192.168.1.100")) {
            fprintf(stderr, "[MOCK] connect() to %s:%d — MOCKED SUCCESS\n", ip, port);
            return 0; /* pretend success */
        }
    }

    /* For all other addresses, call the real connect() */
    return real_connect(sockfd, addr, addrlen);
}
gcc -fPIC -shared -o mock_connect.so mock_connect.c -ldl

# Run your program with the mock, without changing any source code:
LD_PRELOAD=./mock_connect.so ./my_server_program
Real-world use: Tools like Valgrind, AddressSanitizer, and tcmalloc all use a variant of LD_PRELOAD interposition. The faketime utility uses this to make programs believe they are running at a different date/time.

System-Wide Preloading: /etc/ld.so.preload

/etc/ld.so.preload is a text file listing shared libraries to be preloaded for every process on the system, one path per line. Unlike LD_PRELOAD, it applies even to setuid programs (because it is set by root).

# /etc/ld.so.preload — affects ALL processes system-wide
/usr/lib/libmymalloc.so
/usr/lib/libsecurity_audit.so
Use with extreme care: A bug in a library listed in /etc/ld.so.preload can crash every process on the system — including the login shell. Always test thoroughly in a VM before deploying system-wide. If /etc/ld.so.preload contains a broken path, the system may become unusable on next boot.
Method Scope Works with setuid? Root needed?
LD_PRELOAD env var Per-process (one command) No — ignored No
/etc/ld.so.preload System-wide (all processes) Yes Yes

Interview Questions & Answers

Q1. What is LD_PRELOAD and how does it achieve function interposition?
LD_PRELOAD is an environment variable that lists shared libraries to be loaded before all other libraries when a program starts. The dynamic linker resolves symbol references in load order, using the first matching definition it finds. Because LD_PRELOAD libraries are loaded first, any function they define shadows the same-named function in all subsequently loaded libraries — including libc. The original function is still accessible inside the preloaded library using dlsym(RTLD_NEXT, “function_name”).
Q2. Why is LD_PRELOAD ignored for setuid programs?
When a setuid-root binary runs, it executes with elevated privileges. If LD_PRELOAD worked for setuid programs, any unprivileged user could override security functions like getuid(), access(), or auth_check() in the setuid binary with their own versions, effectively bypassing privilege checks and gaining root access. The dynamic linker therefore ignores LD_PRELOAD when it detects the program is running setuid or setgid.
Q3. What is the role of RTLD_NEXT inside an LD_PRELOAD wrapper function?
RTLD_NEXT is a pseudohandle for dlsym() that searches for a symbol starting in the libraries loaded after the calling library. Inside an LD_PRELOAD wrapper that overrides malloc(), calling dlsym(RTLD_NEXT, “malloc”) finds the real malloc in libc — the next one in load order after the preloaded library. Without RTLD_NEXT, the wrapper could not call the original function, making it impossible to augment (rather than completely replace) the original behavior.
Q4. What is the difference between LD_PRELOAD and /etc/ld.so.preload?
LD_PRELOAD is an environment variable set per-process or per-shell session. It requires no special privileges but is ignored for setuid programs. /etc/ld.so.preload is a system-wide configuration file that lists libraries to preload for every process on the system. It requires root access to modify but applies even to setuid programs. Mistakes in /etc/ld.so.preload can destabilize the entire system since every process is affected.
Q5. What is a common pitfall when wrapping malloc() with LD_PRELOAD?
Calling printf() or fprintf() inside a malloc() wrapper causes infinite recursion. printf() internally calls malloc() to allocate its format buffer, which calls your wrapper again, which calls printf(), and so on until the stack overflows. The solution is to either use write() with a static pre-allocated buffer for logging, or maintain a thread-local or global flag that prevents the wrapper from logging while it is already inside a logging call.
Q6. Name three legitimate real-world use cases for LD_PRELOAD.
First, memory debugging tools like Valgrind and AddressSanitizer use it to replace malloc/free with instrumented versions that detect leaks and buffer overflows. Second, the faketime utility preloads a library that intercepts time(), gettimeofday(), and clock_gettime() to make programs believe they are running at a different date or time — useful for testing date-sensitive code. Third, tcmalloc (Google’s thread-cached malloc) can be preloaded to replace glibc malloc with a faster, more scalable allocator in production applications without recompiling them.

Leave a Reply

Your email address will not be published. Required fields are marked *