Hooked on Linux:Rootkit 分类学、钩挂技术和技巧

在本系列文章(共两部分)的第一部分中,我们将探讨 Linux rootkit 的分类,追溯它们从用户态共享对象劫持和内核空间可加载内核模块钩挂到现代 eBPF 和 io_uring 驱动技术的演变过程。

阅读时间:25 分钟恶意软件分析
Hooked on Linux: Rootkit Taxonomy, Hooking Techniques and Tradecraft

简介

本文是 Linux rootkit 两部分系列文章的第一部分。在第一部分中,我们将重点介绍 rootkit 工作原理背后的理论:rootkit 的分类、演变以及它们用来颠覆内核的挂钩技术。在第二部分中,我们将转向防御方面,深入探讨检测工程,涵盖在生产环境中识别和应对这些威胁的实用方法。

什么是 Rootkits?

Rootkit 是一种隐蔽的恶意软件,旨在隐藏恶意活动,如文件、进程、网络连接、内核模块或账户。它们的主要目的是持久性和躲避,让攻击者能够长期访问服务器、基础设施和企业系统等高价值目标。与其他形式的恶意软件不同,rootkit 专注于不被发现,而不是立即追求目标。

Rootkits 如何工作?

Rootkit 操纵操作系统,改变其向用户和安全工具提供信息的方式。它们在用户空间或内核中运行。用户空间 rootkit 使用LD_PRELOAD 或库劫持等技术修改用户级进程。内核空间 rootkit 以最高权限运行,可修改内核结构、拦截系统调用或加载恶意模块。这种深度整合为他们提供了强大的规避能力,但也增加了运营风险。

为什么 Rootkits 难以检测?

内核空间 rootkit 可以操纵操作系统的核心功能,颠覆安全工具,并遮蔽用户态域的可见性。它们通常会在系统中留下极少的痕迹,避开新进程或文件等明显指标,使传统的检测变得困难。识别 rootkit 通常需要进行内存取证、内核完整性检查或操作系统级别以下的遥测。

为什么说 Rootkit 是攻击者的双刃剑?

虽然 rootkit 提供了隐蔽性和控制能力,但也存在操作风险。内核 rootkit 必须根据内核版本和环境进行精确定制。错误(如内存处理不当或错误挂接系统调用)会导致系统崩溃(内核慌乱),从而立即暴露攻击者。至少,这些故障会使系统受到不必要的关注--而这正是攻击者为维持其立足点而极力避免的情况。

内核更新也带来了挑战:API、内存结构或系统调用的更改可能会破坏 rootkit 功能,使持久性变得脆弱。检测到可疑模块或钩子通常会触发深入的取证调查,因为 rootkit 强烈表明攻击是有针对性的、高技能的。对于攻击者来说,rootkit 是高风险、高回报的工具;对于防御者来说,这种脆弱性为通过低级监控进行检测提供了机会。

Windows 与 Linux Rootkits

Windows Rootkit 生态系统

Windows 是 rootkit 开发的主要焦点。攻击者利用内核钩子、驱动程序和未注明的系统调用来隐藏恶意软件、窃取凭证和持久性。成熟的研究社区和在企业环境中的广泛应用推动着不断创新,包括 DKOM、PatchGuard 绕过和 Bootkits 等技术。

强大的安全工具和微软的加固努力促使攻击者采用越来越复杂的方法。由于 Windows 在企业端点和消费类设备中占据主导地位,因此它仍然具有吸引力。

Linux Rootkit 生态系统

Linux rootkits 历来较少受到关注。不同发行版和内核版本之间的碎片化使检测和开发工作变得更加复杂。虽然有学术研究,但许多工具已经过时,生产 Linux 环境往往缺乏专门的监控。

然而,Linux 在云计算、容器、物联网和高性能计算中的作用使其成为越来越多的目标。在对云提供商、电信公司和政府的攻击中,已经观察到真实世界的 Linux rootkits。攻击者面临的主要挑战包括

  • 不同的内核阻碍了跨发行版的兼容性。
  • 长时间的运行会延长内核错配的时间。
  • SELinux、AppArmor 和模块签名等安全功能增加了难度。

独特的 Linux 威胁包括

  • 容器& Kubernetes:通过容器逃逸的新持久性载体。
  • 物联网设备:内核过时,监控功能极少。
  • 生产服务器:缺乏用户交互的无头系统,降低了可见性。

随着 Linux 在现代基础架构中占据主导地位,rootkit 代表着一种监控不足但却不断升级的威胁。改进检测、工具和 Linux 专用技术的研究日益迫切。

Linux Rootkit 实现模式的演变

在过去二十年里,Linux rootkit 已经从基本的用户态技术发展到利用eBPFio_uring 等现代内核接口的高级内核植入技术。这一演变过程中的每个阶段都反映了攻击者的创新和防御者的应对,推动 rootkit 设计向更隐蔽、更灵活和更有弹性的方向发展。

本节概述了这一进展,包括主要特点、历史背景和现实世界中的例子。

2000 年代初共享对象(SO)用户态根基程序

最早的 Linux rootkit 完全在用户空间运行,无需修改内核,依靠LD_PRELOAD 或操纵 shell 配置文件等技术,将恶意共享对象注入合法的二进制文件。通过拦截opendirreaddirfopen 等标准 libc 函数,这些 rootkit 可以操纵pslsnetstat 等诊断工具的输出。虽然这种方法使它们更容易部署,但与内核级植入相比,它们对用户态钩子的依赖意味着它们的隐蔽性和范围有限;它们很容易被简单的重启或配置重置所破坏。突出的例子包括Jynx rootkit(2009 年)Azazel(2013 年),前者利用libc 函数隐藏文件和连接,后者则将共享对象注入与可选的内核模式功能相结合。早在 2003 年,Phrack 杂志第 61 期就详细介绍了这种动态链接器滥用的基本技术。

