使用ftrace拦截Linux内核中的函数

忍者企鹅(En3l) 在与Linux系统安全性相关的一个项目中,我们需要拦截对内核内部重要功能的调用(例如打开文件和运行的进程),以提供监视系统活动并预防性地阻止可疑进程活动的能力。

在开发过程中,我们设法发明了一种很好的方法,该方法使我们能够按名称方便地拦截内核中的任何函数,并在其调用周围执行代码。 可以从可加载的GPL模块安装拦截器,而无需重建内核。 该方法支持用于x86_64体系结构的3.19+内核。

(上图的企鹅图像: ©En3l和DeviantArt 。)

已知方法


Linux安全性API


最正确的方法是使用Linux Security API ,这是专门为这些目的而创建的特殊接口。 在内核代码的关键位置,找到了对安全功能的调用,这些安全功能又依次调用了安全模块设置的回调。 安全模块可以检查操作的上下文并决定是允许还是拒绝操作。

不幸的是,Linux安全性API有两个重要的限制:

  • 安全模块不能动态加载,它是内核的一部分,需要重建
  • 系统中只能有一个安全模块(有一些例外)

如果内核开发人员对于模块的多样性持模棱两可的态度,则禁止动态加载是根本的:安全模块必须是内核的一部分,以便从加载时刻起就不断确保安全性。

因此,要使用Security API,必须提供自己的内核程序集,并将附加模块与SELinux或AppArmor集成在一起,流行的发行版都使用该模块。 客户不想订阅此类义务,因此该路线已关闭。

由于这些原因,安全性API不适合我们,否则将是理想的选择。

修改系统调用表


监视主要是针对用户应用程序执行的操作,因此,原则上可以在系统调用级别实施监视。 如您所知,Linux将所有系统调用处理程序存储在sys_call_table表中。 将该表中的值替换会导致整个系统的行为发生变化。 因此,保留处理程序的旧值并在表中替换我们自己的处理程序,我们可以拦截任何系统调用。

这种方法具有某些优点:

  • 完全控制任何系统调用 -用户应用程序到内核的唯一接口。 使用它,我们可以确保我们不会错过用户流程执行的任何重要操作。
  • 最小的开销。 更新系统调用表时需要一笔一次性的资本投资。 除了不可避免的监视有效负载外,唯一的花费是额外的函数调用(调用原始系统调用处理程序)。
  • 最低内核要求。 如果需要,此方法在内核中不需要任何其他配置选项,因此从理论上讲,它支持尽可能广泛的系统。

但是,他也有一些缺点:

  • 实施的技术复杂性。 就其本身而言,替换表中的指针并不困难。 但是相关任务需要非显而易见的解决方案和一定的资格:
    • 搜索系统调用表
    • 表修改保护旁路
    • 原子和安全的替换

    这些都是有趣的事情,但是它们需要宝贵的开发时间,首先是实施,然后是支持和理解。
  • 无法拦截某些处理程序。 在4.16版之前的内核中,针对x86_64体系结构的系统调用处理包含许多优化。 他们中的一些人要求系统调用处理程序是在汇编程序中实现的特殊适配器。 因此,此类处理程序有时很困难,有时甚至无法用C语言编写的您自己的处理程序来替换。 此外,在不同版本的内核中使用了不同的优化,这增加了储钱罐的技术难度。
  • 仅系统调用被拦截。 这种方法允许您替换系统调用处理程序,从而将入口点限制为仅它们。 所有其他检查都在开始或结束时执行,而我们只有系统调用的参数及其返回值。 有时这导致需要重复检查参数和访问检查是否足够。 有时,当您需要两次复制用户进程的内存时,它会导致不必要的开销:如果参数是通过指针传递的,那么我们首先必须自己复制它,然后原始处理程序将再次为其自身复制参数。 另外,在某些情况下,系统调用提供的事件粒度太低,必须从噪声中另外过滤掉事件。

