LD_PRELOAD로 라이브러리 함수 가로채 디버깅하기 — 호출 추적, malloc 집계, 시간 조작, 장애 주입

장애 상황을 재현하려고 보면 소스가 없는 바이너리이거나, 디스크를 실제로 가득 채우거나 시스템 시계를 바꿔야 하는 경우가 많다. 코드를 고치지 않고 "이 프로그램이 어떤 파일을 여는지", "디스크가 꽉 차면 에러 처리를 제대로 하는지", "인증서 만료 한 달 뒤에는 어떻게 동작하는지"를 보고 싶을 때 쓸 수 있는 도구가 LD_PRELOAD다.

이 글에서는 LD_PRELOAD로 공유 라이브러리 함수를 가로채(interposition) 파일 열기 추적, malloc 호출 집계, 시간 조작, 쓰기 장애 주입을 직접 구현하고, 훅이 먹히지 않는 경우까지 실행 결과로 정리한다. 환경은 Ubuntu 24.04, glibc 2.39, GCC 13.3이다.

동작 원리

동적 링커 ld.soLD_PRELOAD에 지정된 라이브러리를 libc보다 먼저 로드하므로, 같은 이름의 심볼이 있으면 그쪽에 바인딩된다. 원래 함수는 dlsym(RTLD_NEXT, "이름")으로 찾아 호출한다.

#include <stdio.h>
#include <unistd.h>

__attribute__((constructor))
static void hello(void)
{
    dprintf(STDERR_FILENO, "[hello_preload] injected (uid=%d euid=%d)\n", getuid(), geteuid());
}
$ gcc -shared -fPIC -o hello_preload.so hello_preload.c
$ LD_PRELOAD=./hello_preload.so ./fopen_demo
[hello_preload] injected (uid=1000 euid=1000)
read: hello

constructor 속성 함수는 main()보다 먼저 실행되므로 라이브러리가 실제로 주입됐는지 확인하는 용도로 쓰기 좋다.

파일 열기 추적 — 시스템 콜 이름과 libc 심볼은 다르다

strace에서 openat이 보이니 openat만 가로채면 될 것 같지만, 그렇게 만든 훅은 cat에서 아무것도 잡지 못한다.

$ strace -e trace=openat cat hello.txt 2>&1 | tail -3
openat(AT_FDCWD, "hello.txt", O_RDONLY) = 3
hello
+++ exited with 0 +++

$ gcc -shared -fPIC -O2 -o trace_openat_only.so trace_openat_only.c
$ LD_PRELOAD=./trace_openat_only.so cat hello.txt
[trace_open] loaded into pid 63001
hello

가로채야 할 것은 시스템 콜이 아니라 프로그램이 import하는 libc 심볼이다. nm -D로 확인하면 catopen, Python은 open64/openat64를 쓴다.

$ nm -D /usr/bin/cat | grep -w 'U open'
                 U open@GLIBC_2.2.5

$ nm -D /usr/bin/python3.12 | grep -wE 'U (open|open64|openat|openat64|fopen64)'
                 U fopen64@GLIBC_2.2.5
                 U open64@GLIBC_2.2.5
                 U openat64@GLIBC_2.4
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <unistd.h>

#define GET_MODE(flags, mode)                          \
    do {                                               \
        if ((flags) & (O_CREAT | O_TMPFILE)) {         \
            va_list ap;                                \
            va_start(ap, flags);                       \
            mode = va_arg(ap, mode_t);                 \
            va_end(ap);                                \
        }                                              \
    } while (0)

static void log_open(const char *fn, const char *path, int flags, int fd)
{
    int saved = errno;
    dprintf(STDERR_FILENO, "[trace_open] %s(\"%s\", 0x%x) = %d\n", fn, path, flags, fd);
    errno = saved;
}

int open(const char *path, int flags, ...)
{
    static int (*real)(const char *, int, ...);
    mode_t mode = 0;
    GET_MODE(flags, mode);
    if (!real)
        real = dlsym(RTLD_NEXT, "open");
    int fd = real(path, flags, mode);
    log_open(__func__, path, flags, fd);
    return fd;
}

int open64(const char *path, int flags, ...)
{
    static int (*real)(const char *, int, ...);
    mode_t mode = 0;
    GET_MODE(flags, mode);
    if (!real)
        real = dlsym(RTLD_NEXT, "open64");
    int fd = real(path, flags, mode);
    log_open(__func__, path, flags, fd);
    return fd;
}