2000 年代中期至 2010 年代可加载内核模块 (LKM) Rootkits

随着防御者开始善于发现用户态操作,攻击者通过可加载内核模块(LKM)进入了内核空间。虽然 LKM 是合法的扩展,但恶意行为者会利用它们以完全权限运行,挂接sys_call_table ,操纵ftrace ,或更改内部链表以隐藏进程、文件、套接字,甚至 rootkit 本身。尽管 LKM 具有深度控制和强大的隐蔽能力,但在严酷的环境中,它们也面临着严格的审查。它们可以通过有污点的内核状态、/proc/modules 中的列表或专门的 LKM 扫描仪进行检测,而且越来越受到安全启动、模块签名和 Linux 安全模块(LSM)等现代防御措施的阻碍。这个时代的经典案例包括:Adore-ng(2004+),一种能够隐藏自身的系统调用挂钩 LKM;Diamorphine(2016),一种流行的挂钩程序,在许多发行版上仍能正常运行;以及Reptile(2020),一种具有后门功能的现代变种。

2010 年代后期:基于 eBPF 的 Rootkits

为了躲避越来越多的基于 LKM 的威胁检测,攻击者开始滥用 eBPF,这是一个最初为安全数据包过滤和内核跟踪而构建的子系统。自 Linux 4.8+ 以来,eBPF 已发展成为一个可编程的内核虚拟机,能够将代码附加到系统调用钩子、kprobes、跟踪点或 Linux 安全模块事件上。这些植入程序在内核空间运行,但避免了传统的模块加载,从而可以绕过rkhunterchkrootkit 等标准 LKM 扫描程序以及安全启动限制。由于它们不会出现在/proc/modules 中,而且对典型的模块审计机制来说基本上是不可见的,因此需要CAP_BPFCAP_SYS_ADMIN (或罕见的无特权 BPF 访问权限)才能部署。三重交叉(Triple Cross,2022 年)Boopkit(Boopkit,2022 年)等工具定义了这一时代,前者是一种概念验证工具,可注入 eBPF 程序以挂钩execve 等系统调用,后者则完全通过 eBPF 实现隐蔽的 C2 通道,此外还有许多探讨该主题的 Defcon 演示。

2025 年代及其后:基于 io_uring 的 Rootkits(新兴)

io_uring 是 Linux 5.1(2019 年)中引入的高性能异步 I/O 接口,允许进程通过共享内存环对系统操作进行批处理。虽然设计目的是减少系统调用开销以提高性能,但红队人员已经证明,io_uring 可被滥用来创建隐蔽的用户域代理或内核上下文 rootkit,从而规避基于系统调用的 EDR。通过使用io_uring_enter 来批处理文件、网络和进程操作,这些 rootkit 产生的可观测系统调用事件要少得多,从而使传统的检测机制受挫,并避免了对 LKM 和 eBPF 的限制。RingReaper (2025) 使用io_uring 悄悄替换了readwriteconnectunlink 等常见系统调用,尽管仍处于试验阶段,但ARMO 的研究突出表明,这是未来 rootkit 开发的一个极有前途的载体,如果没有定制仪器,就很难跟踪。

Linux rootkit 的设计不断适应更好的防御手段。随着 LKM 加载变得越来越困难,系统调用审计变得越来越先进,攻击者转而使用 eBPF 和io_uring 等替代接口。随着这种演变,战斗不再仅仅是检测,而是要了解 rootkit 用来融入系统核心的机制,从它们的挂钩策略和内部架构开始。

##Rootkit内部和挂钩技术

了解 Linux rootkit 的架构对于检测和防御至关重要。大多数 rootkit 都采用模块化设计,包含两个主要组件:

  • 加载器:安装或注入 rootkit,并可能建立持久性。虽然从严格意义上讲没有必要,但在部署 rootkit 的恶意软件感染链中经常会看到一个单独的加载器组件。
  • 有效载荷:执行恶意操作,如隐藏文件、拦截系统调用或隐蔽通信。

有效载荷在很大程度上依赖钩挂技术来改变执行流程和实现隐蔽性。

Rootkit Loader 组件

加载器是负责将 rootkit 传输到内存中、初始化其执行以及在许多情况下建立持久性或提升权限的组件。它的作用是弥合初始访问(如通过漏洞、网络钓鱼或错误配置)和完全 rootkit 部署之间的差距。

根据 rootkit 模式的不同,加载器可能完全在用户空间运行,也可能通过标准系统接口与内核交互,或者完全绕过操作系统保护。大体上,加载器可以分为三类:基于恶意软件的下载器、用户rootkit初始化器和自定义内核空间加载器。此外,攻击者还可通过用户空间工具(如insmod )手动加载 rootkit。

基于恶意软件的投放器

恶意软件下载器是一种轻量级程序,通常在初次访问后部署,其唯一目的是下载或解压 rootkit 有效载荷并执行它。这些程序通常在用户空间运行,但会提升权限并与内核级功能交互。

常见的技术包括

  • 模块注入:将恶意.ko 文件写入磁盘,然后调用insmodmodprobe 将其作为内核模块加载。
  • 系统调用包装器:使用init_module()finit_module() 的封装器,通过系统调用直接加载 LKM。
  • 内存注入:利用ptracememfd_create 等接口,通常可避免磁盘假象。
  • 基于 BPF 的加载:使用bpftooltc 或直接bpf() syscalls 等实用程序加载 eBPF 程序并将其附加到内核跟踪点或 LSM 挂钩。

用户区域加载器