最初,我们选择并成功实施了这种方法,以追求支持最多数量系统的好处。 但是,那时我们仍然不知道x86_64的功能以及对被拦截呼叫的限制。 后来发现,对于我们来说,支持与启动新进程相关的系统调用(clone()和execve())非常重要,这很特别。 这就是导致我们寻求新选项的原因。

使用kprobes


被考虑的选项之一是使用kprobes :一种专门设计用于调试和跟踪内核的专用API。 该接口允许您为内核中的任何指令设置前置和后处理器,以及为函数输入和返回的处理器。 处理程序可以访问寄存器并可以更改它们。 这样,我们既可以进行监视,又可以影响以后的工作过程。

使用kprobes进行拦截的好处:

  • 成熟的API。 自远古时代(2002年)以来,Kprobes已经存在并得到了改善。 他们有一个文档齐全的界面,已经发现了大多数陷阱,并对其工作进行了尽可能多的优化,依此类推。 通常,与实验性的自制自行车相比,它具有很多优势。
  • 截取核心中的任何位置。 Kprobes是使用嵌入在内核可执行代码中的断点(int3指令)实现的。 这样,您可以在任何功能中的任何位置安装kprobes(如果已知)。 同样,kretprobes是通过欺骗堆栈上的返回地址来实现的,并允许您拦截任何函数的返回(原则上不返回控制的函数除外)。

kprobes的缺点:

  • 技术难度。 Kprobes只是在内核中任何地方设置断点的一种方法。 要获取函数的参数或局部变量的值,您需要知道它们位于哪个寄存器中或位于堆栈中的位置,并从那里独立提取它们。 要阻止函数调用,您必须手动修改进程的状态,以便处理器认为它已经从函数中返回了控制。
  • Jprobes已过时。 Jprobes是kprobes的附加组件,它使您可以方便地拦截函数调用。 它将从寄存器或堆栈中独立提取函数的参数,并调用您的处理程序,该处理程序应具有与挂钩函数相同的签名。 问题在于,不推荐使用jprobes并从现代内核中删除它们。
  • 非同寻常的开销。 断点很昂贵,但是只有一次。 断点不影响其他功能,但是它们的处理相对昂贵。 幸运的是,为x86_64体系结构实现了跳转优化,这大大降低了kprobes的成本,但与修改系统调用表时相比,它仍然具有更大的优势。
  • kretprobes的局限性。 Kretprobes是通过欺骗堆栈上的返回地址来实现的。 因此,他们需要将原始地址存储在某处,以便在处理kretprobe之后返回该地址。 地址存储在固定大小的缓冲区中。 如果发生溢出,当系统中同时执行太多被拦截函数的调用时,kretprobes将跳过操作。
  • 禁用挤出。 由于kprobes基于中断并处理处理器寄存器,因此为了进行同步,所有处理程序都在禁用抢占的情况下执行。 这对处理程序施加了某些限制:您不能在处理程序中等待-分配大量内存,执行I / O,在计时器和信号灯中睡眠以及其他已知的事情。

在研究该主题的过程中,我们的目光投向了可以代替jprobes的ftrace框架。 事实证明,它可以更好地满足我们的函数调用拦截需求。 但是,如果您需要在函数中跟踪特定的指令,则不应忽略kprobes。

拼接


为了完整起见,还值得描述截取函数的经典方法,该方法包括用导致我们的处理程序的无条件转换替换函数开头的指令。 原始指令将转移到另一个位置并执行,然后再返回到拦截的功能。 在两次转换的帮助下,我们将附加代码嵌入(拼接)到函数中,因此这种方法称为splicing

这就是实现kprobes的跳转优化的方式。 使用拼接,您可以实现相同的结果,但无需花费kprobes的额外费用,并且可以完全控制情况。