int openat(int dirfd, const char *path, int flags, ...)
{
    static int (*real)(int, const char *, int, ...);
    mode_t mode = 0;
    GET_MODE(flags, mode);
    if (!real)
        real = dlsym(RTLD_NEXT, "openat");
    int fd = real(dirfd, path, flags, mode);
    log_open(__func__, path, flags, fd);
    return fd;
}
$ gcc -shared -fPIC -O2 -Wall -o trace_open.so trace_open.c
$ LD_PRELOAD=./trace_open.so cat hello.txt
[trace_open] open("hello.txt", 0x0) = 3
hello

$ LD_PRELOAD=./trace_open.so python3 -c 'open("hello.txt").read()' 2>&1 | grep hello
[trace_open] open64("hello.txt", 0x80000) = 3

실제로 어떤 라이브러리에 바인딩됐는지는 LD_DEBUG=bindings로 볼 수 있다.

$ LD_DEBUG=bindings LD_PRELOAD=./trace_open.so cat hello.txt 2>&1 | grep "symbol \`open'"
     63018:	binding file cat [0] to ./trace_open.so [0]: normal symbol `open' [GLIBC_2.2.5]
     63018:	binding file ./trace_open.so [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `open'

fopen은 open 훅에 안 잡힌다

#include <stdio.h>

int main(void)
{
    char buf[64];
    FILE *fp = fopen("hello.txt", "r");
    if (fp && fgets(buf, sizeof(buf), fp))
        printf("read: %s", buf);
    if (fp)
        fclose(fp);
    return 0;
}
$ LD_PRELOAD=./trace_open.so ./fopen_demo
read: hello

$ nm -D fopen_demo | grep -w U | grep open
                 U fopen@GLIBC_2.2.5

glibc의 fopen()은 내부에서 공개 심볼 open이 아닌 내부 함수로 바로 시스템 콜을 호출하므로, 라이브러리 내부 호출은 가로챌 수 없다. fopen까지 보려면 fopen 자체를 훅해야 한다.

malloc 호출 집계와 누수 확인

10번에 한 번 free를 빠뜨리는 테스트 프로그램이다.

#include <stdlib.h>
#include <string.h>

static char *dup_name(const char *s)
{
    char *p = malloc(strlen(s) + 1);
    strcpy(p, s);
    return p;
}

int main(void)
{
    for (int i = 0; i < 100; i++) {
        char *name = dup_name("request");
        if (i % 10 != 0)          /* 10번에 한 번 free를 빠뜨림 */
            free(name);
    }
    char *buf = calloc(4, 1024);
    buf = realloc(buf, 8 * 1024);
    free(buf);
    return 0;
}
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static void *(*real_malloc)(size_t);
static void *(*real_calloc)(size_t, size_t);
static void *(*real_realloc)(void *, size_t);
static void  (*real_free)(void *);

static atomic_long n_malloc, n_calloc, n_realloc, n_free;
static atomic_long bytes;

/* dlsym()이 내부에서 calloc을 부를 수 있어 초기화 전에는 정적 버퍼로 응답 */
static char early_buf[4096];
static size_t early_used;
static int initializing;

static void init(void)
{
    initializing = 1;
    real_malloc  = dlsym(RTLD_NEXT, "malloc");
    real_calloc  = dlsym(RTLD_NEXT, "calloc");
    real_realloc = dlsym(RTLD_NEXT, "realloc");
    real_free    = dlsym(RTLD_NEXT, "free");
    initializing = 0;
}

void *malloc(size_t size)
{
    if (!real_malloc)
        init();
    n_malloc++;
    bytes += size;
    return real_malloc(size);
}

void *calloc(size_t nmemb, size_t size)
{
    if (initializing) {
        void *p = early_buf + early_used;
        early_used += (nmemb * size + 15) & ~15UL;
        return p;
    }
    if (!real_calloc)
        init();
    n_calloc++;
    bytes += nmemb * size;
    return real_calloc(nmemb, size);
}

void *realloc(void *ptr, size_t size)
{
    if (!real_realloc)
        init();
    n_realloc++;
    bytes += size;
    return real_realloc(ptr, size);
}

void free(void *ptr)
{
    if ((char *)ptr >= early_buf && (char *)ptr < early_buf + sizeof(early_buf))
        return;
    if (!real_free)
        init();
    if (ptr)
        n_free++;
    real_free(ptr);
}

