动态链接器解析与劫持
约 4667 字大约 16 分钟
2026-05-25
在 Linux C/C++ 程序里,很多函数并不是编译进最终可执行文件的。比如 printf、malloc、pthread_create、dlopen,通常来自外部动态库。程序启动时,动态链接器会把这些库加载进进程地址空间,并把函数调用绑定到真正的地址。
动态链接劫持利用的就是这个过程:在程序调用真实函数之前,让它先调用我们提供的同名函数。常见手段包括:
- 调整动态库搜索路径。
- 使用
LD_PRELOAD预加载库。 - 使用
/etc/ld.so.preload全局预加载库。 - 在 wrapper 函数里用
dlsym(RTLD_NEXT, "...")找到下一个真实实现。
这类机制常用于调试、性能分析、监控埋点、兼容适配、沙箱和资源限制。例如 HAMi-Core 的 libvgpu.so 就是通过动态库注入和 CUDA / NVML API hook,在容器内限制 GPU 显存和算力。
静态链接和动态链接
C 程序从源代码到运行,大致经历:
source.c -> object file -> executable -> process链接阶段有两种常见方式。
静态链接
静态链接会把依赖库的目标代码复制进最终可执行文件:
gcc main.c -static -o app特点:
- 运行时对外部
.so依赖少。 - 文件更大。
- 库更新后需要重新构建程序。
LD_PRELOAD这类动态链接劫持通常无法影响已经静态链接进去的函数。
动态链接
动态链接不会把库代码完整复制进可执行文件,而是在可执行文件里记录“我需要哪些共享库、哪些符号”:
gcc main.c -o app运行时由动态链接器加载依赖库,例如:
/lib64/ld-linux-x86-64.so.2可以用 ldd 查看一个程序依赖哪些动态库:
ldd ./app示例输出:
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2ELF 里记录了什么
Linux 可执行文件和 .so 通常是 ELF 格式。动态链接相关信息主要包括:
INTERP:指定动态链接器路径,例如/lib64/ld-linux-x86-64.so.2。NEEDED:声明依赖哪些共享库,例如libc.so.6。.dynsym:动态符号表,记录运行时需要解析的符号。.plt/.got.plt:过程链接表和全局偏移表,用于延迟绑定函数地址。RPATH/RUNPATH:程序内嵌的库搜索路径。
可以用这些命令观察:
readelf -l ./app | grep interpreter
readelf -d ./app
objdump -T ./app动态链接器启动时做了什么
一个动态链接程序启动时,内核并不是直接把控制权交给 main,而是先加载 ELF 中 INTERP 指定的动态链接器。
大致流程:
kernel execve()
-> load executable ELF
-> load interpreter ld-linux
-> ld-linux loads NEEDED libraries
-> resolve relocations
-> run init functions
-> jump to program entry
-> __libc_start_main()
-> main()动态链接器的核心工作包括:
- 读取可执行文件的动态段。
- 找到所有
DT_NEEDED依赖库。 - 按搜索规则加载
.so。 - 解析符号,把函数和全局变量绑定到实际地址。
- 执行
.init_array等初始化函数。 - 最后把控制权交给程序入口。
动态库搜索顺序
当程序依赖 libfoo.so 时,动态链接器需要找到它的真实路径。搜索顺序会受系统、发行版和 ELF 标记影响,常见优先级可以粗略理解为:
LD_PRELOAD指定的库先加载。LD_LIBRARY_PATH指定的目录。- ELF 里的
RPATH或RUNPATH。 /etc/ld.so.cache里的缓存路径。- 默认系统目录,例如
/lib、/usr/lib、/lib64、/usr/lib64。
查看动态链接器实际搜索过程:
LD_DEBUG=libs ./app查看系统动态库缓存:
ldconfig -p | head注意:LD_LIBRARY_PATH 和 LD_PRELOAD 是很强的运行时开关,适合调试和局部注入,不适合随便放进全局环境。生产环境滥用这两个变量很容易导致程序加载到错误版本的库。
PLT 和 GOT 是什么
假设程序调用 printf:
printf("hello\n");编译器并不知道运行时 printf 在 libc.so.6 里的最终地址,所以可执行文件里会生成一段间接跳转逻辑。
简化后可以理解成:
call printf@plt
|
v
PLT stub reads GOT entry
|
v
actual printf address in libc第一次调用时,GOT 里可能还没有真实地址。PLT 会跳到动态链接器的 resolver,让它解析 printf,然后把真实地址写回 GOT。后续调用就可以直接跳到真实地址。
这叫 lazy binding,也就是延迟绑定。
可以用环境变量观察绑定过程:
LD_DEBUG=bindings ./app如果想在程序启动时就解析所有符号,可以设置:
LD_BIND_NOW=1 ./app什么是符号劫持
动态链接器解析符号时,会按加载顺序查找“谁提供了这个符号”。如果我们提前加载一个库,并且这个库也导出了 malloc、printf、open 这类同名函数,那么程序可能优先绑定到我们的实现。
这就是符号劫持,也常叫:
- symbol interposition
- function interposition
- hook
- preload hook
它的本质不是修改目标程序二进制,而是改变动态链接器的符号解析结果。
使用 LD_PRELOAD 劫持函数
先写一个普通程序:
// app.c
#include <stdio.h>
int main(void) {
puts("hello");
return 0;
}编译:
gcc app.c -o app再写一个 hook 库:
// hook.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
typedef int (*puts_fn)(const char *);
int puts(const char *s) {
static puts_fn real_puts;
if (real_puts == NULL) {
real_puts = (puts_fn)dlsym(RTLD_NEXT, "puts");
}
real_puts("[hook] before puts");
return real_puts(s);
}编译成共享库:
gcc -shared -fPIC hook.c -o libhook.so -ldl运行:
LD_PRELOAD=$PWD/libhook.so ./app输出:
[hook] before puts
hello这里发生了几件事:
LD_PRELOAD让动态链接器在加载libc.so.6前先加载libhook.so。- 程序解析
puts时,先找到libhook.so里的puts。 - 我们的
puts用dlsym(RTLD_NEXT, "puts")找到后面的真实puts。 - wrapper 做完自己的逻辑后,再调用真实函数。
RTLD_NEXT 的含义
dlsym 用来从动态链接器加载的对象中查找符号:
void *dlsym(void *handle, const char *symbol);RTLD_NEXT 是一个特殊 handle,意思是:
从当前共享库之后的加载对象里,继续查找下一个同名符号。
在 hook 场景里,如果直接调用 puts(s),会再次进入自己的 puts,导致无限递归。用 RTLD_NEXT 可以绕过自己,找到 libc 里的真实实现。
常见模式:
static int (*real_open)(const char *, int, ...);
if (real_open == NULL) {
real_open = dlsym(RTLD_NEXT, "open");
}劫持 malloc 的例子
malloc 是更典型的 hook 对象,可以用于观察内存分配。
// malloc_hook.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
typedef void *(*malloc_fn)(size_t);
void *malloc(size_t size) {
static malloc_fn real_malloc;
if (real_malloc == NULL) {
real_malloc = (malloc_fn)dlsym(RTLD_NEXT, "malloc");
}
void *ptr = real_malloc(size);
fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
return ptr;
}编译:
gcc -shared -fPIC malloc_hook.c -o libmalloc_hook.so -ldl运行任意动态链接程序:
LD_PRELOAD=$PWD/libmalloc_hook.so ls >/dev/null这个例子能工作,但生产级 malloc hook 要复杂得多,因为:
fprintf内部也可能调用malloc,容易递归。- 多线程下初始化真实函数指针需要考虑并发。
- 某些 libc 内部符号不一定走可 interpose 的路径。
- 程序很早期的初始化阶段可能已经调用分配函数。
因此 hook 基础库函数时要特别谨慎,简单 demo 和生产可用实现差距很大。
/etc/ld.so.preload
LD_PRELOAD 是进程级环境变量,只影响当前命令和它派生的子进程。
/etc/ld.so.preload 是系统级配置文件。动态链接器会读取这个文件,并优先加载其中列出的共享库。
文件内容示例:
/usr/local/lib/libhook.so它的效果类似于给所有动态链接程序都加上:
LD_PRELOAD=/usr/local/lib/libhook.so这也是它危险的地方:
- 写错路径可能导致大量命令无法启动。
- hook 库崩溃可能影响整个系统。
- 对容器来说,如果被挂载到容器内
/etc/ld.so.preload,就会影响容器内动态链接进程。
HAMi 这类系统通常会在容器创建时把自己的 preload 文件挂进容器,而不是修改宿主机全局 /etc/ld.so.preload。
劫持 dlsym 本身
很多程序并不是直接链接某个函数,而是运行时用 dlopen / dlsym 获取函数地址。
例如:
void *handle = dlopen("libcuda.so.1", RTLD_NOW);
void *fn = dlsym(handle, "cuMemAlloc_v2");如果只导出一个同名 cuMemAlloc_v2,不一定能影响这种动态查找路径。更强的做法是连 dlsym 本身也 hook 掉:
void *dlsym(void *handle, const char *symbol) {
if (strcmp(symbol, "cuMemAlloc_v2") == 0) {
return my_cuMemAlloc_v2;
}
return real_dlsym(handle, symbol);
}这类实现比普通函数 hook 更麻烦,因为:
- 自己实现
dlsym时还需要找到真实dlsym。 - 初始化过程中很容易递归。
- 不同 libc、不同动态链接器实现细节不完全一样。
- 需要处理符号版本,例如
dlsym@@GLIBC_2.34这类情况。
但它的覆盖面更广,适合拦截那些由框架动态查找的插件 API、driver API 或扩展 API。
HAMi-Core 这类 CUDA hook 库就会面对这个问题:深度学习框架可能通过动态查找调用 CUDA Driver API,因此只依赖普通符号覆盖并不够,通常需要拦截 dlsym 路径。
HAMi 是如何重写 dlsym 的
HAMi-Core 的核心动态库是 libvgpu.so。它并不是修改 glibc 里的 dlsym 实现,而是自己在 libvgpu.so 里导出了一个同名的 dlsym 函数。
因为 libvgpu.so 会通过 LD_PRELOAD 或容器内的 /etc/ld.so.preload 被提前加载,所以进程内后续对 dlsym 的调用,会先命中 HAMi 的实现。
整体逻辑可以简化成:
application / CUDA runtime
-> dlsym(handle, "cuMemAlloc_v2")
-> libvgpu.so::dlsym()
-> if symbol is CUDA/NVML API, return HAMi wrapper
-> otherwise call real glibc dlsym()1. 先保存真实 dlsym
HAMi 自己实现了 dlsym,但它仍然需要在内部调用真实的 glibc dlsym。否则它既没法转发普通符号,也没法找到真实 CUDA / NVML 函数。
HAMi-core 里有一个全局函数指针,概念上类似:
typedef void *(*fp_dlsym)(void *, const char *);
fp_dlsym real_dlsym = NULL;第一次进入 HAMi 的 dlsym 时,它会尝试用 dlvsym 找真实实现:
real_dlsym = dlvsym(RTLD_NEXT, "dlsym", "GLIBC_2.2.5");这里不用普通的 dlsym(RTLD_NEXT, "dlsym"),原因很直接:当前正在实现的就是 dlsym,直接再调 dlsym 很容易重新进入自己,造成递归。
HAMi 还会尝试多个 glibc 版本名,例如:
GLIBC_2.2.5
GLIBC_2.17
GLIBC_2.3
GLIBC_2.4
GLIBC_2.10
GLIBC_2.18
GLIBC_2.22这是为了兼容不同架构和发行版上的 glibc 符号版本。
2. 打开自己的 libvgpu.so
HAMi 需要从 libvgpu.so 里找到自己的 wrapper 函数。它会打开自己的动态库:
char *path = getenv("CUDA_REDIRECT");
if (path != NULL && strlen(path) > 0) {
vgpulib = dlopen(path, RTLD_LAZY);
} else {
vgpulib = dlopen("/usr/local/vgpu/libvgpu.so", RTLD_LAZY);
}CUDA_REDIRECT 可以理解成一个调试或重定向入口:如果设置了它,就从指定路径加载 libvgpu.so;否则使用默认路径 /usr/local/vgpu/libvgpu.so。
3. 对 RTLD_NEXT 做特殊处理
如果调用方本身传入的是 RTLD_NEXT:
dlsym(RTLD_NEXT, "malloc")HAMi 不应该把它改写成 CUDA hook,而是尽量保持 RTLD_NEXT 的语义。它会直接调用真实 dlsym:
if (handle == RTLD_NEXT) {
return real_dlsym(RTLD_NEXT, symbol);
}实际实现里还维护了一个简单的 (thread id, pointer) 记录,用来识别递归 dlsym。如果同一个线程反复查到同一个地址,HAMi 会认为可能出现递归查找,返回 NULL 防止无限递归。
4. 拦截 CUDA Driver API
HAMi 的 CUDA hook 主要面向 cu* 开头的 CUDA Driver API,例如:
cuMemAlloc_v2
cuMemAllocManaged
cuMemAllocAsync
cuLaunchKernel
cuDeviceTotalMem_v2
cuGetProcAddress当 symbol 以 cu 开头时,HAMi 会先做 CUDA 初始化,然后从 libvgpu.so 里查找同名符号:
if (symbol[0] == 'c' && symbol[1] == 'u') {
if (strcmp(symbol, "cuGetExportTable") != 0) {
pthread_once(&pre_cuinit_flag, preInit);
}
void *f = real_dlsym(vgpulib, symbol);
if (f != NULL) {
return f;
}
}如果 libvgpu.so 自己导出了 cuMemAlloc_v2,这里返回的就是 HAMi 的 cuMemAlloc_v2 wrapper,而不是 NVIDIA libcuda.so.1 里的真实函数。
这一步是 HAMi 劫持 CUDA 运行路径的关键。
5. 拦截 NVML API
nvidia-smi 和很多监控工具主要通过 NVML 查询 GPU 信息。HAMi 也会处理 nvml 开头的符号,例如:
nvmlDeviceGetMemoryInfo
nvmlDeviceGetMemoryInfo_v2简化逻辑:
if (symbol starts with "nvml") {
void *f = __dlsym_hook_section_nvml(handle, symbol);
if (f != NULL) {
return f;
}
}所以容器内执行 nvidia-smi 时,如果它通过 dlsym 找 NVML 函数,也会拿到 HAMi 的 wrapper。wrapper 可以把物理 GPU 的真实显存改写成 Pod 被分配到的虚拟显存。
6. 其他符号全部放行
如果不是 HAMi 关心的 CUDA / NVML 符号,就直接调用真实 dlsym:
return real_dlsym(handle, symbol);这样可以尽量减少对普通动态库行为的影响。比如应用查找 pthread_create、malloc、printf,HAMi 不会主动改写这些符号。
7. wrapper 如何再调用真实 CUDA 函数
HAMi 返回 wrapper 地址以后,应用后续调用会进入 HAMi 函数。例如:
app calls cuMemAlloc_v2
-> HAMi cuMemAlloc_v2 wrapper
-> check memory quota
-> call real cuMemAlloc_v2 if allowed那 wrapper 怎么找到真实 CUDA 函数?
HAMi 在初始化时会打开真实的 NVIDIA driver library:
void *table = dlopen("libcuda.so.1", RTLD_NOW | RTLD_NODELETE);然后遍历自己维护的 CUDA API 表,用真实 dlsym 查找函数地址:
cuda_library_entry[i].fn_ptr =
real_dlsym(table, cuda_library_entry[i].name);如果在 libcuda.so.1 里找不到,它还会尝试:
real_dlsym(RTLD_NEXT, cuda_library_entry[i].name);最终 wrapper 调用真实函数时,不再走被 HAMi 重写的 dlsym,而是通过已经保存好的函数指针调用。
8. 用伪代码串起来
把 HAMi 的逻辑压缩成伪代码,大概是:
void *dlsym(void *handle, const char *symbol) {
init_once();
if (real_dlsym == NULL) {
real_dlsym = dlvsym(RTLD_NEXT, "dlsym", "GLIBC_2.2.5");
}
if (vgpulib == NULL) {
vgpulib = dlopen("/usr/local/vgpu/libvgpu.so", RTLD_LAZY);
}
if (handle == RTLD_NEXT) {
return real_dlsym(RTLD_NEXT, symbol);
}
if (starts_with(symbol, "cu")) {
preInit();
void *wrapper = real_dlsym(vgpulib, symbol);
if (wrapper != NULL) {
return wrapper;
}
}
if (starts_with(symbol, "nvml")) {
void *wrapper = find_nvml_wrapper(symbol);
if (wrapper != NULL) {
return wrapper;
}
}
return real_dlsym(handle, symbol);
}所以 HAMi 的 dlsym 重写不是“所有符号都拦截”,而是非常有选择性:
- CUDA Driver API:返回 HAMi wrapper。
- NVML API:返回 HAMi wrapper。
RTLD_NEXT:保持原语义,防止破坏其他 hook。- 其他普通符号:转发给真实
dlsym。
这也是为什么 HAMi 可以做到对应用透明:深度学习框架仍然按原来的方式调用 CUDA / NVML,只是它拿到的函数地址已经被换成了 HAMi 的受控入口。
符号版本和可见性
真实系统里的符号解析还有两个重要细节。
符号版本
glibc 和很多系统库使用 symbol versioning。同一个函数名可能有不同 ABI 版本,例如:
memcpy@GLIBC_2.2.5
memcpy@@GLIBC_2.14@@ 表示默认版本。hook 时如果没有处理版本,可能出现:
- 程序没有绑定到你的实现。
dlsym找到的不是预期版本。- ABI 不匹配导致崩溃。
可以用 objdump -T 查看符号版本:
objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep memcpy符号可见性
并不是所有函数调用都能被外部库劫持。影响因素包括:
- 函数是否导出到动态符号表。
- 调用是否发生在同一个共享库内部。
- 编译时是否使用
-fvisibility=hidden。 - 链接器是否用了
-Bsymbolic。 - 编译器是否内联了函数。
所以 LD_PRELOAD 很强,但不是万能。
安全执行模式
对 setuid / setgid 程序,动态链接器会进入更严格的 secure-execution mode。此时很多环境变量会被忽略或清理,包括常见的:
LD_PRELOAD
LD_LIBRARY_PATH
LD_AUDIT
LD_DEBUG原因很直接:如果普通用户能通过 LD_PRELOAD 劫持 root 权限程序,那就是严重提权漏洞。
所以调试时如果发现 LD_PRELOAD 对某个程序不生效,要检查它是否是 setuid 程序,或者是否运行在受限安全上下文里。
常见用途
动态链接劫持不是只用于“攻击”。工程里常见用途很多:
- 调试:打印
open、connect、malloc、pthread_create等调用。 - 性能分析:统计函数调用次数、耗时和参数。
- 兼容适配:修补旧程序对路径、环境或系统调用包装函数的依赖。
- 灰度替换:在不改程序的情况下替换部分库函数实现。
- 资源控制:在 API 层拦截申请、查询、提交等操作。
- 可观测性:为第三方二进制程序增加 tracing。
但它也有代价:
- 对 ABI 和 libc 行为敏感。
- 容易引入递归、死锁、崩溃。
- 难以覆盖静态链接、内联、直接 syscall 等路径。
- 维护成本会随目标库版本变化而上升。
和 ptrace、eBPF 的区别
动态链接劫持只是观测和控制程序的一种方式。
| 方式 | 位置 | 优点 | 局限 |
|---|---|---|---|
LD_PRELOAD | 用户态函数调用层 | 简单、可改参数和返回值 | 依赖动态链接,覆盖面有限 |
/etc/ld.so.preload | 用户态进程启动层 | 对进程透明,适合统一注入 | 风险高,影响范围大 |
ptrace | 进程调试接口 | 可拦截 syscall、读写寄存器和内存 | 开销大,侵入强 |
| eBPF | 内核观测/控制点 | 低开销,适合系统级观测 | 改用户态函数语义不方便 |
| seccomp | syscall 过滤层 | 适合安全限制 | 只能基于 syscall,不能理解库级语义 |
如果目标是“改写库函数行为”,LD_PRELOAD 很直接。如果目标是“观测全系统 syscall”,eBPF 更合适。如果目标是“阻止某类 syscall”,seccomp 更自然。
排查 checklist
动态链接劫持不生效时,可以按下面顺序看。
1. 程序是否动态链接
file ./app
ldd ./app如果是 statically linked,LD_PRELOAD 通常不会生效。
2. hook 库是否被加载
LD_DEBUG=libs LD_PRELOAD=$PWD/libhook.so ./app也可以看进程映射:
cat /proc/<pid>/maps | grep libhook3. 符号是否导出
nm -D libhook.so | grep ' puts'
objdump -T libhook.so | grep puts-D 表示查看动态符号表。只有导出的动态符号才可能参与动态链接解析。
4. 函数签名是否一致
hook 函数的签名必须和真实函数 ABI 兼容。尤其是:
- 可变参数函数,例如
open、fcntl、ioctl。 - 返回结构体或复杂类型的函数。
- 线程相关函数。
签名不匹配可能不会编译报错,但运行时会出现栈破坏、参数错位或随机崩溃。
5. 是否被安全模式忽略
ls -l ./app如果有 setuid 位:
-rwsr-xr-x那就要考虑 secure-execution mode。
小结
动态链接劫持的核心是:让动态链接器在解析符号时优先选择我们的实现。
最常见的路径是:
LD_PRELOAD / ld.so.preload
-> preload libhook.so
-> libhook.so exports same symbol
-> program calls wrapper first
-> wrapper uses dlsym(RTLD_NEXT) to call real function它强大但不万能。使用时要记住:
- 它主要影响动态链接程序。
- 它依赖符号解析顺序和 ABI 兼容。
dlsym(RTLD_NEXT)是调用真实实现的常用方式。- hook 基础库函数时要格外小心递归和初始化顺序。
- 它适合调试、观测、兼容和资源控制,但不应该被当成强安全边界。
参考资料:
man ld.soman dlopenman dlsymman rtld-audit
更新日志
6992b-add hami于