拼接的好处显而易见:

  • 最低内核要求。 拼接在内核中不需要任何特殊选项,并且可以在任何函数开始时使用。 您只需要知道她的地址即可。
  • 最小的开销。 两个无条件的转换-就是被拦截的代码需要执行的所有操作,才能将控制权转移给处理程序,反之亦然。 这样的过渡由处理器完美地预测并且非常便宜。

但是,此方法的主要缺点严重影响了画面:

  • 技术难度。 她翻身。 您不仅可以获取并重写机器代码。 这是要解决的任务的简短且不完整的列表:
    • 安装和移除拦截的同步(如果在替换其指令的过程中直接调用该函数,该怎么办?)
    • 通过代码修改存储区域的保护绕过
    • 替换指令后CPU缓存无效
    • 分解可替换的说明以将其完整复制
    • 检查更换的零件内部是否没有过渡
    • 检查将替换的零件移动到另一个位置的能力

    是的,您可以监视kprobes并使用livepatch核内框架,但是最终解决方案仍然非常复杂。 很难想象每个新实现中会有多少睡眠问题。

通常,如果您能够调用此恶魔(仅服从初始化对象),并准备在代码中忍受它,则拼接是截取函数调用的一种完全有效的方法。 我对编写自行车持消极态度,因此,如果现成的解决方案变得更简单,则根本无法取得进展,那么该选项对我们仍然是一个备份。

ftrace的新方法


Ftrace是功能级别的内核跟踪框架。 它从2008年开始开发,具有用于用户程序的出色界面。 Ftrace允许您跟踪函数调用的频率和持续时间,显示调用图,按模板过滤感兴趣的函数,等等。 您可以从此处开始阅读有关ftrace功能的信息 ,然后按照链接和官方文档进行操作。

它基于编译器键-pg-mfentry实现ftrace,这两个键在每个函数的开头插入对特殊跟踪函数mcount()或__fentry __()的调用。 通常,在用户程序中,分析器使用此编译器功能来跟踪对所有函数的调用。 内核使用这些功能来实现ftrace框架。

当然,从每个函数调用ftrace并不便宜,因此可以对流行的体系结构进行优化: dynamic ftrace 。 最重要的是,内核知道所有对mcount()或__fentry __()的调用的位置,并且在加载的早期阶段将其机器代码替换为nop-一种不执行任何操作的特殊指令。 当所需的函数中包含跟踪时,ftrace调用将被添加回去。 因此,如果不使用ftrace,则它对系统的影响是最小的。

所需功能的说明


每个截获的函数可以通过以下结构描述:

 /** * struct ftrace_hook -    * * @name:    * * @function:  -,     *   * * @original:   ,     *  ,    * * @address:   ,    * * @ops:   ftrace,  , *      */ struct ftrace_hook { const char *name; void *function; void *original; unsigned long address; struct ftrace_ops ops; }; 

用户只需要填写前三个字段:名称,功能,原始字段。 其余字段被视为实现细节。 所有拦截函数的描述都可以组装成一个数组,并且宏可以用来提高代码的紧凑性:

 #define HOOK(_name, _function, _original) \ { \ .name = (_name), \ .function = (_function), \ .original = (_original), \ } static struct ftrace_hook hooked_functions[] = { HOOK("sys_clone", fh_sys_clone, &real_sys_clone), HOOK("sys_execve", fh_sys_execve, &real_sys_execve), }; 

拦截函数的包装如下:

 /* *        execve(). *     .      *  :       , *    ABI (  "asmlinkage"). */ static asmlinkage long (*real_sys_execve)(const char __user *filename, const char __user *const __user *argv, const char __user *const __user *envp); /* *      .   —  *   .      *  .      ,  *    . */ static asmlinkage long fh_sys_execve(const char __user *filename, const char __user *const __user *argv, const char __user *const __user *envp) { long ret; pr_debug("execve() called: filename=%p argv=%p envp=%p\n", filename, argv, envp); ret = real_sys_execve(filename, argv, envp); pr_debug("execve() returns: %ld\n", ret); return ret; } 