对于共享对象 rootkit,加载程序可能仅限于修改用户配置或环境设置:

  • 动态链接器滥用:设置LD_PRELOAD=/path/to/rootkit.so 可让恶意共享对象在执行目标二进制文件时覆盖 libc 功能。
  • 通过修改配置文件实现持久性:将预加载配置插入.bashrc.profile 或全局文件(如/etc/profile ),可确保跨会话持续执行。

虽然这些加载器在实施过程中微不足道,但在防御薄弱的环境中或作为多级感染链的一部分,它们仍然有效。

自定义内核加载器

高级 rootkit 可能包含自定义内核加载器,可完全绕过标准模块加载路径。这些加载器直接与低级内核接口或内存设备交互,将 rootkit 写入内存,通常可规避内核审计日志或模块签名验证。

例如,Reptile 包含一个作为加载器的用户空间二进制文件,允许它在不调用insmodmodprobe 的情况下加载 rootkit;但它仍然依赖init_mod 系统调用将模块加载到内存中。

其他装载机功能

恶意软件加载器的作用往往超出了简单的初始化,成为攻击链中的一个多功能组件。这些高级加载程序的一个关键步骤是提升权限,即在加载主要有效载荷之前寻求 root 访问权限,通常是利用本地内核漏洞,"Dirty Pipe" 漏洞(CVE-2022-0847)就是一个常见的策略。一旦获得权限,装载机就会负责覆盖履带。这涉及到通过清除关键文件(如bash_history 、内核日志、审计日志或系统主syslog )中的条目来清除执行证据的过程。最后,为保证系统重启后的重新执行,加载器通过安装systemd 单元、cron 作业、udev 规则或修改初始化脚本等机制来确保持久性。这些多功能行为往往模糊了单纯的"载入器" 和成熟恶意软件之间的区别,尤其是在复杂的多阶段感染中。

有效载荷组件

有效载荷提供核心功能:隐身、控制和持久性。攻击者可能会使用几种主要方法。用户空间有效载荷通常被称为 SO rootkits,通过动态链接器劫持readdirfopen 等标准 C 库函数来运行。这样,他们就可以操纵ls,netstat, 和ps 等常用系统工具的输出。虽然它们通常更容易部署,但其业务范围有限。

相比之下,内核空间有效载荷的操作则具有完全的系统权限。它们可以直接从/proc 隐藏文件和进程,操纵网络堆栈,修改内核结构。一种更现代的方法是基于 eBPF 的 rootkit,它利用内核字节码附加到系统调用跟踪点或 Linux 安全模块(LSM)钩子。这些工具包无需使用树外模块即可提供隐蔽性,因此在采用安全启动或模块签名策略的环境中特别有效。bpftool 等工具简化了它们的加载,从而使检测变得更加复杂。最后,基于io_uring 的有效载荷通过io_uring_enter (在 Linux 5.1 及更高版本中可用)利用异步 I/O 批处理绕过传统的系统调用监控。这样就可以隐蔽地进行文件、网络和进程操作,同时最大限度地减少遥测暴露。

Linux Rootkits--挂钩技术

在这一重要基础上,我们现在来看看大多数 rootkit 功能的核心:挂钩。从本质上讲,挂钩涉及拦截和改变函数或系统调用的执行,以隐藏恶意活动或注入新的行为。通过转移代码的正常流向,rootkit 可以隐藏文件和进程、过滤安全事件或秘密监控系统,通常不会留下明显的线索。多年来,攻击者设计出了从传统方法到现代规避手段的多种挂钩技术。在本部分中,我们将深入探讨 Linux rootkit 常用的挂钩技术,通过示例和真实 rootkit 样本(如ReptileDiamorphinePUMAKIT 以及最近的FlipSwitch)来说明每种方法,从而了解它们是如何工作的,以及内核进化是如何对它们提出挑战的。

挂钩的概念

从高层次上讲,挂钩是拦截函数或系统调用调用并将其重定向到恶意代码的做法。这样,rootkit 就能修改返回的数据或行为,以隐藏自己的存在或篡改系统操作。例如,rootkit 可能会钩住列出目录中文件的系统调用 (getdents),使其跳过任何与 rootkit 自身文件相匹配的文件名,从而使用户命令(如ls )"看不到 "这些文件。

挂钩不仅限于内核内部,也可能发生在用户空间。早期的 Linux rootkit 通过向进程注入恶意共享对象,完全在用户态运行。利用动态链接器的LD_PRELOAD 环境变量等技术,rootkit 可以在用户程序中覆盖标准 C 库函数(如getdentsreaddirfopen )。这意味着当用户运行psnetstat 等工具时,rootkit 注入的代码会拦截对列表进程或网络连接的调用,并过滤掉恶意进程。这些用户态钩子不需要内核权限,实现起来也相对简单。

著名的例子包括JynxKit(2012 年)Azazel(2014 年),这些用户模式 rootkit 挂接了数十个libc 函数,以隐藏进程、文件、网络端口,甚至启用后门。不过,用户态钩子有很大的局限性:它更容易被检测和移除,而且缺乏内核级钩子所具备的深度控制能力。因此,尽管复杂性和风险较高,但大多数现代和 "野生 "Linux rootkit 都转向了内核空间挂钩,因为内核挂钩可以在低层次上全面欺骗操作系统和安全工具。

在内核中,挂钩通常意味着更改内核数据结构或代码,这样当内核试图执行特定操作(例如打开文件或进行系统调用)时,就会调用 rootkit 的代码,而不是(或除了)合法代码。多年来,Linux 内核开发人员引入了更强大的保护措施,以防止未经授权的修改,但攻击者却以越来越复杂的钩挂方法来应对。下面,我们将研究内核空间中的主要挂钩技术,从较早的方法(现已基本过时)开始,到试图绕过当代内核防御的现代技术。每个小节都将解释该技术,展示一个简化代码示例,并讨论该技术在已知 rootkit 中的应用,以及在当今 Linux 保护措施下的局限性。

