Hotdry.

Article

Load-time ELF 重写实现系统调用拦截:PLT_GOT 注入与链接器干预的工程实践

深入解析在动态链接器加载阶段对 ELF 二进制进行重写的技术方案,通过 PLT/GOT 注入与链接器行为干预实现系统调用拦截的工程参数与监控要点。

2026-04-18systems

在 Linux 系统中,对进程进行系统调用拦截是安全监控、性能分析和调试工具的核心需求。传统方案如 LD_PRELOAD 只能拦截 libc 函数调用,对于直接使用 sysenter/syscall 指令或静态链接的程序则束手无策。Load-time ELF 重写技术提供了一种在二进制加载阶段进行插桩的可行性路径,能够在动态链接器解析符号之前注入监控代码,实现对系统调用的透明拦截。本文将从 ELF 加载机制出发,阐述 PLT/GOT 注入、链接器行为干预两种主要工程路径,并给出可落地的实现参数与监控建议。

ELF 加载流程与重写介入点

理解 ELF 二进制的加载流程是实现 load-time 重写的前提。当 execve 系统调用执行一个 ELF 可执行文件时,内核首先将可执行文件的代码段和数据段映射到进程的虚拟地址空间,随后启动动态链接器 ld-linux.so(在 x86-64 系统中通常为 /lib64/ld-linux-x86-64.so.2)完成初始化工作。动态链接器的主要任务包括:加载所有 DT_NEEDED 指定的共享库、解析符号引用、执行重定位(relocation)操作、以及调用程序的入口点(_start)。

在重定位阶段,动态链接器会处理多种类型的重定位条目,其中与函数调用拦截最为相关的是 R_X86_64_JUMP_SLOT 类型(对应 32 位架构的 R_386_JUMP_SLOT)。这类重定位条目指向 PLT(Procedure Linkage Table)和 GOT(Global Offset Table)中的槽位,用于延迟绑定(lazy binding)机制:当首次调用一个外部函数时,控制权会转移到 PLT 桩函数,由动态链接器解析实际函数地址并写入 GOT,后续调用则直接跳转到已解析的地址。Load-time 重写的核心思路便是在这一解析过程中插入干预逻辑,使得所有系统调用在进入内核前先经过监控代码。

对于系统调用拦截而言,需要明确一个关键概念:大多数应用程序并不直接发起系统调用,而是通过 glibc 等 C 库提供的包装函数(如 open、read、write、mmap)间接完成。这些 libc 函数内部才真正执行 sysenter/syscall 指令。因此,拦截系统调用的实质是对 libc 函数进行拦截,而非对内核系统调用表的直接篡改。ELF 重写技术正是通过对 libc 函数的导入表条目进行修改,实现对系统调用的透明监控。

PLT_GOT 注入:符号覆写技术详解

PLT/GOT 注入是最直接的 load-time 重写方案。其核心思想是在动态链接器完成符号解析之前,将目标函数的 GOT 条目指向监控桩函数(hook function),使得所有对该函数的调用都被重定向到监控代码。实现这一技术需要解决两个关键问题:如何获取目标函数的原始地址,以及如何在监控代码执行完毕后调用原始函数。

获取原始函数地址的常用方法是通过 dlsym 函数配合 RTLD_NEXT 句柄。在监控桩函数中,首先调用 dlsym (RTLD_NEXT, "original_function_name") 获取 libc 中原始函数的实际地址,然后执行监控逻辑(如参数记录、调用计数、权限检查),最后将控制权传递给原始函数。这种方式不需要修改原始二进制文件本身,而是通过在运行时修改函数指针来实现拦截。

从工程实现角度,PLT/GOT 注入需要关注以下参数。首先是注入时机:必须在动态链接器完成重定位之前进行干预,这意味着需要使用 LD_PRELOAD 机制加载一个共享库,该库中的构造函数(attribute((constructor)))会在动态链接器的重定位阶段之前执行。其次是 GOT 条目的定位:可以通过解析 ELF 文件的 .dynsym 和 .dynstr 节来查找目标函数对应的 GOT 偏移量,或者使用更简单的方法 —— 在监控桩函数中通过 dlsym 动态获取原始函数地址后再覆盖 PLT 条目。

