Linux内核中KRETPROBES与OPTIMIZER故障的简短解析
在LKRG开发过程中,我发现:
- KRETPROBES自内核5.8版本起出现故障(将在即将发布的内核中修复)
- OPTIMIZER自内核5.5版本以来未能充分执行其任务
首先介绍KPROBES和FTRACE
Linux内核提供了两个出色的钩子框架——KPROBES和FTRACE。KPROBES是较老且经典的框架,于2.6.9版本(2004年10月)引入。而FTRACE是较新的接口,与KPROBES相比可能具有更小的开销。我使用“KPROBES”一词是因为内核中提供了多种类型的KPROBES,包括JPROBES、KRETPROBES或经典KPROBES。KPROBES本质上允许动态中断任何内核例程。各种K*PROBES之间的区别如下:
- KPROBES:可以放置在内核中几乎任何指令上
- JPROBES:使用KPROBES实现,主要思想是采用简单的镜像原理以无缝访问被探测函数的参数。但自2017年起,JPROBES已被弃用
- KRETPROBES:有时称为“返回探测”,它们也在底层使用KPROBES。KRETPROBES允许在钩子函数的入口和返回路径上轻松执行用户自己的例程。但KRETPROBES不能放置在任意指令上
当注册KPROBE时,它会复制被探测的指令,并用断点指令(例如,在i386和x86_64上的int3)替换被探测指令的第一个字节。
FTRACE相对于K*PROBES是较新的,最初在内核2.6.27中引入,于2008年10月9日发布。FTRACE的工作方式完全不同,其主要思想基于检测每个编译的函数(注入“长NOP”指令——GCC的“-pg”选项)。当在特定函数上注册FTRACE时,此类“长NOP”将被替换为指向蹦床代码的JUMP指令。随后,该蹦床可以执行任何预先注册的用户定义钩子。
关于Linux内核运行时防护(LKRG)的简要说明
简而言之,LKRG执行Linux内核的运行时完整性检查(类似于Microsoft的PatchGuard技术)并检测针对内核的各种漏洞利用。LKRG尝试后检测并迅速响应运行中Linux内核的未经授权修改(系统完整性)或任务完整性的损坏,例如凭据(用户/组ID)、SECCOMP/沙盒规则、命名空间等。为了能够实现此类功能,LKRG必须在内核中放置各种钩子。KRETPROBES用于满足该要求。
LKRG在FTRACE检测函数上的KPROBE
细心的读者可能会提出一个有趣的问题:如果函数由FTRACE检测(注入“长NOP”),并且有人在其上注册KPROBES,会发生什么?动态注册的FTRACE是否会“覆盖”安装在该函数上的KPROBES,反之亦然?
从LKRG的角度来看,这是非常常见的情况,因为它在许多系统调用上放置了KRETPROBES。Linux内核在这种情况下使用特殊类型的K*PROBES,称为“基于FTRACE的KPROBES”。本质上,这种特殊的KPROBE使用FTRACE基础设施,与KPROBES本身关系不大。这很有趣,因为它也受FTRACE规则约束,例如,如果禁用FTRACE基础设施,这种特殊的KPROBE也将无法工作。
OPTIMIZER
Linux内核开发者更进一步,他们积极“优化”所有K*PROBES以使用FTRACE代替。这背后的主要原因是性能——FTRACE具有更小的开销。如果由于任何原因此类KPROBE无法优化,则使用经典的老式KPROBES基础设施。
当分析LKRG放置的所有KRETPROBES时,您会意识到在现代内核上,所有这些KRETPROBES都被转换为某种类型的FTRACE。
LKRG报告误报
经过如此长的介绍,我们终于可以进入本文的主题。ALT Linux的Vitaly Chikunov报告说,当他运行FTRACE压力测试器时,LKRG报告.text部分损坏:
我花了几个星期(一个月以上)让LKRG检测并接受通过FTRACE放置的内核代码的授权第三方修改。当我最终完成那项工作时,我意识到此外,我需要保护全局FTRACE旋钮(sysctl kernel.ftrace_enabled),它允许root在运行的系统上完全禁用FTRACE。否则,LKRG的钩子可能会在不知情的情况下被禁用,这不仅会禁用其保护(在信任主机root的威胁模型下可以接受),还可能导致误报(因为如果没有钩子,LKRG将不知道哪些修改是合法的)。我添加了该功能,一切工作正常……直到内核5.9。这完全让我感到惊讶。我在FTRACE逻辑中没有看到5.8.x和5.9.x之间的任何重大变化。我花了一些时间在这上面,最终意识到我的全局FTRACE旋钮保护在最新内核(自5.9起)停止工作。然而,此代码在内核5.8.x和5.9.x之间没有更改。神秘之处是什么?
第一个问题——KRETPROBES出现故障
从内核5.8开始,所有未优化的KRETPROBES都无法工作。直到5.8,当引发#DB异常时,进入NMI的操作并未完全执行。其中,执行了以下逻辑:https://elixir.bootlin.com/linux/v5.7.19/source/arch/x86/kernel/traps.c#L589
|
|
在一些旧内核中,改为调用函数ist_enter()。在此函数内部,我们可以看到以下逻辑:https://elixir.bootlin.com/linux/v5.7.19/source/arch/x86/kernel/traps.c#L91
|
|
正如注释所说,“我们不希望in_nmi()返回true,但我们需要通知RCU。”。然而,自内核5.8以来,中断处理逻辑被修改,目前我们有这个(函数“exc_int3”):https://elixir.bootlin.com/linux/v5.8/source/arch/x86/kernel/traps.c#L630
|
|
随后被这个提交修改: https://github.com/torvalds/linux/commit/8edd7e37aed8b9df938a63f0b0259c70569ce3d2
这就是我们自5.8以来所有内核中的当前状态。本质上,自这些提交以来,KRETPROBES无法工作。我们有以下逻辑:
|
|
本质上,exc_int3()调用nmi_enter(),而pre_handler_kretprobe()在调用任何已注册的KPROBE之前通过in_nmi()调用验证是否不在NMI中。
我已将此问题报告给维护者,并已得到解决和正确修复。这些补丁将被回溯到稳定树(并希望也回溯到LTS内核): https://lists.openwall.net/linux-kernel/2020/12/09/1313
然而,回到LKRG的原始问题……我在内核5.8.x中没有看到任何问题,但在5.9.x中看到了问题。这很有趣,因为KRETPROBES在5.8.x中也已损坏。那么发生了什么?
正如我在文章开头提到的,K*PROBES被积极优化并转换为FTRACE。在内核5.8.x中,LKRG的钩子被正确优化,根本没有使用KRETPROBES。这就是为什么我没有在这个版本中看到任何问题。然而,由于某些原因,这种优化在内核5.9.x中不可能。这导致放置了我们知道已损坏的经典未优化KRETPROBES。
第二个问题——OPTIMIZER不再充分执行任务
我没有看到关于OPTIMIZER的源代码有任何变化,钩子函数本身也没有变化。然而,当我查看生成的vmlinux二进制文件时,我看到GCC使用INT3操作码在钩子函数的末尾生成了一个填充:
|
|
在旧内核的生成映像中,此函数中不存在此类填充。然而,此类填充相当常见。
OPTIMIZER逻辑在此处失败:
|
|
其中一个检查试图防止另一种情况,即另一个子系统也在那里放置断点:
|
|
然而,这里的情况并非如此。INT3_INSN_OPCODE作为填充放置在函数的末尾。我想找出为什么在新内核中INT3填充更常见,而旧内核中却不是这样,即使我使用完全相同的编译器和链接器。我开始浏览提交,并找到了这个:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7705dc8557973d8ad8f10840f61d8ec805695e9e
|
|
看起来INT3现在是链接器使用的默认填充。
我已向Linux内核开发者(KPROBES所有者)提出了这个问题,Masami Hiramatsu准备了适当的补丁来修复该问题: https://lists.openwall.net/linux-kernel/2020/12/11/265
我已经验证了它,现在它工作良好。感谢LKRG的开发工作,我们帮助识别并修复了Linux内核中的两个有趣问题。
谢谢, Adam