内核挂钩技术

中断描述符表 (IDT) 挂接

Linux 最早的内核钩挂技巧之一就是针对中断描述符表 (IDT)。在 32 位 x86 Linux 上,系统调用曾通过软件中断 (int 0x80) 调用。IDT 是一个将中断编号映射到处理程序地址的表格。通过修改0x80 的 IDT 条目,rootkit 可以在内核自身的系统调用分配器获得控制权之前劫持系统调用入口点。换句话说,当任何程序通过int 0x80 触发系统调用时,CPU 会首先跳转到 rootkit 的自定义处理程序,从而允许 rootkit 在最底层过滤或重定向调用。下面是 IDT 挂钩的简化代码示例(仅供参考):

// Install the IDT hook
static int install_idt_hook(void) {
    // Get pointer to IDT table
    idt_table = get_idt_table();

    // Save original syscall handler (int 0x80 = entry 128)
    original_syscall_entry = idt_table[0x80];

    // Calculate original handler address
    original_syscall_handler = (void*)(
        (original_syscall_entry.offset_high << 16) |
        original_syscall_entry.offset_low
    );

    // Install our hook
    idt_table[0x80].offset_low = (unsigned long)custom_int80_handler & 0xFFFF;
    idt_table[0x80].offset_high =
        ((unsigned long)custom_int80_handler >> 16)
        & 0xFFFF;

    // Keep same selector and attributes as original
    // idt_table[0x80].selector and type_attr remain unchanged

    printk(KERN_INFO "IDT hook installed at 0x80\n");
    return 0;
}
IDT 劫持代码示例

上述代码为中断0x80 设置了一个新的处理程序,在进行任何系统调用处理之前,将执行流重定向到 rootkit 的处理程序。这样,rootkit 就能完全在系统调用表级别以下拦截或修改系统调用行为。SuckIT 等教育类 rootkit 和旧版 rootkit 会使用 IDT 挂钩。

现在,IDT 挂钩技术已成为历史。它只能在使用int 0x80 机制的旧版 Linux 系统(Linux 2.6 之前的 32 位 x86 内核)上运行。现代 64 位 Linux 使用sysenter/syscall 指令代替软件中断,因此0x80 的 IDT 条目不再用于系统调用。此外,IDT 挂钩高度针对特定架构(仅限 x86),对 x86_64 或其他架构的现代内核无效。

系统调用表挂钩

系统调用表挂钩是一种经典的 rootkit 技术,它涉及修改内核的系统调用调度表,即sys_call_table 。该表是一个函数指针数组,每个入口对应一个特定的系统调用编号。通过覆盖该表中的指针,攻击者可以将合法的系统调用(如getdents64,killread )重定向到恶意处理程序。示例如下。

asmlinkage int (*original_getdents64)(
    unsigned int,
    struct linux_dirent64 __user *,
    unsigned int);

asmlinkage int hacked_getdents64(
    unsigned int fd,
    struct linux_dirent64 __user *dirp,
    unsigned int count)
{
    int ret = original_getdents64(fd, dirp, count);
    // Filter hidden entries from dirp
    return ret;
}

write_cr0(read_cr0() & ~0x10000); // Disable write protection
sys_call_table[__NR_getdents64] = hacked_getdents64;
write_cr0(read_cr0() | 0x10000); // Re-enable write protection
系统调用表劫持代码示例

在示例中,要修改表格,内核模块首先需要禁用表格所在内存页的写保护。下面的汇编代码(如在 Diamorphine 中所见)演示了如何清除CR0 控制寄存器的第 20 位(写保护),即使write_cr0 功能不再向模块输出:

static inline void
write_cr0_forced(unsigned long val)
{
    unsigned long __force_order;

    asm volatile(
        "mov %0, %%cr0"
        : "+r"(val), "+m"(__force_order));
}
控制寄存器 (cr0) 清除代码示例

一旦写保护被禁用,表中的系统调用地址就可能被恶意函数的地址所取代。修改后,写保护将重新启用。使用这种技术的著名 rootkit 包括 Diamorphine、Knark 和 Reveng_rtkit。系统调用表挂接有几个局限性:

  • 内核加固(自 2.6.25 起)隐藏sys_call_table.
  • 内核内存页被设置为只读 (CONFIG_STRICT_KERNEL_RWX)。
  • 安全启动和内核锁定机制等安全功能会阻碍对 CR0 的修改。

最彻底的缓解措施来自 Linux 内核 6.9,它从根本上改变了 x86-64 架构上调度系统调用的方式。在 6.9 版之前,内核执行系统调用时会直接在sys_call_table 数组中查找处理程序:

// Pre-v6.9 Syscall Dispatch
asmlinkage const sys_call_ptr_t sys_call_table[] = {
    #include <asm/syscalls_64.h>
};
在 6.9 版之前的 Linux 内核中执行系统调用

从内核 6.9 开始,系统调用编号会在切换语句中使用,以查找并执行相应的处理程序。sys_call_table 仍然存在,但只是为了与跟踪工具兼容,不再用于系统调用执行路径。

// Kernel v6.9+ Syscall Dispatch
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
    switch (nr) {
    #include <asm/syscalls_64.h>
    default: return __x64_sys_ni_syscall(regs);
    }
};
6.9 版之后 Linux 内核中的系统调用执行问题

由于这一架构变化,在sys_call_table 上覆盖 6.9 及更新内核中的函数指针不会影响系统调用的执行,从而使该技术完全失效。虽然这让我们认为系统调用表补丁不再可行,但我们最近发布的FlipSwitch技术表明,这一载体远未消亡。这种方法利用特定的寄存器操作小工具来瞬间禁用内核写保护机制,实际上允许攻击者绕过现代系统调用路径的"不变性" ,甚至在这些加固环境中重新引入钩子。