如您所见,拦截的函数具有最少的额外代码。 唯一需要仔细注意的是函数签名。 它们必须一对一匹配。 显然,没有这个,论点将被错误地传递,一切将变得艰难。 拦截系统调用的重要性不那么重要,因为它们的处理程序非常稳定,并且为了提高效率,以与系统调用自身相同的顺序接受参数。 但是,如果您打算拦截其他函数,则应记住, 内核内部没有稳定的接口

Ftrace初始化


首先,我们需要找到并保存将拦截的函数的地址。 Ftrace允许您按名称跟踪函数,但是我们仍然需要知道原始函数的地址才能调用它。

您可以使用kallsyms获得地址-内核中所有字符的列表。 该列表包括所有字符,不仅是为模块导出的。 获取钩子函数的地址看起来像这样:

 static int resolve_hook_address(struct ftrace_hook *hook) { hook->address = kallsyms_lookup_name(hook->name); if (!hook->address) { pr_debug("unresolved symbol: %s\n", hook->name); return -ENOENT; } *((unsigned long*) hook->original) = hook->address; return 0; } 

接下来,您需要初始化ftrace_ops结构。 具有约束力
该字段只是func ,表示回调,但我们还需要
设置一些重要标志:

 int fh_install_hook(struct ftrace_hook *hook) { int err; err = resolve_hook_address(hook); if (err) return err; hook->ops.func = fh_ftrace_thunk; hook->ops.flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_IPMODIFY; /* ... */ } 

fh_ftrace_thunk ()是ftrace在跟踪函数时将调用的回调。 稍后关于他。 我们设置的标志是完成拦截所必需的。 它们指示ftrace保存和恢复处理器寄存器,我们可以在回调中更改其内容。

现在我们准备启用拦截。 为此,您必须首先使用ftrace_set_filter_ip()为我们感兴趣的功能启用ftrace,然后允许ftrace使用register_ftrace_function()调用回调:

 int fh_install_hook(struct ftrace_hook *hook) { /* ... */ err = ftrace_set_filter_ip(&hook->ops, hook->address, 0, 0); if (err) { pr_debug("ftrace_set_filter_ip() failed: %d\n", err); return err; } err = register_ftrace_function(&hook->ops); if (err) { pr_debug("register_ftrace_function() failed: %d\n", err); /*    ftrace   . */ ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0); return err; } return 0; } 

拦截功能类似地被关闭,只是顺序相反:

 void fh_remove_hook(struct ftrace_hook *hook) { int err; err = unregister_ftrace_function(&hook->ops); if (err) { pr_debug("unregister_ftrace_function() failed: %d\n", err); } err = ftrace_set_filter_ip(&hook->ops, hook->address, 1, 0); if (err) { pr_debug("ftrace_set_filter_ip() failed: %d\n", err); } } 

在完成对unregister_ftrace_function()的调用之后,可以确保系统(及其包装程序)中没有激活已安装的回调。 因此,例如,我们可以安全地卸载拦截器模块,而不必担心我们系统中的某些功能仍在执行(因为如果它们消失了,处理器将会感到不安)。

执行功能挂钩


拦截实际上是如何进行的? 很简单 Ftrace允许您在退出回调后更改寄存器的状态。 通过更改%rip寄存器(指向下一条可执行指令的指针),我们可以更改处理器执行的指令-也就是说,我们可以强制其执行从当前功能到我们函数的无条件转换。 这样我们就可以控制了。

ftrace的回调如下:

 static void notrace fh_ftrace_thunk(unsigned long ip, unsigned long parent_ip, struct ftrace_ops *ops, struct pt_regs *regs) { struct ftrace_hook *hook = container_of(ops, struct ftrace_hook, ops); regs->ip = (unsigned long) hook->function; } 

