可加载内核模块编程和系统调用拦截

作者:Nitesh Dhanjani

现代 CPU 可以在两种模式下运行:内核模式和用户模式。当 CPU 在内核模式下运行时,允许使用扩展指令集,并且可以自由访问内存和设备寄存器中的任何位置。中断驱动程序和操作系统服务在内核模式下运行。相反,当 CPU 在用户模式下运行时,只允许使用受限的指令集,并且 CPU 对内存和设备的视图受到限制。库函数和用户程序在用户模式下运行。内核模式和用户模式共同构成了现代操作系统中安全性和可靠性的基础。

程序的大部分时间都在用户模式下度过,只有在需要操作系统服务时才切换到内核模式。操作系统服务通过系统调用提供。系统调用是进入内核的“网关”,通过软件中断实现。软件中断是由程序产生并在内核模式下由操作系统处理的中断。

操作系统维护一个“系统调用表”,其中包含指向内核内部实现系统调用的函数的指针。从程序的角度来看,这个系统调用列表为操作系统服务提供了一个明确定义的接口。您可以通过查看文件 /usr/include/sys/syscall.h 来获取不同系统调用的列表。在 Linux 中,此文件包含文件 /usr/include/bits/syscall.h

可加载模块是可以按需加载和卸载到内核中的代码片段。可加载模块为内核添加了额外的功能,而无需重启机器。例如,在 Linux 中,使用可加载模块来处理新的设备驱动程序是很常见的。可加载模块的替代方案是单内核,其中新功能直接添加到内核代码中。单内核的缺点是每次添加新功能时都需要重建和重新安装。

内核编程可能很困难,不仅因为其内在的复杂性,还因为漫长的调试周期。调试操作系统可能需要在每个周期安装新的内核并重启机器。我们强烈建议在内核开发中使用可加载模块,因为 a) 无需重建内核或不必要地频繁重启机器;并且 b) 由于最终用户不需要替换/重建现有的内核,用户更可能安装新功能。

Linux 内核中对可加载模块的支持促进了系统调用的拦截,并且可以利用此功能,如下面的示例所述。请注意,假定读者熟悉 C 编程。

1. 系统调用简介

操作系统通过系统调用提供入口点,允许用户级进程请求内核的服务。区分系统调用和库函数非常重要。库函数链接到程序,并且往往更具可移植性,因为它们不受内核实现的约束。然而,许多库函数使用系统调用来执行系统内核中的各种任务。为了说明这一点,请考虑以下 C 程序,该程序打开一个文件并打印其内容

#include <stdio.h>
int main(void)
{
    FILE *myfile;
    char tempstring[1024];
    if(!(myfile=fopen("/etc/passwd","r")))
    {
         fprintf(stderr,"Could not open file\n");
         exit(1);
    }
    while(!feof(myfile))
    {
         fscanf(myfile,"%s\n",tempstring);
         fprintf(stdout,"%s\n",tempstring);
    }
    exit(0);
}

在该程序中,我们使用了 fopen 函数调用来打开 /etc/passwd 文件。但是,重要的是要注意 fopen 不是系统调用。实际上,fopen 在内部调用系统调用 open 以执行真正的 I/O。要获取程序调用的所有系统调用的列表,请使用 strace 程序。假设您已通过运行 gcc example1.c 将上述程序编译为 a.out,运行 strace,例如:strace ./a.out 将允许您查看 a.out 调用的所有系统调用。

内核切换到调用系统调用的进程所有者的用户 ID。因此,如果普通用户运行上述程序,并将 /etc/shadow(不可读)作为 fopen 的参数,则 open 将失败,fopen 也将失败,从而导致上面的 if 子句转换为 true,从而打印 Could not open file 错误消息。

2. 通过可加载模块拦截系统调用,一个示例

假设我们想要拦截 exit 系统调用,并在任何进程调用它时在控制台上打印一条消息。为了做到这一点,我们必须编写我们自己的伪造 exit 系统调用,然后使内核调用我们的伪造 exit 函数而不是原始的 exit 调用。在我们的伪造 exit 调用结束时,我们可以调用原始的 exit 调用。为了做到这一点,我们必须操作系统调用表数组 (sys_call_table)。查看 /usr/src/linux/arch/i386/kernel/entry.S(假设您在 i386 架构上)。此文件包含内核中实现的所有系统调用的列表以及它们在 sys_call_table 数组中的位置。