FlipSwitch 的目标不是基于数据的sys_call_table ,而是内核新的系统调用调度函数x64_sys_call 的编译机器代码。由于内核现在使用大量的 switch-case 语句来执行系统调用,因此在调度程序的二进制文件中,每个系统调用都有一个硬编码的call 指令。FlipSwitch 会扫描x64_sys_call 函数的内存,以找到目标系统调用的特定"签名" ,通常是0xe8 操作码(CALL 指令),然后是指向原始合法处理程序的 4 字节相对偏移量。

一旦在调度器中确定了这个调用点,rootkit 就会使用小工具清除 CR0 控制寄存器中的写保护(WP)位,从而允许临时写入内核的可执行代码段。然后用一个指向恶意的、由对手控制的函数的新偏移量覆盖原来的相对偏移量。这实际上是"在调度点翻转开关" ,确保每当内核试图通过现代开关语句路径执行目标系统调用时,都会被重定向到 rootkit。尽管 6.9 版内核在架构上进行了加固,但这仍能实现可靠、精确的系统调用拦截。

内联挂钩/功能序幕补丁

内联挂接是通过指针表挂接的另一种方式。内联挂接不是修改表中的指针,而是修补目标函数本身的代码。rootkit 会在内核函数的开始(序幕)写入一条跳转指令,将执行转移到 rootkit 自己的代码。这种技术类似于函数热补丁或 Windows 上用户模式钩子的工作方式(例如,修改函数的第一个字节以跳转到绕行)。

例如,rootkit 可能会以do_sys_open (它是打开文件系统调用处理的一部分)这样的内核函数为目标。通过用x86 JMP 指令覆盖do_sys_open 的前几个字节,rootkit 可以确保每当调用do_sys_open 时,都会跳转到 rootkit 的例程中。然后,恶意例程就可以执行它想执行的任何操作(例如,检查要打开的文件名是否在隐藏列表中,并拒绝访问),还可以选择调用原始do_sys_open ,对非隐藏文件执行正常操作。

unsigned char *target = (unsigned char *)kallsyms_lookup_name("do_sys_open");
unsigned long hook = (unsigned long)&malicious_function;
int offset = (int)(hook - ((unsigned long)target + 5));
unsigned char jmp[5] = {0xE9};
memcpy(&jmp[1], &offset, 4);

// Memory protection omitted for brevity
memcpy(target, jmp, 5);

asmlinkage long malicious_function(
    const char __user *filename,
    int flags, umode_t mode) {
    printk(KERN_INFO "do_sys_open hooked!\n");
    return -EPERM;
}
内联挂钩代码示例

这段代码用JMP 指令覆盖了do_sys_open() 的开头,从而将执行重定向到恶意代码。开源 rootkit Reptile 通过一个名为 KHOOK 的自定义框架(我们稍后将讨论该框架)广泛使用内联函数修补程序。

Reptile 的内联钩子以sys_kill 等函数为目标,可实现后门命令(例如,向进程发送特定信号可触发 rootkit 提升权限或隐藏进程)。另一个例子是 Suterusu,它也对一些钩子进行了内联修补。

内联钩挂既脆弱又高风险:覆盖函数的序言对内核版本和编译器差异很敏感(因此钩挂通常需要每次编译时打补丁或运行时反汇编),如果指令或并发执行处理不当,很容易导致系统崩溃,而且需要绕过现代内存保护(W^XCR0 WP 、模块签名/锁定)或利用漏洞使内核文本可写。

虚拟文件系统挂钩

Linux 中的虚拟文件系统(VFS)层为文件操作提供了一个抽象层。例如,当你读取一个目录(如ls /proc )时,内核最终会调用一个函数来遍历目录条目。文件系统定义了自己的 file_operations,并为iterate_shared (列出目录内容)或文件 I/O 的读/写等操作提供了函数指针。VFS 挂钩涉及用 rootkit 提供的函数替换这些函数指针,以操纵文件系统呈现数据的方式。

从本质上讲,rootkit 可以挂钩 VFS,通过将文件或目录过滤出目录列表来隐藏它们。常见的窍门是:钩住迭代目录条目的函数,让它跳过任何符合特定模式的文件名。file_operations 目录结构(尤其是/proc/sys 中的目录结构)经常成为攻击目标,因为隐藏恶意进程往往需要隐藏/proc/<pid> 下的条目。

请看这个目录列表功能的钩子示例:

static iterate_dir_t original_iterate;

static int malicious_filldir(
    struct dir_context *ctx,
    const char *name, int namelen,
    loff_t offset, u64 ino,
    unsigned int d_type)
{
    if (!strcmp(name, "hidden_file"))
        return 0; // Skip hidden_file
    return ctx->actor(ctx, name, namelen, offset, ino, d_type);
}

static int malicious_iterate(struct file *file, struct dir_context *ctx)
{
    struct dir_context new_ctx = *ctx;
    new_ctx.actor = malicious_filldir;
    return original_iterate(file, &new_ctx);
}

// Hook installation
file->f_op->iterate = malicious_iterate;
VFS 挂接代码示例

该替换功能可在目录列表操作中过滤掉隐藏文件。通过在 VFS 层挂钩,rootkit 不需要篡改系统调用表或底层程序集;它只需利用文件系统接口。Adore-NG 是一款曾经风靡一时的 Linux rootkit,它利用 VFS 挂钩来隐藏文件和进程。它修补了用于目录迭代的函数指针,以隐藏特定 PID 和文件名的条目。许多其他内核 rootkit 都有类似的代码,通过 VFS 钩子隐藏自己或其人工制品。

VFS 挂钩仍在广泛使用,但由于内核结构偏移量在不同版本之间发生变化,可能导致挂钩中断,因此有一定的局限性。

基于 Ftrace 的挂钩