使用container_of()宏,我们获得嵌入在其中的struct ftrace_hook的地址的struct ftrace_hook的地址,然后用处理程序的地址替换struct pt_regs结构中的%rip寄存器值。 仅此而已。 对于x86_64以外的体系结构,可以以不同的方式调用此寄存器(例如IP或PC),但是该思想原则上适用于它们。

请注意为回调添加了notrace限定词 。 它们可以标记不允许使用ftrace跟踪的功能。 例如,这就是跟踪过程中涉及的ftrace自身功能的标记方式。 这有助于防止跟踪内核中的所有功能时系统陷入无限循环(ftrace可以做到这一点)。

ftback回调通常在禁用挤压的情况下进行调用(例如kprobes)。 可能会有例外,但是您不应该依赖它们。 但是,对于我们而言,此限制并不重要,因此我们仅替换结构中的八个字节。

稍后将调用的包装器函数将在与原始函数相同的上下文中执行。 因此,您可以在其中执行拦截函数中允许执行的操作。 例如,如果拦截中断处理程序,则仍然无法在包装器中休眠。

递归呼叫保护


上面的代码有一个陷阱:当我们的包装器调用原始函数时,它将再次陷入ftrace,后者将再次调用我们的回调,这将把控制权再次传递给包装器。这种无限递归需要以某种方式缩短。

对我们来说,最优雅的方法是使用parent_ipftrace回调的参数之一,该参数包含调用跟踪函数的函数的返回地址。通常,此参数用于构造函数调用图。我们可以使用它来区分被重复调用函数的第一个调用。

确实,在回拨时parent_ip应该指向我们的包装内部,而在第一个位置-内核中另一个位置。只有在首次调用该函数时,才应转移控制权,而应允许所有其他函数执行原始函数。

通过将地址与当前模块(包含我们所有功能)的边界进行比较,可以非常有效地执行条目检查。如果仅在模块中包装程序调用被拦截的函数,则此方法非常有用。否则,您需要更具选择性。

总的来说,正确的ftrace回调如下:

 static void notrace fh_ftrace_thunk(unsigned long ip, unsigned long parent_ip, struct ftrace_ops *ops, struct pt_regs *regs) { struct ftrace_hook *hook = container_of(ops, struct ftrace_hook, ops); /*      . */ if (!within_module(parent_ip, THIS_MODULE)) regs->ip = (unsigned long) hook->function; } 

这种方法的特色/优势:

  • 低开销。只是一些减去和比较。没有自旋锁,列表传递等。
  • . . , .
  • . kretprobes , ( ). , .


让我们看一个例子:您在终端中键入ls命令以查看当前目录中的文件列表。外壳程序(例如Bash)使用C标准库中的一对传统的fork()+ execve()函数来启动新进程。在内部,这些功能分别通过系统调用clone()execve()实现。假设我们拦截execve()系统调用以控制新进程的启动。

以图形形式,处理程序函数的截取如下所示:

截取顺序图