需要特别注意的是,现代 Linux 发行版普遍启用了 RELRO(RELocation Read-Only)安全机制。Full RELRO 会将 GOT 标记为只读,在动态链接器完成解析后禁止对其进行修改。因此,PLT/GOT 注入方案仅在 Partial RELRO 或 Non-RELRO 环境下有效。对于启用了 Full RELRO 的二进制,可以考虑在监控库中定义与目标函数相同名称的符号,由于链接器的符号解析顺序遵循依赖图,监控库中的符号会优先被采用,从而实现拦截效果。

链接器行为干预:自定义动态链接器方案

对于需要更深度控制的场景,例如拦截静态链接的二进制或修改内核系统调用的入口点,PLT/GOT 注入则显得力不从心。此时需要采用更为底层的技术:自定义动态链接器或二进制重写工具。

自定义动态链接器方案的核心是用我们自己修改过的 ld-linux.so 替换系统的动态链接器。修改工作主要包括在链接器的符号解析函数(如 do_relocation)中添加拦截逻辑:当检测到目标函数(如 openat、write、mmap)被解析时,记录其地址并在后续调用时插入跳转指令。这种方案的优势在于能够拦截所有依赖该动态链接器的进程,无需修改目标二进制或环境变量。但其代价是需要维护一个独立的链接器版本,且可能与系统安全更新产生冲突。

二进制重写工具代表了一种更为通用的方案。典型实现包括 DynInst、Pin(Intel 的二进制检测工具)以及开源的 Frida 和 QuenyaStudio。这类工具在进程启动后、main 函数执行前附加到目标进程,扫描二进制代码段并插入监控指令。其技术细节涉及对多种指令编码的处理、需要正确处理指令边界问题,以及在插入监控代码后更新控制流图。

从实际工程角度,二进制重写工具的选择应考虑以下因素。对于简单的函数拦截需求,LD_PRELOAD 配合 PLT/GOT 注入是实现成本最低的方案。对于需要拦截静态链接程序或绕过反作弊检测的场景,可以考虑使用 Frida-gadget(通过 ptrace 附加)或自定义链接器。对于高性能要求的场景(如生产环境的系统调用审计),则应采用 eBPF 方案,将监控逻辑放入内核空间执行,避免用户态与内核态之间的高频上下文切换。

监控参数配置与回滚策略

在生产环境中部署系统调用拦截方案时,必须建立完善的监控指标和回滚机制。以下是工程实践中的关键参数建议。

监控指标层面,首先应关注拦截成功率,即监控代码被触发的次数与预期应拦截次数的比例。可以通过在监控桩函数入口处增加原子计数器来实现。其次是性能开销,建议采集拦截前后进程的 CPU 使用率、内存占用和系统调用延迟变化。对于高频系统调用(如 read/write),建议设置采样阈值,例如每 1000 次调用仅记录一次详情,避免日志量爆炸。第三是异常检测,监控进程是否出现 oom-killer 杀死、段错误或链接库加载失败等情况。

回滚策略层面,建议采用多级降级机制。第一级降级是禁用特定类型的系统调用拦截,只保留高危操作(如 execve、mount)的监控。第二级降级是切换到日志模式,仅记录调用事实而不进行参数解析,避免深度的数据拷贝开销。第三级降级是完全移除监控库,恢复到原始执行状态。

配置管理方面,推荐使用环境变量控制拦截行为。例如设置 INTERCEPT_SYSCALLS=1 启用拦截,INTERCEPT_LOG_LEVEL=debug 调整日志级别,INTERCEPT_SAMPLE_RATE=0.01 设置采样率。这种方式无需重新编译监控库即可调整行为,便于在运行时进行动态配置。

总结

Load-time ELF 重写技术为系统调用拦截提供了一条在用户态实现的可行路径。PLT/GOT 注入方案实现简洁,适用于动态链接的普通应用程序;自定义链接器和二进制重写工具则提供了更强大的能力,但伴随更高的复杂度和维护成本。在实际工程中,应根据被监控程序的特性(是否静态链接、是否启用 RELRO、是否有反调试机制)选择合适的技术方案,同时建立完善的监控指标和回滚策略,确保系统的稳定运行。

资料来源:本文技术细节参考了 Linux ELF 动态链接规范(Linux Foundation Refspecs)、Stack Overflow 上关于 LD_PRELOAD 与系统调用拦截的技术讨论,以及 BSDCan 2025 会议上关于 ELF 内部机制的公开演讲材料。

systems