现代 Linux 内核包含一个功能强大的跟踪框架,名为 ftrace(函数跟踪器)。Ftrace 用于调试和性能分析,允许在几乎所有内核函数入口或出口附加钩子(回调),而无需直接修改内核代码。它的工作原理是在运行时以受控方式动态修改内核代码(通常是在调用跟踪处理程序的轻量级蹦床中打补丁)。重要的是,ftrace 提供了内核模块注册跟踪处理程序的 API,只要满足特定条件(如内核支持 ftrace 和 debugfs 接口)即可。

Rootkit 已开始滥用 ftrace,以不那么明显的方式实施钩子。rootkit无需手动将JMP 写入函数,而是要求内核的ftrace机制代劳,本质上是使钩子 "合法化"。这意味着 rootkit 不需要找到函数地址或修改页面保护;它只需为想要拦截的函数名称注册一个回调,内核就会安装钩子。

下面是一个使用 ftrace 挂钩mkdir 系统调用处理程序的简化示例:

static int __init hook_init(void) {
    target_addr = kallsyms_lookup_name(SYSCALL_NAME("sys_mkdir"));
    if (!target_addr) return -ENOENT;
    real_mkdir = (void *)target_addr;

    ops.func = ftrace_thunk;
    ops.flags = FTRACE_OPS_FL_SAVE_REGS
        | FTRACE_OPS_FL_RECURSION_SAFE
        | FTRACE_OPS_FL_IPMODIFY;

    if (ftrace_set_filter_ip(&ops, target_addr, 0, 0)) return -EINVAL;
    return register_ftrace_function(&ops);
}
Ftrace 挂钩代码示例

该钩子会拦截sys_mkdir 函数,并通过恶意处理程序重新路由。最近的 rootkit(如KoviDSingularityUmbra)都使用了基于 ftrace 的钩子。这些 rootkit 会在各种内核函数(包括系统调用)上注册 ftrace 回调,以监控或操纵这些函数。

ftrace 挂钩的主要优点是不会在全局表或打补丁的代码中留下明显的脚印。挂钩是通过合法的内核接口完成的。在外行人看来,一切看起来都很正常;sys_call_table ,函数序言没有被 rootkit 手动覆盖(它们被 ftrace 机制覆盖,但这在启用跟踪功能的内核中是常见且允许的情况)。此外,ftrace 钩子通常可以即时启用/禁用,本质上比手动打补丁的侵入性更低。

虽然 ftrace 挂钩功能强大,但它受到环境和权限边界的限制(如果从内核外部使用)。它需要访问跟踪接口 (debugfs) 和CAP_SYS_ADMIN 权限,而在加固或容器化系统中,即使 UID 0 也会受到命名空间、LSM 或安全启动锁定策略的限制。出于安全考虑,Debugfs 也可能在生产中被卸载或只读。因此,虽然拥有完全权限的 root 用户通常可以使用 ftrace,但现代防御系统通常会禁用或限制这些功能,从而降低基于 ftrace 的钩子在高度加固环境中的实用性。

Kprobes 挂钩

Kprobes 是另一种用于调试和仪器检测的内核功能,攻击者将其用于 rootkit 挂钩。Kprobes 允许用户在运行时通过注册探针处理程序动态侵入几乎所有内核例程。当指定指令即将执行时,kprobe 基础结构会保存状态,并将控制权转移到自定义处理程序。处理程序运行后(甚至可以更改寄存器或指令指针),内核恢复正常执行原始代码。简单来说,kprobes 可以让你将自定义回调附加到内核代码中的任意点(函数入口、特定指令等),有点像带有处理程序的断点。
使用 kprobes 进行恶意挂钩通常涉及拦截函数,以阻止其执行某些操作或获取某些信息。现代 rootkit 中的一个常见用法:由于许多重要符号(如sys_call_tablekallsyms_lookup_name )已不再导出,rootkit 可以在可以访问该符号的函数上部署 kprobe 并窃取它。kprobe 结构和注册如下所示。

// Declare a kprobe targeting the symbol "kallsyms_lookup_name"
static struct kprobe kp = {
    .symbol_name = "kallsyms_lookup_name"
};

// Function pointer type matching kallsyms_lookup_name
typedef unsigned long
    (*kallsyms_lookup_name_t)(const char *name);

// Global pointer to the resolved kallsyms_lookup_name
kallsyms_lookup_name_t kallsyms_lookup_name;

// Register the kprobe; kernel resolves kp.addr
// to the address of the symbol
register_kprobe(&kp);

// Assign resolved address to our function pointer
kallsyms_lookup_name =
    (kallsyms_lookup_name_t) kp.addr;

// Unregister the kprobe (only needed it once)
unregister_kprobe(&kp);
Kprobes 挂钩代码示例

该探针用于获取kallsyms_lookup_name 的符号名,通常是系统调用表挂钩的前奏。虽然在最初的提交中没有出现,但最近对 Diamorphine 的更新使用了这一技术。它放置一个 kprobe 来抓取kallsyms_lookup_name 本身的指针(或使用已知函数上的 kprobe 来间接获取它所需要的)。同样,其他 rootkit 会使用临时的 kprobe 来定位符号,然后在完成后取消注册,转而通过其他方式执行钩子。Kprobes 还可用于直接挂钩行为(而不仅仅是查找地址)。或者 jprobe(专门的 kprobe)可以完全重定向一个函数。不过,使用 kprobes 来完全替换功能比较麻烦,而且并不常用,因为如果要持续劫持一个函数,打补丁或使用 ftrace 会更简单。Kprobes 通常用于间歇或辅助挂钩。