在这里,我们看到用户进程(蓝色如何对内核(红色)进行系统调用),其中ftrace框架(紫色)从我们的模块(绿色调用函数

  1. 用户进程执行SYSCALL。使用此指令,将转移内核模式,并将控制权转移到低级系统调用处理程序-entry_SYSCALL_64()。他负责64位内核上的所有64位程序的系统调用。
  2. . , , do_syscall_64 (), . sys_call_tablesys_execve ().
  3. ftrace. __fentry__ (), ftrace. , , nop , sys_execve() .
  4. Ftrace . ftrace , . , %rip, .
  5. . parent_ip , do_syscall_64() — sys_execve() — , %rip pt_regs .
  6. Ftrace . FTRACE_SAVE_REGS, ftrace pt_regs . ftrace . %rip — — .
  7. -. - sys_execve() . fh_sys_execve (). , do_syscall_64().
  8. . . fh_sys_execve() ( ) . . — sys_execve() , real_sys_execve , .
  9. . sys_execve(), ftrace . , -…
  10. . sys_execve() fh_sys_execve(), do_syscall_64(). sys_execve() . : ftrace sys_execve() .
  11. . sys_execve() fh_sys_execve(). . , execve() , , , . .
  12. 管理回到核心。最后,fh_sys_execve()完成,控制权传递给do_syscall_64(),后者认为系统调用照常完成。核心继续其核业务。
  13. 管理返回到用户流程。最后,内核执行IRET指令(或SYSRET,但对于execve(),它始终是IRET),为新的用户进程设置寄存器,并使中央处理器进入用户代码执行模式。系统调用(并开始新过程)已完成。

优缺点


结果,我们获得了一种非常方便的方法来拦截内核中的任何函数,该方法具有以下优点:

  • API . . , , . — -, .
  • . . - , , , - . ( ), .
  • 侦听与跟踪兼容。显然,此方法与ftrace不冲突,因此您仍然可以从内核中获取非常有用的性能指标。使用kprobes或剪接会干扰ftrace机制。

该解决方案的缺点是什么?

  • 内核配置要求。要使用ftrace成功执行函数挂钩,内核必须提供许多功能:
    • kallsyms字符列表,用于按名称搜索功能
    • 一般用于跟踪的ftrace框架
    • ftrace关键拦截选项

    . , , , , . , - , .
  • ftrace , kprobes ( ftrace ), , , . , ftrace — , «» ftrace .
  • . , . , , ftrace . , , .
  • 两次调用ftrace。上面描述的指针分析方法parent_ip再次导致对钩子函数的ftrace调用。这会增加一些开销,并且会中断其他跟踪,而这些跟踪将看到两倍的呼叫。可以通过施加一点黑魔法来避免此缺点:ftrace调用位于函数的开头,因此,如果将原始函数的地址向前移5个字节(调用指令的长度),则可以跳过ftrace。

更详细地考虑一些缺点。

内核配置要求


对于初学者,内核必须支持ftrace和kallsyms。为此,必须启用以下选项:

  • 配置文件
  • CONFIG_KALLSYMS

然后,ftrace应该支持动态寄存器修改。该选项对此负责。

  • CONFIG_DYNAMIC_FTRACE_WITH_REGS

此外,使用的内核必须基于3.19或更高版本,才能访问FTRACE_OPS_FL_IPMODIFY标志。较早版本的内核也可以替换%rip寄存器,但是从3.19开始,只有在设置该标志后才能执行此操作。旧内核标志的存在将导致编译错误,而新内核标志的缺失将导致空闲拦截。

最后,要执行拦截,ftrace调用的位置至关重要:该调用必须位于函数序言之前的最开始处(在该序言中为局部变量分配空间并形成堆栈框架)。该选件考虑了此体系结构功能

  • CONFIG_HAVE_FENTRY

x86_64体系结构支持此选项,但i386不支持。由于i386体系结构的限制,编译器无法在函数序言之前插入ftrace调用,因此,在调用ftrace时,函数堆栈已被修改。在这种情况下,要进行拦截,仅改变%eip寄存器的值是不够的-您还必须撤消在序言中执行的所有因函数而异的所有操作。

因此,ftrace拦截不支持x86 32位体系结构。原则上,可以借助某些黑魔法(生成并执行“反序言”)来实现它,但是此解决方案的技术简单性将受到损害,这是使用ftrace的优点之一。

不明显的惊喜


在测试期间,我们遇到了一个有趣的功能:在某些发行版中,挂钩函数导致系统崩溃。自然,这仅发生在开发人员使用的系统以外的系统上。该问题也没有在具有任何发行版和内核版本的原始拦截原型上重现。

调试表明,挂起发生在拦截的函数内部。出于某种神秘的原因,当在ftrace回调内部调用原始函数时,将parent_ip继续在内核代码中而不是包装函数代码中指定地址。因此,出现了一个无限循环,因为ftrace一遍又一遍地调用了我们的包装器,而没有执行任何有用的动作。

幸运的是,我们既可以使用工作代码,也可以使用损坏的代码,因此找到差异只是时间问题。统一代码并丢弃所有不必要的内容后,将版本之间的差异本地化为包装函数。

此选项有效:

 static asmlinkage long fh_sys_execve(const char __user *filename, const char __user *const __user *argv, const char __user *const __user *envp) { long ret; pr_debug("execve() called: filename=%p argv=%p envp=%p\n", filename, argv, envp); ret = real_sys_execve(filename, argv, envp); pr_debug("execve() returns: %ld\n", ret); return ret; } 

但是这个-挂了系统:

 static asmlinkage long fh_sys_execve(const char __user *filename, const char __user *const __user *argv, const char __user *const __user *envp) { long ret; pr_devel("execve() called: filename=%p argv=%p envp=%p\n", filename, argv, envp); ret = real_sys_execve(filename, argv, envp); pr_devel("execve() returns: %ld\n", ret); return ret; } 

事实证明,日志记录级别会影响行为吗?对这两个函数的机器代码进行仔细研究后,很快就弄清了情况,并引起了编译器的责任感。通常,他在宇宙射线附近的可疑名单上,但这次不在。

事实证明,对pr_devel()的调用被扩展为void。此版本的printk宏用于在开发过程中进行日志记录。这样的日志条目在操作过程中并不有趣,因此,如果未声明DEBUG宏,则会自动从代码中删除它们。之后,编译器的功能变为:

 static asmlinkage long fh_sys_execve(const char __user *filename, const char __user *const __user *argv, const char __user *const __user *envp) { return real_sys_execve(filename, argv, envp); } 

优化就在这里。在这种情况下,它的工作所谓的优化尾调用(尾调用优化)。如果一个函数调用另一个函数并立即返回其值,则它允许编译器用直接跳转到其主体的方式替换诚实函数调用。在机器代码中,诚实的呼叫看起来像这样:

 0000000000000000 <fh_sys_execve>: 0: e8 00 00 00 00 callq 5 <fh_sys_execve+0x5> 5: ff 15 00 00 00 00 callq *0x0(%rip) b: f3 c3 repz retq 

和无效-像这样:

 0000000000000000 <fh_sys_execve>: 0: e8 00 00 00 00 callq 5 <fh_sys_execve+0x5> 5: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax c: ff e0 jmpq *%rax 

第一个CALL语句与编译器在所有函数的开头插入的__fentry __()调用相同。但是在普通代码中,您还可以通过CALL指令看到对real_sys_execve的调用(通过内存中的指针),并使用RET指令从fh_sys_execve()返回。损坏的代码直接使用JMP转到real_sys_execve()函数。

通过优化尾部调用,您可以节省一些无意义的堆栈帧,其中包括CALL指令存储在堆栈中的返回地址。但是,对于我们来说,回信地址的正确性起着至关重要的作用-我们用它parent_ip来做出有关拦截的决定。经过优化后,fh_sys_execve()函数不再将新的返回地址保存在堆栈上,而是保留了旧的-指向内核。因此parent_ip继续指向原子核内部,最终导致形成无限循环。

这也解释了为什么该问题仅在某些发行版中出现。编译模块时,不同的发行版使用不同的编译标志集。在遇险发行版中,默认情况下启用了尾部调用优化。

解决该问题的方法是使用包装函数禁用整个文件的尾部调用优化:

 #pragma GCC optimize("-fno-optimize-sibling-calls") 

结论


我还能说什么呢?为Linux内核开发低级代码很有趣。我希望此出版物能为某人节省一些时间,以帮助他们编写出世界上最好的防病毒软件。

如果您想自己尝试拦截,那么可以在Github上找到完整的内核模块代码

Source: https://habr.com/ru/post/zh-CN413241/


All Articles