LD_AUDIT 劫持检测与主机狩猎手册
LD_AUDIT 是 glibc 动态链接器暴露的审计接口。动态链接的 glibc 程序启动时,解释器 ld-linux-x86-64.so.2 会读取 LD_AUDIT 环境变量所指向的共享对象,并在对象加载、路径搜索、符号绑定、PLT 调用入口和返回等阶段查找并回调其中的 audit 函数。这套机制本来用于链接行为观察、性能分析和调试,也可以被滥用为用户态劫持通道。
一、实验目的 ------ `LD_AUDIT` 是 glibc 动态链接器暴露的审计接口。动态链接的 glibc 程序启动时,解释器 `ld-linux-x86-64.so.2` 会读取 `LD_AUDIT` 环境变量所指向的共享对象,并在对象加载、路径搜索、符号绑定、PLT 调用入口和返回等阶段查找并回调其中的 audit 函数。这套机制本来用于链接行为观察、性能分析和调试,也可以被滥用为用户态劫持通道。 二、实验环境 ------ ```php uname -a ldd --version | head -1 gcc --version | head -1 ```  三、从加载链路理解可观测痕迹 --------------   动态链接程序被内核加载后,控制权先交给 ELF 解释器,也就是 `ld-linux-x86-64.so.2`。解释器解析辅助向量、环境变量、依赖库和重定位信息,之后才把控制权交给主程序入口。 `LD_AUDIT` 的处理发生在主程序常规依赖绑定完成之前,因此审计模块有机会观察甚至影响后续绑定过程。 审计模块运行于独立链接命名空间。它自身依赖的库不会直接污染主程序的符号查找路径。若审计模块调用了 `fprintf()`、`strcmp()` 等 libc 函数,审计命名空间通常会额外加载一份 `libc.so.6`。因此,在 `/proc/<pid>/maps` 中看到同一路径的 `libc.so.6` 出现两组 offset 为 `00000000` 的映射,可以作为强信号;但合法 `dlmopen()` 也可能产生类似现象,不能把双份 libc 单独视为入侵结论。  `la_symbind64` 不会自动接收所有绑定事件。模块通常需要在 `la_objopen` 中返回 `LA_FLG_BINDTO | LA_FLG_BINDFROM`,链接器才会把相关绑定事件送达。因此静态分析时只看是否存在 `la_symbind64` 符号并不充分,还要结合 `la_objopen`、字符串、映射位置和运行态行为综合判断。 四、观察型审计模块加载痕迹 ------------- 先实现一个仅完成协议握手、不篡改任何符号的观测模块。 代码主动调用 fprintf,目的是触发独立命名空间加载 libc,方便我们复现双 libc 映射特征。 将下面的源码保存为 `audit_probe.c`。 ```php cat > audit_probe.c << 'EOF' #define _GNU_SOURCE #include <link.h> #include <stdio.h> unsigned int la_version(unsigned int version) { fprintf(stderr, "audit probe loaded, linker version=%u\n", version); return LAV_CURRENT; } EOF ``` 以位置无关代码方式编译为共享对象。 ```php gcc -fPIC -shared -O2 -Wall -Wextra -o audit_probe.so audit_probe.c ``` 验证文件类型和导出符号。 ```php file audit_probe.so readelf -Ws audit_probe.so | grep la_version ```  启动一个生命周期可控的普通进程。 ```php LD_AUDIT=/root/ld_audit_lab/audit_probe.so sleep 300 & PID=$(pgrep -n -f "sleep 300") echo "PID=$PID" ```  说明 `ld-linux` 已经加载审计模块,并调用了 `la_version()`。 读取进程环境变量,确认 LD\_\* 变量存在。 ```php tr '\0' '\n' < /proc/$PID/environ | grep '^LD_' ```  查看进程映射。 ```php awk '/audit_probe|libc[.]so[.]6|ld-linux/ {print}' /proc/$PID/maps ```  能够看到两组 `libc.so.6` 映射、`audit_probe.so` 映射,以及 `ld-linux-x86-64.so.2` 映射。 统计 offset 为 0 的 libc 起始映射。 ```php awk '/libc[.]so[.]6/ && $3 ~ /^0+$/ {print $1}' /proc/$PID/maps | sort -u | wc -l ```  从进程观测到几个关键现象: 进程环境变量存在 LD\_AUDIT、maps 内能够找到审计模块,说明 ld 已经加载模块。 同时观测到两份 libc.so.6 基地址映射,根源是审计模块独立命名空间,内部调用 fprintf 触发加载独立 libc。 观察结束后清理后台进程。 ```php kill "$PID" ``` 五、主机进程只读巡检 ----------   基于前面观察到的痕迹,编写巡检脚本遍历 `/proc`,同时做多维度校验:进程启动环境、可疑路径下共享对象、已删除文件映射、memfd 匿名库、同一 so 多次加载特征。 脚本保存为 `ld_audit_hunt.sh`。 ```php cat > ld_audit_hunt.sh << 'EOF' #!/usr/bin/env bash set -u FOUND=0 REPORT_SYSTEM_DELETED="${REPORT_SYSTEM_DELETED:-0}" # 用户可写目录集合,是恶意 .so 常见落地位置。 # 使用 [.] 表示字面量点号,避免 gawk 对 \. 产生告警。 SUSPECT_PATH_RE='^/(tmp|var/tmp|dev/shm)(/|$)|^/run/user/[0-9]+(/|$)|^/home/[^/]+/([.]cache|[.]local|tmp)(/|$)' # 系统路径下的 deleted 映射常见于包更新后的旧进程映射,默认过滤。 SYSTEM_PATH_RE='^/(usr/lib|usr/lib64|lib|lib64|usr/bin|bin|sbin|usr/sbin)(/|$)' print_proc_header() { local pid="$1" local user cmd user=$(stat -c '%U' "/proc/$pid" 2>/dev/null || printf '?') cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true) if [[ -z "$cmd" ]]; then cmd="[$(cat "/proc/$pid/comm" 2>/dev/null || printf '?')]" fi printf 'pid=%-7s user=%-12s cmd=%s\n' "$pid" "$user" "$cmd" } scan_environ() { local pid="$1" local envfile="/proc/$pid/environ" [[ -r "$envfile" ]] || return 0 tr '\0' '\n' < "$envfile" 2>/dev/null \ | grep -E '^(LD_AUDIT|LD_PRELOAD|LD_LIBRARY_PATH)=' || true } scan_maps() { local pid="$1" local maps="/proc/$pid/maps" [[ -r "$maps" ]] || return 0 awk \ -v suspect_re="$SUSPECT_PATH_RE" \ -v system_re="$SYSTEM_PATH_RE" \ -v report_system_deleted="$REPORT_SYSTEM_DELETED" ' function path_from_column(start, i, p) { p = $start for (i = start + 1; i <= NF; i++) p = p " " $i return p } NF >= 6 && $2 ~ /x/ { path = path_from_column(6) if (path ~ /[.]so([.][0-9]+)*($| )/ && path ~ suspect_re) print "exec_so_from_writable_path=" path if (path ~ /^\/memfd:/) print "memfd_mapping=" path if (path ~ /[(]deleted[)]$/) { clean = path sub(/ [(]deleted[)]$/, "", clean) if (report_system_deleted == "1") print "deleted_mapping=" path else if (clean ~ suspect_re) print "deleted_mapping=" path else if (clean !~ system_re) print "deleted_mapping=" path } } ' "$maps" awk ' function path_from_column(start, i, p) { p = $start for (i = start + 1; i <= NF; i++) p = p " " $i return p } NF >= 6 && $3 ~ /^0+$/ { path = path_from_column(6) if (path ~ /(libc[.]so[.]6|ld-linux|[.]so([.][0-9]+)*($| ))/) first_page_count[path]++ } END { for (path in first_page_count) { if (first_page_count[path] > 1) print "multi_load=" path " count=" first_page_count[path] } } ' "$maps" } for procdir in /proc/[0-9]*; do pid=${procdir#/proc/} [[ -d "$procdir" ]] || continue env_hits=$(scan_environ "$pid") map_hits=$(scan_maps "$pid") if [[ -n "$env_hits$map_hits" ]]; then print_proc_header "$pid" if [[ -n "$env_hits" ]]; then printf '%s\n' "$env_hits" | sed 's/^/ env: /' fi if [[ -n "$map_hits" ]]; then printf '%s\n' "$map_hits" | sed 's/^/ map: /' fi FOUND=1 fi done exit "$FOUND" EOF chmod +x ld_audit_hunt.sh bash -n ld_audit_hunt.sh ``` 先做一次基线运行。 ```php bash ld_audit_hunt.sh; echo "EXIT=$?" ```  干净环境通常没有输出,退出码为 0。若开启 `REPORT_SYSTEM_DELETED=1`,可能看到系统升级后遗留的旧进程映射,例如 `/usr/bin/ssh-agent (deleted)` 或 `/usr/bin/gpg-agent (deleted)`。 重新启动观察模块。 ```php LD_AUDIT=/root/ld_audit_lab/audit_probe.so sleep 300 & sleep 0.5 bash ld_audit_hunt.sh; echo "EXIT=$?" ```  - `env: LD_AUDIT=...` 是直接证据,说明进程启动时携带了审计配置。 - `map: multi_load=...libc.so.6 count=2` 是强辅助证据,说明同一 libc 进入了多个命名空间。 - 若同时出现 `exec_so_from_writable_path=/tmp/...` 或 `/dev/shm/...`,优先级应进一步提高。 - 若出现非系统目录 `deleted_mapping=...`,说明文件可能已从目录树删除但仍在内存中执行,需要保全样本或进入内存取证。 清理测试进程。 ```php pkill -f "sleep 300" ``` 六、构造 GOT 改写型审计模块 ---------------- 前面的模块仅实现观测,接下来编写具备劫持能力的恶意模块。利用 `la_symbind64` 劫持 `getuid`,将符号绑定目标替换为模块内自定义函数。 将源码保存为 `evil_audit.c`。 ```php cat > evil_audit.c << 'EOF' #define _GNU_SOURCE #include <link.h> #include <stdint.h> #include <string.h> #include <sys/types.h> static uid_t fake_getuid(void) { return 0; } unsigned int la_version(unsigned int version) { (void)version; return LAV_CURRENT; } unsigned int la_objopen(struct link_map *map, Lmid_t lmid, uintptr_t *cookie) { (void)map; (void)lmid; (void)cookie; return LA_FLG_BINDTO | LA_FLG_BINDFROM; } uintptr_t la_symbind64(Elf64_Sym *sym, unsigned int ndx, uintptr_t *refcook, uintptr_t *defcook, unsigned int *flags, const char *symname) { (void)ndx; (void)refcook; (void)defcook; (void)flags; if (strcmp(symname, "getuid") == 0) return (uintptr_t)fake_getuid; return sym->st_value; } EOF ``` 编译为共享对象。 ```php gcc -fPIC -shared -O2 -Wall -Wextra -o evil_audit.so evil_audit.c ``` 查看 audit 接口符号。 ```php readelf -Ws evil_audit.so | grep -E 'la_version|la_objopen|la_symbind64' ```  ```php la_version 说明它能作为 glibc audit 模块完成协议握手 la_objopen 说明它请求接收后续绑定事件 la_symbind64 说明它具备改写符号绑定目标的能力 ``` 当静态分析中同时看到 `getuid`、`open`、`connect`、`execve`、`SSL_read`、`SSL_write` 等敏感字符串,风险等级应进一步提高。 七、主动检测 GOT 目标改写 ---------------  单靠 `dlsym(RTLD_DEFAULT, "getuid")` 不足以证明 GOT 是否被改写。`dlsym()` 走的是一次独立符号查找,并不等价于读取主程序 PLT/GOT 中实际使用的目标地址。因此通过使用主动哨兵程序,直接解析主可执行文件的 `DT_JMPREL` 重定位表,读取每个受关注符号的 GOT 槽,再通过 `/proc/self/maps` 判断目标地址归属。 该方法可以覆盖 `la_symbind64` 把 PLT/GOT 目标改到 audit 模块、preload 模块、匿名映射或其他非 libc 对象的情况。但它无法感知只在 `la_pltenter` 中临时改寄存器、不修改 GOT 的行为,那类情况需要结合行为审计、参数异常和业务日志进一步发现。 将源码保存为 `ld_audit_canary.c`。 ```php cat > ld_audit_canary.c << 'EOF' #define _GNU_SOURCE #include <dlfcn.h> #include <elf.h> #include <fcntl.h> #include <link.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <unistd.h> #define MAX_MAPS 512 #define MAX_PATH 512 struct map_range { uintptr_t start; uintptr_t end; char perms[5]; char path[MAX_PATH]; }; static struct map_range maps[MAX_MAPS]; static size_t map_count; static const char *watched[] = { "getuid", "geteuid", "getpid", "open", "close", "read", "write", NULL }; static int is_watched(const char *name) { for (size_t i = 0; watched[i]; i++) { if (strcmp(name, watched[i]) == 0) return 1; } return 0; } static void load_maps(void) { FILE *fp = fopen("/proc/self/maps", "r"); if (!fp) { perror("fopen(/proc/self/maps)"); exit(1); } char line[1024]; while (fgets(line, sizeof(line), fp) && map_count < MAX_MAPS) { unsigned long start = 0, end = 0, offset = 0, inode = 0; char perms[5] = {0}; char dev[32] = {0}; char path[MAX_PATH] = {0}; int n = sscanf(line, "%lx-%lx %4s %lx %31s %lu %511[^\n]", &start, &end, perms, &offset, dev, &inode, path); if (n < 6) continue; maps[map_count].start = (uintptr_t)start; maps[map_count].end = (uintptr_t)end; strncpy(maps[map_count].perms, perms, sizeof(maps[map_count].perms) - 1); if (n == 7) strncpy(maps[map_count].path, path, sizeof(maps[map_count].path) - 1); else strcpy(maps[map_count].path, "[anonymous]"); map_count++; } fclose(fp); } static const char *path_for_addr(uintptr_t addr) { for (size_t i = 0; i < map_count; i++) { if (addr >= maps[i].start && addr < maps[i].end) return maps[i].path; } return NULL; } static void force_plt_resolution(void) { volatile uid_t uid = getuid(); volatile uid_t euid = geteuid(); volatile pid_t pid = getpid(); (void)uid; (void)euid; (void)pid; int fd = open("/dev/null", O_RDONLY); if (fd >= 0) { char c = 0; (void)read(fd, &c, 0); (void)close(fd); } (void)write(-1, "", 0); } static int inspect_main_object(struct dl_phdr_info *info, size_t size, void *data) { (void)size; int *suspicious = (int *)data; if (info->dlpi_name && info->dlpi_name[0] != '\0') return 0; ElfW(Addr) base = info->dlpi_addr; ElfW(Dyn) *dynamic = NULL; for (ElfW(Half) i = 0; i < info->dlpi_phnum; i++) { const ElfW(Phdr) *ph = &info->dlpi_phdr[i]; if (ph->p_type == PT_DYNAMIC) { dynamic = (ElfW(Dyn) *)(base + ph->p_vaddr); break; } } if (!dynamic) { fprintf(stderr, "cannot find PT_DYNAMIC in main executable\n"); return 1; } const char *strtab = NULL; ElfW(Sym) *symtab = NULL; void *jmprel = NULL; size_t pltrelsz = 0; int pltrel = 0; for (ElfW(Dyn) *d = dynamic; d->d_tag != DT_NULL; d++) { switch (d->d_tag) { case DT_STRTAB: strtab = (const char *)d->d_un.d_ptr; break; case DT_SYMTAB: symtab = (ElfW(Sym) *)d->d_un.d_ptr; break; case DT_JMPREL: jmprel = (void *)d->d_un.d_ptr; break; case DT_PLTRELSZ: pltrelsz = (size_t)d->d_un.d_val; break; case DT_PLTREL: pltrel = (int)d->d_un.d_val; break; default: break; } } if (!strtab || !symtab || !jmprel || !pltrelsz) { fprintf(stderr, "cannot read PLT relocation metadata\n"); return 1; } printf("== PLT binding check ==\n"); if (pltrel == DT_RELA) { ElfW(Rela) *rela = (ElfW(Rela) *)jmprel; size_t count = pltrelsz / sizeof(ElfW(Rela)); for (size_t i = 0; i < count; i++) { const char *name = strtab + symtab[ELF64_R_SYM(rela[i].r_info)].st_name; if (!is_watched(name)) continue; uintptr_t *slot = (uintptr_t *)(base + rela[i].r_offset); uintptr_t target = *slot; const char *path = path_for_addr(target); int bad = (!path || !strstr(path, "libc.so.6")); if (bad) (*suspicious)++; printf("%-8s GOT=%p target=%p owner=%s%s\n", name, (void *)slot, (void *)target, path ? path : "[not mapped]", bad ? " [SUSPECT]" : ""); } } else if (pltrel == DT_REL) { ElfW(Rel) *rel = (ElfW(Rel) *)jmprel; size_t count = pltrelsz / sizeof(ElfW(Rel)); for (size_t i = 0; i < count; i++) { const char *name = strtab + symtab[ELF64_R_SYM(rel[i].r_info)].st_name; if (!is_watched(name)) continue; uintptr_t *slot = (uintptr_t *)(base + rel[i].r_offset); uintptr_t target = *slot; const char *path = path_for_addr(target); int bad = (!path || !strstr(path, "libc.so.6")); if (bad) (*suspicious)++; printf("%-8s GOT=%p target=%p owner=%s%s\n", name, (void *)slot, (void *)target, path ? path : "[not mapped]", bad ? " [SUSPECT]" : ""); } } else { fprintf(stderr, "unsupported PLT relocation type: %d\n", pltrel); } return 1; } int main(void) { force_plt_resolution(); load_maps(); int suspicious = 0; dl_iterate_phdr(inspect_main_object, &suspicious); printf("== summary ==\n"); if (suspicious == 0) printf("all watched PLT entries resolve to libc.so.6\n"); else printf("%d watched PLT entries resolve outside libc.so.6\n", suspicious); return suspicious ? 2 : 0; } EOF ``` 编译时关闭 PIE,并保留 lazy binding。 ```php gcc -O0 -fno-builtin -no-pie -Wl,-z,lazy -Wall -Wextra \ -o ld_audit_canary ld_audit_canary.c -ldl ``` 先在健康状态下运行。 ```php ./ld_audit_canary; echo "EXIT=$?" ```  没有 `[SUSPECT]` 再加载实验用恶意 audit 模块运行。 ```php LD_AUDIT=/root/ld_audit_lab/evil_audit.so ./ld_audit_canary; echo "EXIT=$?" ```  `getuid` 的 GOT 槽仍在主程序中,但 GOT 槽内保存的目标地址已经落在 `/root/ld_audit_lab/evil_audit.so`,因此哨兵标记 `[SUSPECT]` 并返回退出码 2。 八、追溯持久化注入点 ---------- `LD_AUDIT` 通常通过环境变量注入到目标进程。仅处理当前运行进程不够,如果不定位启动源,重启后同一模块仍可能再次加载。 下面的脚本覆盖系统级 systemd、用户级 systemd、cron、全局 shell 配置,以及 root 和普通用户的 shell 启动文件。脚本只读文本配置,不修改系统状态。 保存为 `ld_env_persistence_scan.sh`。 ```php cat > ld_env_persistence_scan.sh << 'EOF' #!/usr/bin/env bash set -u FOUND=0 MAX_SIZE="${MAX_SIZE:-2M}" scan_file() { local file="$1" local prefix="${2:-}" local out [[ -r "$file" && -f "$file" ]] || return 0 out=$( LC_ALL=C awk ' /^[[:space:]]*#/ { next } /^[[:space:]]*$/ { next } /LD_(AUDIT|PRELOAD|LIBRARY_PATH)[[:space:]]*=/ { printf "%s:%d:%s\n", FILENAME, FNR, $0 } ' "$file" 2>/dev/null ) if [[ -n "$out" ]]; then if [[ -n "$prefix" ]]; then printf '%s\n' "$out" | sed "s#^#$prefix #" else printf '%s\n' "$out" fi FOUND=1 fi } scan_dir() { local dir="$1" local prefix="${2:-}" local file [[ -d "$dir" ]] || return 0 while IFS= read -r -d '' file; do scan_file "$file" "$prefix" done < <(find "$dir" -xdev -type f -size "-$MAX_SIZE" -print0 2>/dev/null) } scan_path() { local path="$1" local prefix="${2:-}" [[ -e "$path" ]] || return 0 if [[ -d "$path" ]]; then scan_dir "$path" "$prefix" elif [[ -f "$path" ]]; then scan_file "$path" "$prefix" fi } for path in \ /etc/systemd/system \ /etc/systemd/user \ /run/systemd/system \ /run/systemd/user \ /usr/lib/systemd/system \ /lib/systemd/system \ /etc/profile \ /etc/profile.d \ /etc/bash.bashrc \ /etc/zsh \ /etc/cron.d \ /etc/crontab \ /var/spool/cron \ /var/spool/cron/crontabs; do scan_path "$path" done while IFS=: read -r user home; do [[ -d "$home" ]] || continue for path in \ "$home/.config/systemd/user" \ "$home/.config/environment.d" \ "$home/.bashrc" \ "$home/.bash_profile" \ "$home/.profile" \ "$home/.zshrc" \ "$home/.zshenv" \ "$home/.pam_environment"; do scan_path "$path" "user=$user" done done < <(getent passwd | awk -F: '($3 == 0 || $3 >= 1000) && $6 != "" {print $1":"$6}') exit "$FOUND" EOF chmod +x ld_env_persistence_scan.sh bash -n ld_env_persistence_scan.sh ``` 先做基线运行。 ```php bash ld_env_persistence_scan.sh; echo "EXIT=$?" ``` 若无真实生效配置,应无输出并返回:  接着模拟三类常见持久化。 ```php useradd -m -s /bin/bash testuser 2>/dev/null || true mkdir -p /home/testuser/.config/systemd/user cat > /home/testuser/.config/systemd/user/malicious.service << 'EOF' [Unit] Description=Malicious User Service [Service] Type=simple Environment=LD_AUDIT=/home/testuser/.cache/evil.so ExecStart=/usr/bin/sleep infinity [Install] WantedBy=default.target EOF chown -R testuser:testuser /home/testuser echo 'export LD_PRELOAD=/tmp/hooked.so' >> /home/testuser/.bashrc echo '* * * * * root LD_AUDIT=/dev/shm/hidden.so /usr/bin/something' > /etc/cron.d/suspicious ``` 再次运行扫描。 ```php bash ld_env_persistence_scan.sh; echo "EXIT=$?" ```  清理模拟持久化。 ```php userdel -r testuser 2>/dev/null || true rm -f /etc/cron.d/suspicious ``` 九、LD\_DEBUG 定点确认 ---------------- 先清理旧调试文件,再带 audit 模块启动一个短生命周期程序。 ```php rm -f /tmp/lddebug* LD_DEBUG=files,bindings LD_DEBUG_OUTPUT=/tmp/lddebug \ LD_AUDIT=/root/ld_audit_lab/evil_audit.so \ /bin/sleep 2 ``` 筛选关键行时,不建议只搜索 `audit`,因为基线中也可能出现 glibc 内部符号 `_dl_audit_symbind_alt`、`_dl_audit_preinit`。更稳妥的方式是同时搜索模块路径和回调名。 ```php grep -E 'evil_audit|la_version|la_objopen|la_symbind64|calling init' /tmp/lddebug.* | head -30 ```  这些行表示动态链接器尝试查找可选 audit 回调,但模块没有导出对应符号。只要 `la_version`、`la_objopen`、`la_symbind64` 已经被查找并生效,且 GOT canary 证明目标改写。 再做一次无 audit 模块的基线对比。 ```php rm -f /tmp/lddebug_clean* LD_DEBUG=files,bindings LD_DEBUG_OUTPUT=/tmp/lddebug_clean \ /bin/sleep 2 grep -E 'evil_audit|la_version|la_objopen|la_symbind64|calling init' /tmp/lddebug_clean.* | head -20 ```  只有 `calling init: /usr/lib/x86_64-linux-gnu/libc.so.6` 和 `calling init: /lib64/ld-linux-x86-64.so.2`,说明没有外部 audit 模块加载。 清理调试文件。 ```php rm -f /tmp/lddebug* /tmp/lddebug_clean* ``` 十、可疑共享对象静态分析 ------------ 先获取基本元数据。 ```php file evil_audit.so sha256sum evil_audit.so stat evil_audit.so | grep -E 'Access|Modify|Change|Size' file audit_probe.so sha256sum audit_probe.so ```  接着查看审计接口符号。 ```php readelf -Ws evil_audit.so | grep -E 'la_version|la_objopen|la_symbind|la_objsearch|la_preinit' readelf -Ws audit_probe.so | grep -E 'la_version|la_objopen|la_symbind|la_objsearch|la_preinit' ``` 本次实验中两者对比如下。  再检查敏感字符串。 ```php strings -a evil_audit.so | grep -E 'getuid|geteuid|open|openat|connect|execve|SSL_|/tmp|/dev/shm|/proc|LD_AUDIT' strings -a audit_probe.so | grep -E 'getuid|geteuid|open|openat|connect|execve|SSL_|/tmp|/dev/shm|/proc|LD_AUDIT' ```  最后查看依赖和运行时搜索路径。 ```php readelf -d evil_audit.so | grep -E 'NEEDED|RPATH|RUNPATH|SONAME' readelf -d audit_probe.so | grep -E 'NEEDED|RPATH|RUNPATH|SONAME' ```  两个模块都依赖 `libc.so.6`。这本身不能说明恶意,但可以解释为什么实验进程 maps 中会出现双份 libc。 静态判断可以按下面的层级进行。 ```php 仅导出 la_version → 可以确认是 audit 模块,可能用于观察或调试 导出 la_version + la_objopen → 请求接收对象加载和绑定事件,风险上升 导出 la_version + la_objopen + la_symbind64 → 具备符号绑定改写能力,高风险 同时存在 getuid/open/connect/execve/SSL_* 等敏感字符串 → 需要结合运行态证据按劫持模块处理 ``` 十一、端到端攻击链验证 ----------- 把落地文件、持久化、运行进程、内存映射和 GOT canary 串起来,形成一条完整证据链。 先把实验模块复制到用户可写目录。 ```php cp evil_audit.so /tmp/evil.so find /tmp /var/tmp /dev/shm -name '*.so*' -ls 2>/dev/null ``` 接着写入 root shell 启动脚本,模拟持久化注入。 ```php echo 'export LD_AUDIT=/tmp/evil.so' >> ~/.bashrc grep LD_AUDIT ~/.bashrc ```  启动受影响进程。 ```php export LD_AUDIT=/tmp/evil.so sleep 300 & TARGET_PID=$(pgrep -n -f "sleep 300") echo "TARGET_PID=$TARGET_PID" ``` 从四个维度验证。 第一,检查环境变量。 ```php tr '\0' '\n' < /proc/$TARGET_PID/environ | grep '^LD_' ```  第二,检查内存映射。 ```php awk '/evil[.]so/ {print "可疑 .so 映射:", $0}' /proc/$TARGET_PID/maps awk '/libc[.]so[.]6/ && $3 ~ /^0+$/ {print $1}' /proc/$TARGET_PID/maps | sort -u | wc -l | xargs -I{} echo "libc 命名空间数: {}" ```  发现 `/tmp/evil.so` 的多个映射段,并且 libc 命名空间数为 2。 第三,运行 GOT 哨兵。 ```php LD_AUDIT=/tmp/evil.so ./ld_audit_canary 2>&1 | grep -E 'SUSPECT|summary|resolve outside' ```  第四,扫描持久化配置。 ```php bash ld_env_persistence_scan.sh; echo "EXIT=$?" ```  围绕端到端结果,可以形成如下证据链。 ```php 落地文件:/tmp/evil.so 持久化源:/root/.bashrc 中 export LD_AUDIT=/tmp/evil.so 运行环境:/proc/<pid>/environ 中 LD_AUDIT=/tmp/evil.so 内存映射:/proc/<pid>/maps 中 /tmp/evil.so 已映射 命名空间:libc.so.6 offset=0 映射计数为 2 主动验证:getuid GOT owner=/tmp/evil.so [SUSPECT] ``` 实验结束后清理。 ```php kill $TARGET_PID 2>/dev/null || true unset LD_AUDIT sed -i '/LD_AUDIT/d' ~/.bashrc rm -f /tmp/evil.so ``` 十二、结论 -----  本次复现实验最终形成的完整检测链路如下。 ```php /proc/<pid>/environ 发现 LD_AUDIT=/tmp/evil.so /proc/<pid>/maps 发现 /tmp/evil.so 映射和 libc 多命名空间 readelf 发现 la_version、la_objopen、la_symbind64 strings 发现 getuid、fake_getuid GOT canary 发现 getuid owner=/tmp/evil.so [SUSPECT] 持久化扫描 发现 shell 或 systemd/cron 中的 LD_* 注入 LD_DEBUG 定点确认 evil_audit.so 被动态链接器加载 ``` 这条链路说明,`LD_AUDIT` 劫持虽然比 `LD_PRELOAD` 更隐蔽,但并非不可检测。可靠狩猎不能依赖单条规则,而应把环境变量、内存映射、启动源配置、共享对象静态特征和 GOT 实际目标串成证据链。只要 `LD_AUDIT` 环境变量、用户可写目录 `.so` 映射、`la_symbind64` 导出符号和 GOT canary `[SUSPECT]` 中的两个强信号同时出现,就应按真实入侵进行处置。
发表于 2026-08-25 09:49:37
阅读 ( 3087 )
分类:
二进制
0 推荐
收藏
1 条评论
guyu
2 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!