Kprobes 有用但有限:它们会增加运行时开销,如果放在非常热或受限的底层函数上,可能会破坏系统稳定(递归探测会被抑制),因此攻击者必须谨慎选择探测点;它们也是可审计的,可能会触发内核警告或被系统审计记录,活动探测可在/sys/kernel/debug/kprobes/list 下查看(因此意外条目是可疑的);某些内核在构建时可能不支持 kprobe/debug。

内核挂钩框架

如前所述,对于 Reptile rootkit,攻击者有时会创建更高级别的框架来管理他们的钩子。内核钩子(KHOOK)就是这样一个框架(由 Reptile 的作者开发),它抽象掉了内联补丁的脏活累活,为 rootkit 开发人员提供了一个更简洁的界面。从本质上讲,KHOOK 是一个库,它允许你指定一个要挂钩的函数和你的替代函数,并处理修改内核代码的问题,同时提供一个蹦床来安全地调用原始函数。下面举例说明如何使用类似 KHOOK 的宏(基于 Reptile 的用法)来挂起 kill 系统调用:

// Creates a replacement for sys_kill:
// long sys_kill(long pid, long sig)
KHOOK_EXT(long, sys_kill, long, long);

static long khook_sys_kill(long pid, long sig) {
    // Signal 0 is used to check if a process
    // exists (without sending a signal)
    if (sig == 0) {
        // If the target is invisible (hidden by
        // a rootkit), pretend it doesn't exist
        if (is_proc_invisible(pid)) {
            return -ESRCH; // No such process
        }
    }

    // Otherwise, forward the call to the original sys_kill syscall
    return KHOOK_ORIGIN(sys_kill, pid, sig);
}
KHOOK 代码示例

KHOOK 通过内联函数修补程序进行操作,用跳转到攻击者控制的处理程序来覆盖函数序言。上例说明了如果 kill 信号为 0,sys_kill() 如何被重定向到恶意处理程序。

虽然 KHOOK 简化了内联修补程序,但它仍然继承了其所有缺点:它修改内核文本以插入跳转存根,因此内核锁定、安全启动或W^X 等保护措施都可以阻止它。它们还依赖于体系结构和版本(通常仅限于 x86,并且在内核 5.x+ 上失效),因此在不同版本中都很脆弱。

用户空间的钩挂技术

用户空间挂钩是一种针对 libc 层或通过动态链接器访问的其他共享库的技术,用于拦截用户工具使用的常用 API 调用。这些调用的例子包括readdir,getdents,open,fopen,fgetsconnect 。通过插入替换函数,攻击者可以操纵ps,ls,lsofnetstat 等普通用户域工具,返回经过更改或"sanitized 的" 视图。它用于隐藏进程、文件、套接字或隐藏恶意代码的证据。

常见的实现方法是镜像动态链接器解析符号的方式,或者涉及修改进程内存。这些方法包括使用LD_PRELOAD 环境变量或LD_AUDIT 强制提前加载恶意共享对象(.so)文件,修改 ELF DT_* 条目或库搜索路径以优先处理恶意库,或在进程内执行运行时 GOT/PLT 覆盖。覆写 GOT/PLT 通常涉及更改内存保护设置 (mprotect)、编写新代码 (write),然后在注入后恢复原始设置 (restore)。

挂钩函数通常使用dlsym(RTLD_NEXT, ...) 调用真正的 libc 符号进行正常操作。然后,它只过滤或修改它打算隐藏的目标的结果。LD_PRELOAD 滤波器readdir() 函数的基本示例如下。

#define _GNU_SOURCE       // GNU extensions (RTLD_NEXT)
#include <dlfcn.h>        // dlsym(), RTLD_NEXT
#include <dirent.h>       // DIR, struct dirent, readdir()
#include <string.h>       // strstr()

// Pointer to the original readdir()
static struct dirent *(*real_readdir)(DIR *d);

struct dirent *readdir(DIR *d) {
    if (!real_readdir) // resolve original once
        real_readdir =
            dlsym(RTLD_NEXT, "readdir");
    struct dirent *ent;
    // Fetch next dir entry from real readdir
    while ((ent = real_readdir(d)) != NULL) {
        // If name contains the secret marker,
        // skip this entry (hide it)
        if (strstr(ent->d_name, ".secret"))
            continue;
        return ent; // return visible entry
    }
    return NULL; // no more entries
}

此示例通过在真正的libc 之前提供一个已解析的库,有效地隐藏了与过滤器匹配的文件名,从而取代了readdir() 。历史上的用户模式隐藏工具和轻量级 "rootkit "使用LD_PRELOAD 或 GOT/PLT 补丁来隐藏进程、文件和套接字。攻击者还将共享对象注入到特定服务中,无需内核模块就能实现有针对性的隐身。

用户空间干预只影响加载恶意库(或注入恶意库)的进程。对于全系统的持久性来说,它很脆弱(服务/单元文件、消毒环境、setuid/静态二进制文件使问题变得复杂)。相对于内核钩子,检测非常简单:检查可疑的LD_PRELOAD/LD_AUDIT 条目、/proc/<pid>/maps 中的意外映射共享对象、磁盘库与内存导入之间的不匹配或更改的 GOT 条目。完整性工具、服务监管程序(systemd)和简单的进程内存检查通常会暴露这种技术。

使用 eBPF 的挂钩技术

最新的 rootkit 实施模式涉及滥用 eBPF(扩展伯克利数据包过滤器)。eBPF 是 Linux 中的一个子系统,允许特权用户将字节码程序加载到内核中。虽然通常被描述为"沙盒虚拟机,但" 其安全性实际上依赖于静态验证器,该验证器可确保字节码安全(无无限循环、无非法内存访问),然后再将其 JIT 编译为本地机器代码,以实现近乎零延迟的执行。

