Case Study 24-1: Implementing a Memory Leak Detector with LD_PRELOAD

Objective

Build a complete memory leak detection tool using LD_PRELOAD function interposition. The tool intercepts all malloc, calloc, realloc, and free calls, tracks every live allocation, and reports leaks at program exit. Unlike AddressSanitizer (which requires recompilation), this tool works on any existing binary without modification.


Design: Tracking Allocations

The core challenge: for each malloc(size)ptr, we need to remember ptr → size until free(ptr) is called. At exit, any remaining entries in the map are leaks.

For a simple implementation, we use a fixed-size hash table to avoid calling malloc from within our malloc hook (which would cause infinite recursion).

Key Design Decisions

  1. No malloc in the hook: Use a static array for our tracking structure — no heap allocation in the tracking code.
  2. Thread-safe: Use POSIX spinlock for the hash table.
  3. Real functions via dlsym: Call the real malloc/free through function pointers obtained with dlsym(RTLD_NEXT, ...).
  4. Stack trace (optional): backtrace() can capture the call stack at allocation time for better leak attribution.

The Complete Implementation

// leak_detector.c
// Memory leak detector using LD_PRELOAD
//
// Build:
//   gcc -fPIC -shared -o leak_detector.so leak_detector.c -ldl -rdynamic
//
// Use:
//   LD_PRELOAD=./leak_detector.so ./your_program
//
// Output:
//   At program exit, leaked allocations are reported to stderr.

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <dlfcn.h>
#include <execinfo.h>  // for backtrace()
#include <pthread.h>

// ============================================================
// Real allocator function pointers
// ============================================================
static void *(*real_malloc)(size_t)            = NULL;
static void *(*real_calloc)(size_t, size_t)    = NULL;
static void *(*real_realloc)(void *, size_t)   = NULL;
static void  (*real_free)(void *)              = NULL;

// ============================================================
// Tracking Hash Table
// A fixed-size open-addressing hash table to avoid using malloc
// internally. Each slot stores one {ptr, size, backtrace} entry.
// ============================================================
#define TABLE_SIZE  (1 << 16)   // 65536 slots
#define MAX_FRAMES  8           // Backtrace depth

typedef struct {
    void     *ptr;                    // Allocated pointer (NULL = empty slot)
    size_t    size;                   // Allocation size
    void     *frames[MAX_FRAMES];    // Backtrace frames at malloc time
    int       num_frames;            // Number of valid frames
} AllocEntry;

static AllocEntry table[TABLE_SIZE];  // Static allocation — never calls malloc
static pthread_mutex_t table_lock = PTHREAD_MUTEX_INITIALIZER;
static size_t total_leaked   = 0;
static size_t leak_count     = 0;
static size_t total_allocated = 0;
static size_t alloc_count     = 0;

// Hash function: simple pointer hash
static inline uint32_t hash_ptr(void *ptr) {
    uintptr_t p = (uintptr_t)ptr;
    // Knuth multiplicative hash
    p = p * 2654435761UL;
    return (uint32_t)(p >> (64 - 16));  // Top 16 bits
}

// Insert an allocation into the tracking table
static void track_alloc(void *ptr, size_t size) {
    if (!ptr) return;

    uint32_t h = hash_ptr(ptr) & (TABLE_SIZE - 1);

    pthread_mutex_lock(&table_lock);
    // Linear probe for an empty slot
    for (int i = 0; i < TABLE_SIZE; i++) {
        uint32_t idx = (h + i) & (TABLE_SIZE - 1);
        if (table[idx].ptr == NULL) {
            table[idx].ptr  = ptr;
            table[idx].size = size;
            // Capture backtrace (skip 2 frames: track_alloc + our malloc wrapper)
            table[idx].num_frames = backtrace(table[idx].frames, MAX_FRAMES);
            total_allocated += size;
            alloc_count++;
            break;
        }
    }
    pthread_mutex_unlock(&table_lock);
}

// Remove an allocation from the tracking table
static void track_free(void *ptr) {
    if (!ptr) return;

    uint32_t h = hash_ptr(ptr) & (TABLE_SIZE - 1);

    pthread_mutex_lock(&table_lock);
    for (int i = 0; i < TABLE_SIZE; i++) {
        uint32_t idx = (h + i) & (TABLE_SIZE - 1);
        if (table[idx].ptr == ptr) {
            table[idx].ptr = NULL;  // Mark as free
            break;
        }
        if (table[idx].ptr == NULL) break;  // Stop at first empty (not found)
    }
    pthread_mutex_unlock(&table_lock);
}