有了 sys_call_table 数组,我们可以操作它,使 sys_exit 入口点指向我们新的伪造 exit 调用。我们必须存储指向原始 sys_exit 调用的指针,并在我们完成向控制台打印消息后调用它。实现上述功能的源代码如清单 1 所示。

清单 1. Example 2.c

通过调用 gcc 编译清单 1 中所示的程序:gcc -Wall -DMODULE -D__KERNEL__ -DLINUX -c example2.c。这将生成我们的 example2.o 模块。为了将此模块插入内核,以 root 身份执行此操作:insmod example2.o。现在,确保您在控制台上(因为 printk 只打印到控制台),并运行任何使用 exit 系统调用的程序。例如,ls 应该打印:HEY! sys_exit called with error_code=0

接下来,尝试使用不存在的文件调用 ls;这将导致 ls 使用除 0 以外的参数调用 exit 系统调用。因此,ls somefilethatdoesnotexist 应该打印:HEY! sys_exit called with error_code=1

为了列出所有已加载的模块,请使用 lsmod。要删除模块,请运行 rmmod example2

3. 一个更有趣的例子:拦截 sys_execve 以防止

“Rootkit”

在机器被攻陷后,恶意用户倾向于用木马程序(除了正常功能外还执行恶意指令的程序)替换常用程序。此类木马程序的软件包在互联网上广泛传播,并且任何人都可以轻松访问。因此,保护程序免受恶意用户替换变得很重要。

为了防止此类问题,我们的下一个示例涉及拦截各种系统调用,最重要的是 sys_execve,以检查要执行的程序的哈希值是否与哈希数据库文件中存在的已知哈希值匹配。如果哈希值不匹配,则拒绝执行该程序,并记录此类尝试。实现此目的的一种方法如下步骤所示

  1. 拦截 sys_execve 并计算正在执行文件的 inode,然后将其与哈希数据库中存在文件的 inode 进行比较。Inode 是包含文件系统中文件信息的数据结构。由于每个文件都有唯一的 inode,因此我们可以确定我们的比较结果。如果未找到匹配项,则调用原始 sys_execve 并返回。但是,如果找到匹配项,则计算程序的哈希值,然后将其与哈希数据库中存在的哈希值进行比较。如果它们匹配,则调用原始 sys_execve 并返回。如果它们不匹配,则记录尝试并返回错误。

  2. 拦截 sys_delete_module。如果使用我们的模块名称作为参数调用,则返回错误。我们的模块无法删除。

  3. 拦截 sys_create_module,并返回错误。不能再插入模块,因为我们不希望任何恶意模块能够拦截步骤 1 中描述的 sys_execve

  4. 拦截 sys_open 以防止我们的哈希数据库和日志文件被打开进行写入。

  5. 拦截 sys_unlink 以防止删除哈希数据库和日志文件。

请注意,以上方法不能提供完全的保护,但这只是一个简单的初步实现。例如,恶意用户可以修改 /dev/kmem 中的内核符号,或使用原始设备访问硬盘,并绕过 open 来写入哈希数据库文件。此外,由于我们的实现只是一个可加载模块,恶意用户可以更改我们的 /etc/rc.d 文件,并阻止我们的模块在下次机器重启时加载。此外,还存在各种其他可能导致我们的哈希数据库和日志文件被更改或删除的系统调用。

此时,承认恶意用户可能滥用可加载模块支持的可能性变得很重要。例如,可以拦截 sys_execve 函数调用以调用木马程序而不是预期的程序,并且可以拦截诸如 readwrite 之类的系统调用来执行击键记录。因此,可加载内核模块的灵活性和强大功能可能会被已获得系统访问权限的恶意用户滥用。有关此示例的详细信息以及完整的源代码,请参阅资源。

资源

鸣谢

Loadable Kernel Module Programming and System Call Interception
Nitesh Dhanjani 是普渡大学的研究生。他的兴趣是操作系统、网络和安全。他曾为包括 Ernst & Young LLP 在内的多家公司执行安全审计和审查,并在业余时间提供咨询服务。可以通过 dhanjani@dhanjani.com 与他联系。

Gustavo Rodriguez-Rivera 是普渡大学的访问助理教授,也是 Geodesic Systems 的软件架构师。他的兴趣是操作系统、网络和内存管理。可以通过 grr@cs.purdue.edu 与他联系。

加载 Disqus 评论