Linux内核中KRETPROBES与OPTIMIZER故障解析

本文深入分析了Linux内核5.8版本中KRETPROBES机制的故障原因,以及5.5版本后OPTIMIZER优化不足的问题。通过LKRG开发过程中的实际案例,揭示了中断处理逻辑变更和编译器填充指令对内核钩子机制的影响,并提供了相应的修复方案。

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实现。JPROBES背后的主要思想是采用简单的镜像原则来无缝访问被探测函数的参数。然而,自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时,你会意识到在现代内核上,它们都被转换为某种类型的FTRACE。

LKRG报告误报

经过如此长的介绍,我们终于可以进入本文的主题。ALT Linux的Vitaly Chikunov报告说,当他运行FTRACE压力测试器时,LKRG报告.text部分损坏:

我花了几个星期(一个月以上)让LKRG检测并接受通过FTRACE放置的对内核代码的授权第三方修改。当我最终完成那项工作时,我意识到此外,我需要保护全局FTRACE旋钮(sysctl kernel.ftrace_enabled),它允许root在运行的系统上完全禁用FTRACE。否则,LKRG的钩子可能会在不知情的情况下被禁用,这不仅会禁用其保护(在信任主机root的威胁模型下可以接受),而且可能导致误报(因为没有钩子,LKRG不知道哪些修改是合法的)。我添加了该功能,一切工作正常……

直到内核5.9。这完全让我惊讶。我没有看到5.8.x和5.9.x之间在FTRACE逻辑上有任何重大变化。我花了一些时间,最终意识到我对全局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 设计