// ============================================================
// Constructor: Initialize function pointers
// ============================================================
static void init(void) __attribute__((constructor));
static void init(void) {
    // Must use RTLD_NEXT — "next library after me in search order"
    real_malloc  = dlsym(RTLD_NEXT, "malloc");
    real_calloc  = dlsym(RTLD_NEXT, "calloc");
    real_realloc = dlsym(RTLD_NEXT, "realloc");
    real_free    = dlsym(RTLD_NEXT, "free");

    if (!real_malloc || !real_calloc || !real_realloc || !real_free) {
        // Cannot find real allocators — fatal
        const char *msg = "leak_detector: FATAL: cannot find real allocators\n";
        write(2, msg, strlen(msg));
        _exit(1);
    }

    fprintf(stderr, "[leak_detector] Initialized. Tracking allocations...\n");
}

// ============================================================
// Destructor: Report leaks at exit
// ============================================================
static void report_leaks(void) __attribute__((destructor));
static void report_leaks(void) {
    fprintf(stderr, "\n[leak_detector] ============ Leak Report ============\n");
    fprintf(stderr, "[leak_detector] Total allocations: %zu (%zu bytes)\n",
            alloc_count, total_allocated);

    leak_count = 0;
    total_leaked = 0;

    for (int i = 0; i < TABLE_SIZE; i++) {
        if (table[i].ptr != NULL) {
            leak_count++;
            total_leaked += table[i].size;

            fprintf(stderr, "\nLEAK #%zu: %p (%zu bytes)\n",
                    leak_count, table[i].ptr, table[i].size);

            if (table[i].num_frames > 2) {
                // Print backtrace (skip first 2 frames: track_alloc + our malloc)
                char **syms = backtrace_symbols(table[i].frames, table[i].num_frames);
                if (syms) {
                    for (int f = 2; f < table[i].num_frames; f++) {
                        fprintf(stderr, "  #%d %s\n", f - 2, syms[f]);
                    }
                    real_free(syms);  // Free the symbol strings (not tracked)
                }
            }
        }
    }

    fprintf(stderr, "\n[leak_detector] =====================================\n");
    if (leak_count == 0) {
        fprintf(stderr, "[leak_detector] No leaks detected.\n");
    } else {
        fprintf(stderr, "[leak_detector] LEAKED: %zu allocations, %zu bytes total\n",
                leak_count, total_leaked);
    }
    fprintf(stderr, "[leak_detector] =====================================\n\n");
}

// ============================================================
// Interposed Functions
// ============================================================

// Bootstrap calloc: dlsym itself may call calloc before our init() runs.
// We need a static buffer for this bootstrap phase.
static int in_bootstrap_calloc = 0;
static uint8_t bootstrap_buf[4096];
static size_t  bootstrap_used = 0;

void *calloc(size_t nmemb, size_t size) {
    if (!real_calloc) {
        // Bootstrap: dlsym hasn't returned yet; use static buffer
        if (in_bootstrap_calloc) return NULL;
        in_bootstrap_calloc = 1;

        size_t total = nmemb * size;
        if (bootstrap_used + total > sizeof(bootstrap_buf)) return NULL;
        void *p = bootstrap_buf + bootstrap_used;
        memset(p, 0, total);
        bootstrap_used += total;
        in_bootstrap_calloc = 0;
        return p;
    }

    void *ptr = real_calloc(nmemb, size);
    track_alloc(ptr, nmemb * size);
    return ptr;
}

void *malloc(size_t size) {
    if (!real_malloc) return NULL;
    void *ptr = real_malloc(size);
    track_alloc(ptr, size);
    return ptr;
}

void *realloc(void *old_ptr, size_t new_size) {
    if (!real_realloc) return NULL;
    void *new_ptr = real_realloc(old_ptr, new_size);
    if (old_ptr) track_free(old_ptr);
    if (new_ptr) track_alloc(new_ptr, new_size);
    return new_ptr;
}

void free(void *ptr) {
    // Check for bootstrap buffer pointers — do not pass to real_free
    if (ptr >= (void *)bootstrap_buf &&
        ptr < (void *)(bootstrap_buf + sizeof(bootstrap_buf))) {
        return;  // Bootstrap allocation — no real free needed
    }
    if (real_free) {
        track_free(ptr);
        real_free(ptr);
    }
}

Test Program: Intentional Leaks

// test_leaks.c — Program with intentional memory leaks for testing
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void function_with_leak(void) {
    // This allocation is never freed
    char *buf = malloc(256);
    strcpy(buf, "This memory is leaked");
    printf("Allocated: %s\n", buf);
    // Missing: free(buf);
}