__attribute__((destructor))
static void report(void)
{
    char line[256];
    int len = snprintf(line, sizeof(line),
        "[malloc_count] malloc=%ld calloc=%ld realloc=%ld free=%ld "
        "bytes=%ld outstanding=%ld\n",
        (long)n_malloc, (long)n_calloc, (long)n_realloc, (long)n_free,
        (long)bytes, (long)(n_malloc + n_calloc - n_free));
    if (write(STDERR_FILENO, line, len) < 0)
        _exit(1);
}
$ gcc -O2 -o leak_demo leak_demo.c
$ gcc -shared -fPIC -O2 -Wall -o malloc_count.so malloc_count.c
$ LD_PRELOAD=./malloc_count.so ./leak_demo
[malloc_count] malloc=100 calloc=1 realloc=1 free=91 bytes=13088 outstanding=10

$ LD_PRELOAD=./malloc_count.so python3 -c 'print("hi")'
hi
[malloc_count] malloc=3601 calloc=46 realloc=190 free=3628 bytes=2960997 outstanding=19

outstanding=10이 빠뜨린 free 횟수와 정확히 일치한다. 결과 출력에 printf 대신 snprintf + write를 쓴 것은 printf가 내부에서 malloc을 불러 훅이 재귀로 들어가는 것을 피하기 위해서다.

시간 조작 — 인증서 만료 테스트

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdlib.h>
#include <time.h>

static long offset_sec(void)
{
    const char *s = getenv("FAKE_DAYS");
    return s ? atol(s) * 86400L : 0;
}

int clock_gettime(clockid_t clk, struct timespec *ts)
{
    static int (*real)(clockid_t, struct timespec *);
    if (!real)
        real = dlsym(RTLD_NEXT, "clock_gettime");
    int ret = real(clk, ts);
    if (ret == 0 && (clk == CLOCK_REALTIME || clk == CLOCK_REALTIME_COARSE))
        ts->tv_sec += offset_sec();
    return ret;
}

time_t time(time_t *t)
{
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    if (t)
        *t = ts.tv_sec;
    return ts.tv_sec;
}
$ gcc -shared -fPIC -O2 -Wall -o fake_time.so fake_time.c
$ TZ=Asia/Seoul date '+%F %T'
2026-09-13 23:46:42

$ FAKE_DAYS=400 TZ=Asia/Seoul LD_PRELOAD=./fake_time.so date '+%F %T'
2027-10-18 23:46:42

$ FAKE_DAYS=-365 LD_PRELOAD=./fake_time.so python3 -c 'import datetime; print(datetime.datetime.now().date())'
2025-09-13

유효기간 30일짜리 자체 서명 인증서를 만들고, 31일 뒤로 시간을 옮겨 -checkend 결과를 비교했다. 시스템 시계는 건드리지 않는다.

$ openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 30 -subj '/CN=test.local' 2>/dev/null
$ openssl x509 -in cert.pem -noout -checkend 0; echo "rc=$?"
Certificate will not expire
rc=0

$ FAKE_DAYS=31 LD_PRELOAD=./fake_time.so openssl x509 -in cert.pem -noout -checkend 0; echo "rc=$?"
Certificate will expire
rc=1

장애 주입 — 디스크 꽉 참(ENOSPC) 재현

일반 파일에 일정 바이트 이상 쓰면 write()ENOSPC를 돌려주게 해서, 디스크를 실제로 채우지 않고 에러 처리 경로를 확인한다.

#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <unistd.h>

static ssize_t (*real_write)(int, const void *, size_t);
static long limit = -1;     /* 일반 파일에 FAIL_WRITE_AFTER 바이트를 넘게 쓰면 ENOSPC */
static long written;

__attribute__((constructor))
static void init(void)
{
    real_write = dlsym(RTLD_NEXT, "write");
    const char *s = getenv("FAIL_WRITE_AFTER");
    if (s)
        limit = atol(s);
}

static int is_regular(int fd)
{
    struct stat st;
    return fstat(fd, &st) == 0 && S_ISREG(st.st_mode);
}

ssize_t write(int fd, const void *buf, size_t count)
{
    if (limit < 0 || !is_regular(fd))
        return real_write(fd, buf, count);

    if (written + (long)count > limit) {
        errno = ENOSPC;
        return -1;
    }
    ssize_t n = real_write(fd, buf, count);
    if (n > 0)
        written += n;
    return n;
}
$ gcc -shared -fPIC -O2 -Wall -o fail_write.so fail_write.c
$ head -c 300000 /dev/urandom > data.bin
$ FAIL_WRITE_AFTER=100000 LD_PRELOAD=./fail_write.so dd if=data.bin of=out.bin bs=4096 status=none; echo "rc=$? size=$(stat -c %s out.bin)"
dd: error writing 'out.bin': No space left on device
rc=1 size=98304

