可加载内核模块漏洞利用
许多有用的计算机安全工具的想法都有一个共同的起源:黑客世界。端口扫描器和密码破解器等工具最初旨在帮助黑客(黑帽子)尝试入侵系统,但系统管理员已经有利可图地应用它们来审计自己服务器和用户帐户的安全性。本文介绍了一个黑客的想法——内核模块漏洞利用——并展示了如何通过使用一些相同的想法和技术来提高系统的安全性。首先,我将讨论我的想法的起源以及它的工作原理,然后我将尝试通过一些简短的示例来揭开内核模块编程的神秘面纱。最后,我们将完成一个实质性的、有用的示例,这将有助于防止一类攻击损害您的系统。
在我们开始之前,我需要提及标准免责声明。请注意,内核空间中的错误很可能导致您的机器崩溃,而内核空间中的无限循环将使您的机器挂起。不要在生产机器上开发和测试新模块,并彻底测试模块以确保它们不会破坏系统的稳定性或损坏您的数据。为了最大限度地减少调试周期中由于系统崩溃造成的数据丢失,我建议您使用虚拟机或模拟器(如 bochs、plex86、User-Mode Linux 端口或 VMware)进行测试,或者在您的开发工作站上安装日志文件系统(如 SGI 的 xfs)。此外,本文中的所有代码示例都未经 SMP 机器测试,并且大多数可能不是多处理器安全的。现在我们已经解决了这个问题,让我们来谈谈模块。
几个月前,我正在开发一个名为 Linux 审计跟踪生成器的系统。对于系统上的每个进程,我想跟踪所有系统调用及其参数。为此,我尝试了几种方法,但没有一种像我希望的那样成功。例如,包装用于 write() 的 libc 函数只使我能够记录源自 C 程序的 write() 调用,而动态二进制 Instrumentation 受限于 Instrumentation 库可以解析的可执行文件的类型(C、C++ 和 Fortran)。仅限于审计由少数几种语言生成的可执行文件只是一个小的实际限制,因为实际上 GNU/Linux 系统上的每个程序都是用 C、C++ 或某种具有基于 C 或 C++ 的运行时库的语言编写的,例如 Perl 或 Python。然而,这些解决方案的不完整性在理论层面上真的困扰着我。我知道通过从一种鲜为人知的、不依赖 C 或 C++ 的语言调用系统调用,甚至通过手工制作汇编语言中的系统调用来绕过这个系统是多么的直接。很明显,编写一个坚不可摧的用户空间审计工具是不可能的,并且如果不侵入内核,就很难编写一个真正有用的工具。由于我不想维护补丁或处理漫长的重新编译-重启-调试周期,因此我认为在内核空间中执行此操作是不可行的。
我刚把这些担忧放在次要位置并开始这个项目,我就在本地 LUG 的邮件列表中看到了一条消息,这给了我一个想法。这条消息是关于内核模块漏洞利用的转发建议。这个特殊的模块是一个很糟糕的模块:它修改了某些系统调用的行为,以将自身隐藏在 lsmod 命令之外,并隐藏扫描器、黑客、嗅探器日志和其他此类文件的存在。我差点在我的办公室里尖叫“Eureka!”。我不必处理维护内核补丁、重新编译或重启;我可以将我的工具开发为可加载模块。我认识到模块漏洞利用背后的通用技术可以被改编为向系统调用添加多种类型的有用行为,包括不同的安全策略、比 UNIX 模型允许的更细粒度的安全性,当然还有我的审计跟踪生成器。
稍后我将讨论一些通过更改和包装系统调用可以做的有趣的事情,但让我们首先通过一个示例内核模块来亲自动手。这是一个简单的示例,类似于每个人最喜欢的第一个程序,但它演示了可加载内核模块最基本的部分,init_module 和 cleanup_module 函数
#include <linux/kernel.h> #include <linux/module.h> int init_module() { printk("<1> Hello, kernel!\n"); return 0; } void cleanup_module() { printk("<1>I'm not offended that you" "unloaded me. Have a pleasant day!\n"); }
您可能必须为符号 MODVERSIONS 使用 #define,并为文件 linux/modversions.h 使用 #include(来自 Linux 源代码树),具体取决于您的系统设置方式。将这个简短的模块命名为 hello.c,并使用以下命令编译它
gcc -c -DMODULE -D__KERNEL__ hello.c您现在应该在当前目录中有一个名为 hello.o 的文件。如果您当前在 X 中,请切换到虚拟控制台并(以 root 用户身份)键入 insmod hello.o。您应该在屏幕上看到“Hello, kernel!”。如果您想检查您的模块是否已加载,请使用 lsmod 命令;它应该显示您的 hello 模块已加载并占用内存。您现在可以 rmmod 这个模块;它会礼貌地通知您已卸载它。
linux/kernel.h 和 linux/module.h 头文件是任何模块开发中最基本的两个,对于您编写的任何模块,您都可能需要它们。最好是这些头文件(与 modversions.h 不同)来自 /usr/include/linux 而不是 Linux 源代码树。(如果您的发行版供应商已将 /usr/include/linux 链接到 Linux 源代码树,请投诉——这种做法很可能导致重大损坏和让您头痛。)对于任何实质性模块,您都将使用相当多的内核头文件,并且您会发现
grep -l /usr/include/linux
在开发模块时是一个好朋友。
将 init_module 视为模块的“对象构造函数”。init_module 应该分配存储空间、初始化数据并更改内核状态,以便您的模块可以执行其工作。在本例中,init_module 只是声明它的存在并返回 0 以表示成功,就像许多 C 函数一样。因此,我们的 hello 模块的初始化仅包括调用 printk 函数,这是一个特别方便的函数。本质上,它的功能类似于标准 C printf 函数,但有两个不同之处。首先,也是最明显的,printk 允许您为给定的消息指定优先级(尖括号中的“1”)。其次,printk 将其输出发送到一个循环缓冲区,该缓冲区由内核记录器使用,并(可能)发送到 syslogd。由于 syslog 的输出会频繁刷新,因此调用带有明智放置的高优先级消息的 printk 可以极大地帮助调试——尤其是在内核空间代码中的任何错误都可能导致机器崩溃或至少导致“内核 Oops”的情况下。
您可能会问,为什么不直接使用 printf 呢?很简单:这样做是不可能的。Linux 内核未链接到 C 库,因此像 printf 这样的老朋友在内核空间代码中不可用。但是,内核中有许多有用的例程可以为您提供类似于库例程的功能,包括 C 库中大多数 str 系列函数的工作方式。要在您的模块中使用这些,只需包含 linux/string.h(注意不要包含 C 库版本)。
如果 init_module 是构造函数,则 remove_module 是析构函数。务必尽可能仔细地整理模块;如果您不释放一些内存或恢复数据结构,您将必须重新启动才能使系统恢复正常。
现在我们毕业到一个更高级的示例。清单 1 展示了一个模块,该模块会在 uid 0(root 用户)或 uid 500(我在我的工作站上的用户)以外的任何人使用缓冲区中的某个位置包含单词“Linux”调用 write 系统调用时记录消息。您可能需要稍作努力才能找到单独使用此模块的用途,但我向您保证,它演示了几个有用的概念。我们能够通过用我们自己的函数替换 write 系统调用来完成所有这些操作,该函数执行检查和日志记录,然后调用 write。让我们逐步完成这个示例。
请注意所有包含文件。确实有很多,但不要绝望,我们要担心的文件是 linux/sched.h 和 asm/uaccess.h。sched.h 包含允许您通过 current 宏访问当前的 task_struct 结构,从而提供有关当前进程的大量有用信息(有关 task_struct 中一些有用字段的列表,请参见表 1),而 uaccess.h 提供了用于访问用户空间内存的有用宏(稍后会详细介绍)。
task_struct 中的这几个字段甚至足以启用一些非常有趣的模块。是否应该允许任意用户 su 到 root 用户?您可以通过包装 setuid 并检查几个预先指定的 UID 之一来防止他们这样做,然后再允许“真正的”setuid。这将允许您在内核级别开发一个等效于 wheel 组的工具,或允许 su root 的用户组。顺便说一句,FSF 长期以来一直认为 wheel 组是法西斯管理员的工具(有关更多信息,请参见 GNU su 的文档)。
能够仅根据哪个 uid 调用系统调用来审计或更改系统调用的行为显然是一种强大的能力。仔细控制和审计“nobody”用户及其朋友 uucp、mail 和 postgres 用户的操作可以制定良好的安全策略。然而,更强大的技术是根据参数更改行为。我们现在忽略 sys_call_table 和 origwrite,直接进入 wrapped_write,它检查调用进程的 uid 及其缓冲区参数。
您应该注意到的第一件事是 wrapped_write 以调用 kmalloc 开始。您可能会问,为什么不是 malloc 呢?请记住,我们仍然在内核空间中,并且我们无法访问 malloc 和其他标准库函数。即使我们可以访问,调用 malloc(它返回指向用户空间内存的指针)也是毫无价值的。我们需要在内核空间中分配一些内存,以便将数据从 buf 参数复制到其中。这是一个重点:内核空间和用户空间之间相同的内存可见性屏障可以防止您的程序使内核崩溃,但也为您的内核编程增加了一点复杂性。当您从 C 程序调用 write 时,您传递一个指向用户空间内存块的指针,该内存块无法从内核访问。因此,如果您想对用户空间指针指向的数据执行任何操作,您必须首先将该内存区域复制到内核空间。copy_from_user 宏为您执行此操作。copy_from_user 接受三个参数:一个“to”指针、一个“from”指针和一个计数。
考虑到我们对 current 和 task_struct 的了解,wrapped_write 的其余部分非常简单。也许一个更有趣的模块会使用 strstr 检查字符串“Linux sucks”,如果存在,则在该点更改 write_buf 以包含“Linux rule”,然后(使用 copy_to_user 宏)将 write_buf 传输回用户空间,然后再调用原始 write。然后,如果毫无戒心的用户写入“Linux sucks”,它将被替换为“Linux rules”。kfree 在这里很重要。在内核中泄漏内存是一件坏事,因此请务必 kfree 您 kmalloc 的所有内容。
在 init_module 中,我们实际上进行了切换,以便调用我们的函数而不是原始 write。回想一下,syss_call_table 是一个指向函数的指针数组。通过更改索引 SYS_write(表示 write 的系统调用号的常量)处的值,我们能够使另一个函数替换 write。请务必保存原始函数,以便在卸载模块时可以替换它!您可以通过编译并使用 insmod 安装它来测试此模块;然后 su 到 0 或 500 以外的某个用户,并键入
% echo "I like Linux"
在虚拟控制台上。您应该收到来自内核的消息,表明您又在谈论 Linux 了。恭喜!您现在可以开始使用执行某些有用的模块了。
清单 2 [可在 ftp://ftp.linuxjournal.com/pub/lj/listings/issue89/4829.tgz 获取] 展示了一个有用的模块,它可以帮助防止您的系统遭受堆栈粉碎攻击。堆栈粉碎攻击基本上包括写入超出固定大小缓冲区的末尾,以便覆盖当前函数的返回地址,通常跳转到 exec (/bin/sh, ...)。由于像 httpd、fingerd 或 wu-ftpd 这样的程序确实没有理由 exec shell,因此我们将提供一种机制来禁止它。至此,您已经掌握了理解大部分代码的知识,只有一个小例外:strncpy_from_user 函数。正如您可能期望的那样,它的功能非常类似于其 C 库对应物 strncpy,并且是从用户空间获取空终止字符串的便捷方法。由于代码很简单,我们将简要讨论该方法,然后让您自己提出改进系统安全性的绝妙想法。
清单 2 中的实现非常简单。它不像人们可能希望的那样高效或健壮,但编写此代码是为了清晰起见,并且通过将 wrapped_execve 中的线性搜索更改为更高效的搜索,可以轻松地使其更好。本质上,此模块所做的是重载 kill 系统调用,以便如果您向进程发送信号 42;它将被添加到“不安全”进程列表中,这些进程不应被允许执行文件名中包含“sh”的任何二进制文件。(42 是实时信号之一;您可能没有使用它。如果您正在使用它,请随意替换为 32 到 64 之间的任何数字。)然后,execve 系统调用会检查进程是否是不安全的进程,如果是,则检查它是否正在尝试执行 shell。如果是,则返回成功而不执行任何操作。为所有服务器进程使用此模块很容易;只需将其添加到您的 init 脚本中即可
kill -42 ...
清单 2 代表了清单 1 的一个进化步骤,但它表明可以修改调用的行为,而不仅仅是向调用路径添加行为。它也做了有用的工作。我希望您像我一样对编写内核模块漏洞利用程序来提高安全性感到兴奋。本文为您提供了入门的基本工具。幸运的是,Linux 程序员可以获得大量的文档,这将帮助您编写更复杂和功能更强大的模块;请参阅“资源”部分。