void function_no_leak(void) {
    char *buf = malloc(128);
    strcpy(buf, "This is properly freed");
    printf("Allocated and freed: %s\n", buf);
    free(buf);
}

int main(void) {
    // Leak 1: direct malloc without free
    int *array = malloc(10 * sizeof(int));
    for (int i = 0; i < 10; i++) array[i] = i;
    // Missing: free(array);

    // No leak: properly managed
    function_no_leak();

    // Leak 2: in a called function
    function_with_leak();

    // Leak 3: realloc chain with lost original pointer
    char *str = malloc(64);
    strcpy(str, "hello");
    str = realloc(str, 128);  // Old pointer tracked as freed, new one tracked
    // Missing: free(str);

    printf("Program ending with %d leaked allocations\n", 3);
    return 0;
}

Build and Run

# Build the leak detector
gcc -fPIC -shared -rdynamic -o leak_detector.so leak_detector.c -ldl -lpthread

# Build the test program (no special flags needed!)
gcc -g -o test_leaks test_leaks.c

# Run with leak detection
LD_PRELOAD=./leak_detector.so ./test_leaks

Expected Output:

[leak_detector] Initialized. Tracking allocations...
Allocated: This memory is leaked
Allocated and freed: This is properly freed
Program ending with 3 leaked allocations

[leak_detector] ============ Leak Report ============
[leak_detector] Total allocations: 7 (%zu bytes)

LEAK #1: 0x55a1b2c001a0 (40 bytes)
  #0 main in ./test_leaks
  #1 __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6

LEAK #2: 0x55a1b2c005b0 (256 bytes)
  #0 function_with_leak in ./test_leaks
  #1 main in ./test_leaks
  #2 __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6

LEAK #3: 0x55a1b2c009d0 (128 bytes)
  #0 main in ./test_leaks
  #1 __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6

[leak_detector] =====================================
[leak_detector] LEAKED: 3 allocations, 424 bytes total
[leak_detector] =====================================

The backtrace correctly identifies: - Leak #1: 40 bytes in main (the int array[10]) - Leak #2: 256 bytes in function_with_leak - Leak #3: 128 bytes in main (the realloc'd string)


Testing on an Existing Binary

The power of LD_PRELOAD: apply without recompilation.

# Test on git
LD_PRELOAD=./leak_detector.so git --version
# [leak_detector] Initialized. Tracking allocations...
# git version 2.39.2
# [leak_detector] ============ Leak Report ============
# [leak_detector] Total allocations: 847 (284192 bytes)
# LEAK #1: 0x... (4096 bytes)
# ... (many allocations from git and libc internals)
# [leak_detector] LEAKED: 23 allocations, 18432 bytes total

Note: many "leaks" in well-known programs are intentional — memory that is logically valid until process exit, but not explicitly freed because the OS reclaims it anyway. This is called a "reachable" vs. "unreachable" leak distinction, which tools like Valgrind make explicitly.


Key Implementation Details

The Bootstrap Calloc Problem

dlsym(RTLD_NEXT, "calloc") itself calls calloc internally during dynamic linker initialization. This creates an infinite recursion: our calloc calls dlsym, which calls calloc, which calls dlsym...

The solution: detect this bootstrap phase and serve allocations from a static buffer until real_calloc is ready. Many production malloc wrappers use this technique.

Hash Table Without malloc

The AllocEntry table[TABLE_SIZE] is a global static array — it requires no heap allocation. The 65536 slots × (sizeof(void) + sizeof(size_t) + 8sizeof(void*) + sizeof(int)) ≈ 65536 × 80 bytes ≈ 5 MB of BSS. This is the cost of avoiding internal heap allocation.

Production tools (Valgrind, AddressSanitizer) use their own internal allocators that explicitly bypass the interposed functions.

The -rdynamic Flag

When compiling the leak detector, -rdynamic adds all symbols to the dynamic symbol table, enabling backtrace_symbols() to resolve function names for the leak detector's own call stack frames.


Summary

This LD_PRELOAD leak detector demonstrates: - Function interposition: Replacing malloc/free without modifying the target binary - dlsym(RTLD_NEXT): Finding the "next" implementation of a function in the library chain - __attribute__((constructor/destructor)): Running setup/teardown code via DT_INIT_ARRAY/DT_FINI_ARRAY - Bootstrap problem: Handling the circular dependency when initialization code calls the function being intercepted - backtrace(): Capturing call stacks for diagnostic attribution

The same technique powers production tools: Valgrind's memcheck, address sanitizer's leak detection, jemalloc's heap profiling, and many security research tools.