$ FAIL_WRITE_AFTER=100000 LD_PRELOAD=./fail_write.so gzip -c data.bin > out.gz; echo "rc=$?"

gzip: stdout: No space left on device
rc=1

$ FAIL_WRITE_AFTER=100000 LD_PRELOAD=./fail_write.so python3 -c 'open("out.txt","wb").write(b"x"*300000)'; echo "rc=$?"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
OSError: [Errno 28] No space left on device
rc=1

세 프로그램 모두 에러를 보고하고 0이 아닌 종료 코드로 끝났다. 반면 cp는 같은 조건에서도 성공한다.

$ FAIL_WRITE_AFTER=100000 LD_PRELOAD=./fail_write.so cp data.bin out2.bin; echo "rc=$?"
rc=0

$ strace -e trace=write,copy_file_range cp data.bin out2.bin 2>&1 | head -2
copy_file_range(3, NULL, 4, NULL, 9223372035781033984, 0) = 300000
copy_file_range(3, NULL, 4, NULL, 9223372035781033984, 0) = 0

cpwrite() 대신 copy_file_range()로 커널 안에서 복사하므로 훅을 우회한다. 가로챌 함수를 정하기 전에 strace/nm -D로 실제 호출 경로를 먼저 확인해야 하는 이유다.

LD_PRELOAD가 적용되지 않는 경우

$ gcc -O2 -static -o fopen_static fopen_demo.c && file fopen_static | grep -o 'statically linked'
statically linked

$ LD_PRELOAD=./hello_preload.so ./fopen_static
read: hello

$ ls -l /usr/bin/passwd | cut -c1-10; LD_PRELOAD=$PWD/hello_preload.so passwd --help | head -1
-rwsr-xr-x
Usage: passwd [options] [LOGIN]

$ getcap /usr/bin/ping; LD_PRELOAD=$PWD/hello_preload.so ping -c1 -W1 127.0.0.1 | head -1
/usr/bin/ping cap_net_raw=ep
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.

세 경우 모두 [hello_preload] injected 줄이 없다. 정적 링크 바이너리는 ld.so를 거치지 않고, setuid·file capability 바이너리는 secure-execution 모드(AT_SECURE)라서 동적 링커가 LD_PRELOAD를 무시한다.

주의사항

상황문제대응
시스템 콜 이름으로 훅 작성openat 시스템 콜이어도 libc 심볼은 open/open64일 수 있음nm -D <바이너리> | grep ' U '로 import 심볼 확인
라이브러리 내부 호출fopenopen, printfwrite 같은 glibc 내부 호출은 안 잡힘상위 API(fopen, fwrite) 자체를 훅
다른 경로의 시스템 콜cpcopy_file_range, sendfile, mmap 쓰기는 write 훅 우회strace로 실제 경로 확인 후 해당 함수까지 훅
malloc 훅 안에서 로그 출력printf/dlsym이 다시 malloc/calloc을 호출해 재귀·크래시snprintf+write, 초기화 중엔 정적 버퍼로 응답
가변 인자 open(path, flags, ...)O_CREAT일 때 mode를 안 넘기면 쓰레기 권한으로 파일 생성O_CREAT/O_TMPFILE이면 va_arg로 꺼내 전달
멀티스레드 프로그램카운터·지연 초기화가 경쟁 상태stdatomic, pthread_once, 스레드 로컬 재진입 방지 플래그
정적 링크·setuid·capability 바이너리LD_PRELOAD 무시됨정적 바이너리는 ptrace/seccomp, 권한 바이너리는 root로 복사본 실행 등 다른 방법
errno 보존로그 출력 함수가 errno를 바꿔 호출자가 엉뚱한 에러를 봄로그 전 int saved = errno; → 복원
/etc/ld.so.preload시스템 전체 프로세스에 주입되어 잘못되면 부팅·로그인 불가디버깅에는 환경 변수 방식만 사용

마무리

목적가로챌 함수
파일 접근 추적open, open64, openat, fopentrace_open.so
메모리 사용 집계·누수malloc, calloc, realloc, freemalloc_count.so
시간 조작clock_gettime, time, gettimeofdayfake_time.so (완성품: libfaketime)
장애 주입write, connect, mallocfail_write.so
주입 여부 확인constructor 함수, LD_DEBUG=bindingshello_preload.so

LD_PRELOAD 훅은 수십 줄로 만들 수 있지만, 잡히는지는 대상 프로그램의 실제 호출 경로에 달려 있다. 훅을 만들기 전에 nm -Dstrace로 경로부터 확인하는 순서를 지키면 헛수고를 줄일 수 있다.

참고

답글 남기기