本文是《Hooking Linux Kernel Functions, Part 1: Looking for the Perfect Solution》的翻译文章。
我们最近参与了一个Linux系统安全相关项目,需要hooking几个重要的Linux内核函数调用,例如打开文件和启动进程,并利用它来启用系统活动监控并抢先阻止可疑进程。
最后,我们发明了一种有效的方法,用于通过名称来hook内核中的任何函数,并在ftrace(Linux内核跟踪功能)的帮助下围绕其调用执行代码。 在这个由三部分组成的系列文章的第一部分中,描述了在提出新解决方案之前我们尝试hookLinux内核函数的四种方法。 并详细介绍了每种方法的主要优缺点。
可以尝试这几种方法来拦截关键的Linux内核函数:
下面,我们将详细讨论每个内核选项。
起初,我们认为使用Linux安全API的hook函数是最佳选择,因为这个接口就是为此而设计的。 内核代码的关键点包含安全函数调用,这些调用可能导致安全模块安装的回调。该模块可以研究特定操作的上下文,并决定是允许还是禁止它。
不幸的是,Linux Security API有两个主要限制:
虽然内核开发人员对系统是否可以包含多个安全模块有不同的看法,但是模块无法动态加载是可以肯定的事实。 为了确保系统从一开始就保持安全,安全模块必须是内核的一部分。
因此,为了使用Linux安全API,我们需要构建一个定制的Linux内核,并与AppArmor或SELinux集成额外的模块,后者在流行的发行版中使用。然而,这个选项并不适合我们的客户,因此我们得寻找另一个解决方案。
由于监控主要用于由用户应用程序执行的操作,所以我们可以在系统调用级别上实现它。 所有Linux系统调用处理程序都存储在sys_call_table表中。 更改此表中的值会导致更改系统的行为。 因此,我们可以通过保存旧的处理程序值并将自己的处理程序添加到表中来hook任何系统调用。
这种方法也有一些优点和缺点。 更改系统调用表中的值的主要优点如下:
不过,这种方法也有几个缺点:
技术实施较复杂。 虽然替换表中的值并不困难,但还有一些额外的任务需要某些条件和一些不明显的解决方案:
有些处理程序无法替换。在4.16版本之前的Linux内核中,x86_64体系结构的系统调用处理有一些额外的优化。其中一些优化要求在汇编中实现系统调用处理程序。这些类型的处理程序很难或不可能被用c语言编写的定制处理程序所替代。此外,不同的内核版本使用不同的优化,这一事实进一步增加了任务的技术复杂性。
只hook系统调用。由于此方法允许你替换系统调用处理程序,因此它极大地限制了入口点。 所有的附加检查都可以在系统调用之前或之后立即执行,我们只有系统调用参数及其返回值。 因此,有时我们可能需要仔细检查进程的访问权限和系统调用参数的有效性。 此外,在某些情况下,需要复制两次用户进程内存会产生额外的开销费用。 例如,当参数通过指针传递时,会有两个副本:一个是你自己创建的副本,另一个是由原始处理程序创建的。 有时,系统调用还提供低粒度的事件,因此还可能需要应用其他过滤器来消除噪音。
首先,我们尝试更改系统调用表,以便我们可以覆盖尽可能多的系统,我们甚至成功实现了这种方法。 但是x86_64体系结构有几个特定的功能,还有一些我们不知道的钩子调用限制。 确保支持与启动特定新进程相关的系统调用 - clone()和execve() - 对我们来说至关重要。 这就是我们继续寻找其他解决方案的原因。
我们剩下的选择之一是使用Kprobes - 一种专为Linux内核跟踪和调试而设计的特定API。 Kprobes允许你为任何内核指令以及函数入口和函数返回处理程序安装预处理程序和后处理程序。 处理程序可以访问寄存器并可以更改它们。 这样,我们就有机会监控工作流程并改变它。
使用Kprobes跟踪Linux内核函数的主要好处如下:
然而,Kprobes也有其缺点:
技术复杂性。 Kprobes只是在内核中的特定位置设置断点的工具。 要获取函数参数或局部变量值,你需要知道堆栈的确切位置以及它们所在的寄存器,并手动将它们移出。 此外,要阻止函数调用,还需要手动修改进程的状态,这样就可以让它认为它已经从函数返回了控制权。
Jprobes已被弃用。Jprobes是一个专门的kprobes版本,旨在使执行Linux内核跟踪变得更容易。 Jprobes可以从寄存器或堆栈中提取函数参数并调用你的处理程序,但处理程序和跟踪函数应该具有相同的签名。 唯一的问题是jprobes已被弃用,并已从最新的内核中删除。
开销太大。 即使这是一次性程序,定位断点也是非常昂贵的。虽然断点不影响其余功能,但它们的处理也相对昂贵。 幸运的是,通过使用为x86_64架构实现的跳转优化,可以显着降低使用kprobes的成本。 但是,kprobes的成本超过了修改系统调用表的成本。
Kretprobes的局限性。kretprobes功能是通过替换堆栈上的返回地址来实现的。为了在处理结束后回到原始地址,kretprobes需要在某个地方保留原始地址。 地址存储在固定大小的缓冲区中。 如果缓冲区过载,例如当系统执行跟踪函数的同时调用太多时,kretprobes将跳过某些操作。
禁用抢占。Kprobes基于处理器寄存器的中断和故障。因此,为了执行同步,所有处理程序都需要以禁用的抢占方式执行。因此,处理程序有几个限制:你不能在其中等待,这意味着不能分配大量内存、处理输入输出、在信号量和计时器中休眠等等。
不过,如果只需要跟踪函数内部的特定指令,那么kprobes肯定是有用的。
还有一种配置内核函数hooking的经典方法:将函数开头的指令替换为通向处理程序的无条件跳转。 原始指令被移动到不同的位置,并在跳回到截取的函数之前执行。 因此,只需两次跳转,就可以将代码拼接成一个函数。
此方法的工作方式与kprobes跳转优化的方式相同。使用拼接,也可以获得与使用kprobes相同的结果,但开销更低,并且可以完全控制流程。
使用拼接的优点非常明显:
然而,这种方法有一个主要缺点 - 技术复杂性。 更换函数中的机器代码并不容易。 为了使用拼接,你需要完成以下几项操作:
当然,你也可以使用livepatch框架并查看kprobes的一些提示,但最终的解决方案仍然过于复杂。 此解决方案的每个新实现都会包含太多的睡眠问题。
如果你已准备好处理隐藏在代码中的这些恶魔,那么拼接可能是一种非常有用的方法来hook Linux内核函数。 但由于我们不喜欢这个选项,我们将其作为替补方案,以防我们找不到更好的选择。
当我们研究这个主题时,我们注意到了Linux ftrace,一个可用于跟踪Linux内核函数调用的框架。 虽然使用ftrace执行Linux内核跟踪是常见的做法,但此框架也可以用作jprobes的替代方案。 事实证明,ftrace比jprobes更适合跟踪函数调用的需求。
Ftrace允许我们通过名称hook关键Linux内核函数,并且可以在不重建内核的情况下安装钩子。 在本系列的下一部分中,我们将更多地讨论ftrace。 什么是ftrace? ftrace如何运作? 我们将回答这些问题,并为你提供详细的ftrace示例,以便你更好地了解该过程。 我们还会告诉你主要的优缺点。 等待本系列的第二部分了解有关这种不寻常方法的更多细节。
有许多方法可以尝试在Linux内核中hook关键函数。 我们已经描述了完成这项任务的四种最常用的方法,并解释了每种方法的优点和缺点。 在我们的三部分系列的下一部分中,我们将告诉你更多关于我们的专家团队最终提出的解决方案 - 使用ftrace hook Linux内核函数。
有什么问题吗? 您可以在此处了解有关我们Linux内核开发经验的更多信息。