Linux内核中KRETPROBES与OPTIMIZER故障的简短故事

本文详细分析了Linux内核自5.8版本起KRETPROBES机制的失效问题及OPTIMIZER的优化不足,探讨了FTRACE与KPROBES的交互机制,并介绍了LKRG在运行时完整性检查中遇到的挑战及修复方案。

Linux内核中KRETPROBES与OPTIMIZER故障的简短故事

在LKRG的开发过程中,我发现:

  • 自内核5.8起,KRETPROBES被破坏(将在即将发布的内核中修复)
  • 自内核5.5起,OPTIMIZER未充分执行其任务

首先——KPROBES和FTRACE

Linux内核提供了两个出色的钩子框架——KPROBES和FTRACE。KPROBES较老且经典,于2.6.9版本(2004年10月)引入。而FTRACE是较新的接口,可能比KPROBES开销更小。我使用“KPROBES”一词是因为内核中提供了多种类型的KPROBES,包括JPROBES、KRETPROBES或经典的KPROBES。KPROBES本质上允许动态中断任何内核例程。各种K*PROBES之间的区别是什么?

  • KPROBES:可以放置在内核中几乎任何指令上
  • JPROBES:使用KPROBES实现。JPROBES的主要思想是采用简单的镜像原则,允许无缝访问被探测函数的参数。然而,自2017年起,JPROBES已被弃用。更多信息可见:https://lwn.net/Articles/735667/
  • 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内核的运行时完整性检查(类似于微软的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 instead。其主要原因是性能——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

1
2
3
4
if (!user_mode(regs)) {
    rcu_nmi_enter();
    preempt_disable();
}

在一些较旧的内核中,改为调用函数ist_enter()。在该函数内部,我们可以看到以下逻辑:https://elixir.bootlin.com/linux/v5.7.19/source/arch/x86/kernel/traps.c#L91

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if (user_mode(regs)) {
    RCU_LOCKDEP_WARN(!rcu_is_watching(), "entry code didn't wake RCU");
} else {
    /*
     * We might have interrupted pretty much anything.  In
     * fact, if we're a machine check, we can even interrupt
     * NMI processing.  We don't want in_nmi() to return true,
     * but we need to notify RCU.
     */
    rcu_nmi_enter();
}

preempt_disable();

如注释所述,“我们不希望in_nmi()返回true,但需要通知RCU。”。然而,自内核5.8起,中断处理逻辑被修改,目前我们有这个(函数“exc_int3”):https://elixir.bootlin.com/linux/v5.8/source/arch/x86/kernel/traps.c#L630

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
 * idtentry_enter_user() uses static_branch_{,un}likely() and therefore
 * can trigger INT3, hence poke_int3_handler() must be done
 * before. If the entry came from kernel mode, then use nmi_enter()
 * because the INT3 could have been hit in any context including
 * NMI.
 */
if (user_mode(regs)) {
    idtentry_enter_user(regs);
    instrumentation_begin();
    do_int3_user(regs);
    instrumentation_end();
    idtentry_exit_user(regs);
} else {
    nmi_enter();
    instrumentation_begin();
    trace_hardirqs_off_finish();
    if (!do_int3(regs))
        die("int3", regs, 0);
    if (regs->flags & X86_EFLAGS_IF)
        trace_hardirqs_on_prepare();
    instrumentation_end();
    nmi_exit();
}

不幸变化的根源来自这个提交: https://github.com/torvalds/linux/commit/0d00449c7a28a1514595630735df383dec606812#diff-51ce909c2f65ed9cc668bc36cc3c18528541d8a10e84287874cd37a5918abae5

随后被这个提交修改: https://github.com/torvalds/linux/commit/8edd7e37aed8b9df938a63f0b0259c70569ce3d2

这就是自5.8以来所有内核中的当前状态。本质上,自这些提交起,KRETPROBES不再工作。我们有以下逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
asm_exc_int3() -> exc_int3():
                    |
    ----------------|
    |
    v
...
nmi_enter();
...
if (!do_int3(regs))
       |
  -----|
  |
  v
do_int3() -> kprobe_int3_handler():
                    |
    ----------------|
    |
    v
...
if (!p->pre_handler || !p->pre_handler(p, regs))
                             |
    -------------------------|
    |
    v
...
pre_handler_kretprobe():
...
    if (unlikely(in_nmi())) {
        rp->nmissed++;
        return 0;
    }

本质上,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操作码在被钩取函数的末尾生成了填充:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
ffffffff8130528b:       41 bd f0 ff ff ff       mov    $0xfffffff0,%r13d
ffffffff81305291:       e9 fe fe ff ff          jmpq   ffffffff81305194
ffffffff81305296:       cc                      int3
ffffffff81305297:       cc                      int3
ffffffff81305298:       cc                      int3
ffffffff81305299:       cc                      int3
ffffffff8130529a:       cc                      int3
ffffffff8130529b:       cc                      int3
ffffffff8130529c:       cc                      int3
ffffffff8130529d:       cc                      int3
ffffffff8130529e:       cc                      int3
ffffffff8130529f:       cc                      int3

在旧内核的生成映像中,此函数不存在此类填充。然而,此类填充相当常见。

OPTIMIZER逻辑在此失败:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
try_to_optimize_kprobe() -> alloc_aggr_kprobe() -> __prepare_optimized_kprobe()
-> arch_prepare_optimized_kprobe() -> can_optimize():
/* Decode instructions */
addr = paddr - offset;
while (addr < paddr - offset + size) { /* Decode until function end */
    unsigned long recovered_insn;
    if (search_exception_tables(addr))
        /*
         * Since some fixup code will jumps into this function,
         * we can't optimize kprobe in this function.
         */
        return 0;
    recovered_insn = recover_probed_instruction(buf, addr);
    if (!recovered_insn)
        return 0;
    kernel_insn_init(&insn, (void *)recovered_insn, MAX_INSN_SIZE);
    insn_get_length(&insn);
    /* Another subsystem puts a breakpoint */
    if (insn.opcode.bytes[0] == INT3_INSN_OPCODE)
        return 0;
    /* Recover address */
    insn.kaddr = (void *)addr;
    insn.next_byte = (void *)(addr + insn.length);
    /* Check any instructions don't jump into target */
    if (insn_is_indirect_jump(&insn) ||
        insn_jump_into_range(&insn, paddr + INT3_INSN_SIZE,
                 DISP32_SIZE))
        return 0;
    addr += insn.length;
}

其中一个检查试图保护另一种情况,即另一个子系统也在那里放置断点:

1
2
3
    /* Another subsystem puts a breakpoint */
    if (insn.opcode.bytes[0] == INT3_INSN_OPCODE)
        return 0;

然而,这里的情况并非如此。INT3_INSN_OPCODE作为填充放置在函数末尾。我想找出为什么在新内核中INT3填充更常见,而旧内核中即使我使用完全相同的编译器和链接器也不存在这种情况。我开始浏览提交,并找到了这个:https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7705dc8557973d8ad8f10840f61d8ec805695e9e

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/arch/x86/kernel/vmlinux.lds.S b/arch/x86/kernel/vmlinux.lds.S
index b06d6e1188deb..3a1a819da1376 100644
--- a/arch/x86/kernel/vmlinux.lds.S
+++ b/arch/x86/kernel/vmlinux.lds.S
@@ -144,7 +144,7 @@ SECTIONS
 		*(.text.__x86.indirect_thunk)
 		__indirect_thunk_end = .;
 #endif
-	} :text = 0x9090
+	} :text =0xcccc
 
 	/* End of text section, which should occupy whole number of pages */
 	_etext = .;

看起来INT3现在是链接器使用的默认填充。

我已向Linux内核开发者(KPROBES所有者)提出该问题,Masami Hiramatsu准备了适当的补丁来修复该问题: https://lists.openwall.net/linux-kernel/2020/12/11/265

我已验证,现在它工作良好。感谢LKRG的开发工作,我们帮助识别并修复了Linux内核中的两个有趣问题。

谢谢, Adam

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计