Linux内核KRETPROBES与OPTIMIZER故障解析

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

Linux内核中损坏的KRETPROBES和OPTIMIZER简史

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

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

KPROBES和FTRACE简介

Linux内核提供两个出色的钩子框架——KPROBES和FTRACE。KPROBES更早且经典——于2.6.9版本(2004年10月)引入。FTRACE是较新的接口,与K*PROBES相比可能具有更小的开销。

各种类型的K*PROBES包括:

  • KPROBES:可放置在内核中几乎任何指令上
  • JPROBES:使用KPROBES实现,但自2017年起已弃用
  • KRETPROBES:称为“返回探针”,允许在钩子函数的入口和返回路径上轻松执行用户自定义例程

当注册KPROBE时,它会复制被探测指令,并用断点指令替换被探测指令的第一个字节。

FTRACE最初在内核2.6.27中引入,其工作原理完全不同,主要思想是基于检测每个编译函数(注入"长NOP"指令)。当在特定函数上注册FTRACE时,此类"长NOP"被替换为指向蹦床代码的JUMP指令。

Linux内核运行时防护(LKRG)

LKRG对Linux内核执行运行时完整性检查,并检测针对内核的各种漏洞利用。为了实现这些功能,LKRG必须在内核中放置各种钩子,KRETPROBES用于满足这一要求。

LKRG在FTRACE检测函数上的KPROBE

如果一个函数被FTRACE检测(注入"长NOP"),并且有人在其上注册KPROBES,会发生什么?Linux内核在这种情况下使用特殊类型的KPROBES,称为"基于FTRACE的KPROBES"。

OPTIMIZER

Linux内核开发人员更进一步,积极"优化"所有K*PROBES以使用FTRACE代替,主要原因是性能——FTRACE开销更小。

第一个问题:KRETPROBES损坏

从内核5.8开始,所有非优化的KRETPROBES都不工作。在5.8之前,当引发#DB异常时,NMI的入口没有完全执行。但从内核5.8开始,中断处理逻辑被修改。

根本原因来自以下提交:

1
https://github.com/torvalds/linux/commit/0d00449c7a28a1514595630735df383dec606812

后来被以下提交修改:

1
https://github.com/torvalds/linux/commit/8edd7e37aed8b9df938a63f0b0259c70569ce3d2

本质上,自这些提交以来,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中。

此问题已报告给维护者并得到正确修复。这些补丁将被反向移植到稳定树。

第二个问题:OPTIMIZER未充分执行其任务

在分析生成的vmlinux二进制文件时,我发现GCC使用INT3操作码在钩子函数末尾生成了填充:

1
2
3
4
5
6
...
ffffffff8130528b:       41 bd f0 ff ff ff       mov    $0xfffffff0,%r13d
ffffffff81305291:       e9 fe fe ff ff          jmpq   ffffffff81305194
ffffffff81305296:       cc                      int3
ffffffff81305297:       cc                      int3
...

这种填充在旧内核的生成映像中不存在。OPTIMIZER逻辑在此失败:

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

然而,这里的情况并非如此。INT3_INSN_OPCODE作为填充放置在函数末尾。

我发现以下提交改变了链接器的默认填充策略:

1
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7705dc8557973d8ad8f10840f61d8ec805695e9e

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

通过与Linux内核开发人员讨论此问题,Masami Hiramatsu准备了适当的补丁来修复这个问题:

1
https://lists.openwall.net/linux-kernel/2020/12/11/265

通过LKRG开发工作,我们帮助识别并修复了Linux内核中的两个有趣问题。

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