장애 상황을 재현하려고 보면 소스가 없는 바이너리이거나, 디스크를 실제로 가득 채우거나 시스템 시계를 바꿔야 하는 경우가 많다. 코드를 고치지 않고 "이 프로그램이 어떤 파일을 여는지", "디스크가 꽉 차면 에러 처리를 제대로 하는지", "인증서 만료 한 달 뒤에는 어떻게 동작하는지"를 보고 싶을 때 쓸 수 있는 도구가 LD_PRELOAD다.
이 글에서는 LD_PRELOAD로 공유 라이브러리 함수를 가로채(interposition) 파일 열기 추적, malloc 호출 집계, 시간 조작, 쓰기 장애 주입을 직접 구현하고, 훅이 먹히지 않는 경우까지 실행 결과로 정리한다. 환경은 Ubuntu 24.04, glibc 2.39, GCC 13.3이다.
동작 원리
동적 링커 ld.so는 LD_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로 확인하면 cat은 open, 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
cp는 write() 대신 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 심볼 확인 |
| 라이브러리 내부 호출 | fopen→open, printf→write 같은 glibc 내부 호출은 안 잡힘 | 상위 API(fopen, fwrite) 자체를 훅 |
| 다른 경로의 시스템 콜 | cp의 copy_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, fopen | trace_open.so |
| 메모리 사용 집계·누수 | malloc, calloc, realloc, free | malloc_count.so |
| 시간 조작 | clock_gettime, time, gettimeofday | fake_time.so (완성품: libfaketime) |
| 장애 주입 | write, connect, malloc 등 | fail_write.so |
| 주입 여부 확인 | constructor 함수, LD_DEBUG=bindings | hello_preload.so |
LD_PRELOAD 훅은 수십 줄로 만들 수 있지만, 잡히는지는 대상 프로그램의 실제 호출 경로에 달려 있다. 훅을 만들기 전에 nm -D와 strace로 경로부터 확인하는 순서를 지키면 헛수고를 줄일 수 있다.