攻击者可以加载一个或多个附加到敏感内核事件的 eBPF 程序,而不是插入 LKM 来修改内核行为。例如,我们可以编写一个 eBPF 程序,附加到execve 的系统调用条目(通过 kprobe 或 tracepoint),从而监控或操纵进程的执行。同样,eBPF 可以在 LSM 层挂钩(如程序执行通知),以阻止或隐藏某些操作。示例如下。

// Attach this eBPF program to the tracepoint for sys_enter_execve
SEC("tp/syscalls/sys_enter_execve")
int tp_sys_enter_execve(struct sys_execve_enter_ctx *ctx) {
    // Get the current process's PID and TID as a 64-bit value
    // Upper 32 bits = PID, Lower 32 bits = TID
    __u64 pid_tgid = bpf_get_current_pid_tgid();

    // Delegate handling logic to a helper function
    return handle_tp_sys_enter_execve(ctx, pid_tgid);
}
eBPF 挂钩代码示例

TripleCross 和 Boopkit 就是两个突出的公开例子。TripleCross 演示了一种 rootkit,它使用 eBPF 挂接执行和隐藏等系统调用。Boopkit 利用 eBPF 作为隐蔽的通信渠道和后门,通过附加 eBPF 程序来操纵套接字缓冲区(允许远程方通过伪造的数据包与 rootkit 通信)。这些都是概念验证项目,但它们证明了 eBPF 在 rootkit 开发中的可行性。

其主要优点是,eBPF 挂钩不需要加载 LKM,而且与现代内核保护兼容。对于支持 eBPF 的内核来说,这是一项强大的技术。但是,尽管它们很强大,它们也受到制约。它们需要提升权限才能加载,受到验证器安全检查的限制,在重启时短暂存在(需要单独的持久性),而且越来越容易被审计/取证工具发现。在通常不使用 eBPF 工具的系统中,eBPF 的使用尤其明显。

使用 io_uring 的规避技术

虽然io_uring 并非用于挂钩,但作为 rootkit 使用的电子数据记录程序规避技术的最新补充,它值得一提。io_uring 是一种基于环形缓冲区的异步 I/O API,可让进程以最小的系统调用开销提交成批的 I/O 请求(SQE)并获得完成(CQE)。它不是一个挂钩框架,但其设计改变了系统调用/可见性表面,并暴露了强大的面向内核的基元(注册缓冲区、固定文件、映射环),攻击者可以滥用这些基元进行隐蔽的 I/O、躲避系统调用的工作流,或者在与漏洞相结合时,将其作为一种利用基元,从而在较低层安装挂钩。

攻击模式可分为两类:(1)规避/性能滥用:恶意进程使用io_uring 批量执行大量读/写/元数据操作,因此传统的每个系统调用检测器看到的事件或非典型模式较少;(2)利用启用io_uring 表面(环映射、注册资源)中的漏洞历来是权限升级的载体,之后攻击者可通过更传统的方法安装内核钩子。io_uring 如果代码直接提交操作,也会绕过某些 libc 封装器,因此拦截 libc 调用的用户态钩挂可能会被规避。下面是一个简单的提交/重发流程示意图:

// Minimal io_uring usage (error handling omitted)

// io_uring context (SQ/CQ rings shared with kernel)
struct io_uring ring;

// Initialize ring with space for 16 SQEs
io_uring_queue_init(16, &ring, 0);

// Grab a free submission entry (or NULL if full)
struct io_uring_sqe *sqe =
    io_uring_get_sqe(&ring);

// Prepare SQE as a read(fd, buf, len, offset=0)
io_uring_prep_read(sqe, fd, buf, len, 0);

// Submit pending SQEs to the kernel (non-blocking)
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
// Block until a completion is available
io_uring_wait_cqe(&ring, &cqe);
// Mark the completion as handled (free slot)
io_uring_cqe_seen(&ring, cqe);

上面的示例显示,提交队列通过单个或几个io_uring_enter 系统调用将许多文件操作送入内核,从而减少了每次操作的系统调用遥测。

对隐蔽数据收集或高吞吐量外渗感兴趣的对手可能会转而使用io_uring 来减少系统调用噪音。io_uring 本质上不会安装全局钩子或改变其他进程的行为;除非与权限升级相结合,否则它是进程本地的。可以通过检测io_uring syscalls (io_uring_enter,io_uring_register) 并观察异常模式来进行检测:异常大的批次、许多注册文件/缓冲区或执行大量批次元数据操作的进程。内核版本差异也很重要:io_uring 功能发展很快,因此攻击者的技术可能与版本有关。最后,由于io_uring 需要一个正在运行的恶意进程,防御者通常可以中断该进程并检查其环、注册文件和内存映射,以发现滥用行为。

结论

从简单地覆盖表中的指针到现在,Linux 中的钩挂技术已经有了长足的进步。现在,我们看到攻击者利用合法的内核工具框架(ftrace、kprobes、eBPF)来植入更难检测的钩子。从 IDT 和系统调用表补丁到内联钩子和动态探测,每种方法在隐蔽性和稳定性方面都有自己的权衡。防御者需要了解所有这些可能的载体。实际上,现代 rootkit 通常结合多种挂钩技术来实现其目标。例如,PUMAKIT 使用直接的系统调用表钩子和 ftrace 钩子,而 Diamorphine 则使用系统调用钩子和 kprobe 来绕过符号隐藏。这种分层方法意味着检测工具必须检查系统的许多方面:IDT 条目、系统调用表、特定模型寄存器(用于系统中心钩子)、函数序言的完整性、结构(VFS 等)中关键函数指针的内容、活动的 ftrace 操作、注册的 kprobes 以及加载的 eBPF 程序。

在本系列的第二部分,我们将从理论转向实践。有了对 rootkit 分类和钩挂技术的了解,我们将专注于检测工程,构建并应用实用的检测策略,以识别真实 Linux 环境中的这些威胁。

分享这篇文章