一道思考题所引起动态跟踪 ‘学案’
本文地址:https://www.ebpf.top/post/ftrace_kernel_dynamic
李程远老师在极客时间 《容器实战高手课》中的 “ 加餐 04 | 理解 ftrace(2):怎么理解 ftrace 背后的技术 tracepoint 和 kprobe?” 留了一道思考题:
想想看,当我们用 kprobe 为一个内核函数注册了 probe 之后,怎样能看到对应内核函数的第一条指令被替换了呢?
kprobe 是内核函数动态跟踪的一种实现机制,使用该机制几乎可跟踪所有的内核函数(排除带有 __kprobes/nokprobe_inline
注解的和标有 NOKPROBE_SYMBOL
的函数)。 kprobe 跟踪机制的实现目前主要有 2 种机制:
-
一般情况下,当 kprobe 函数注册的时候,把目标地址上内核代码的指令码,替换成了 “cc”,也就是 int3 指令。这样一来,当内核代码执行到这条指令的时候,就会触发一个异常而进入到 Linux int3 异常处理函数 do_int3() 里。在
do_int3()
这个函数里,进行检查,如果发现有对应的 kprobe 注册了 probe,就会依次执行注册的 pre_handler()、替换前的指令、post_handler()。 -
如内核基于 ftrace 对函数进行 trace,则会函数头上预留了
callq <__fentry__>
的 5 个字节(在启动的时候被替换成了 nop)。kprobe 跟踪机制会复用 ftrace 跟踪预留的 5 个字节,将其替换成ftrace_caller
,而不再使用 int3 软中断指令替换。
不论上述那种方式,kprobe 实现原理基本一致:进行目标指令替换,替换的指令可以使程序跳转到一个特定的 handler 里,然后再去执行注册的 probe 的函数。
本文,我将基于 ftrace 机制对整个动态替换的机制进行验证。如对 ftrace 不熟悉,建议提前阅读 Linux 原生跟踪工具 Ftrace 必知必会 。
1. 基础知识
1.1 默认编译
我们用 C 语言实现一个非常简单程序进行简单验证:
|
|
在默认参数编译后的代码如下,可见函数头部没有特殊定义。
|
|
1.2 使用 -pg
选项
使用 -pg
参数编译后,我们可以看到在函数头部增加了对 mcount
函数的调用,这种机制常用用于运行程序性能分析:
|
|
gcc 添加 -pg 选项后,编译器都会在函数头部增加 mcount/fentry 函数调用( 设置了 notrace 属性函数除外);
#define notrace __attribute__((no_instrument_function))
1.3 使用 -pg
和 -mfentry
选项
在 gcc 4.6 版本后,新增编译选项 -mfentry
, 将通过调用实现更加简洁高效的 __fentry__
函数替换 mcount
, 在 Linux Kernel 4.19 x86 体系结构默认使用该方式 。
|
|
这里我们以 fentry 为例,该函数调用会占用 5 个字节。 Linux 内核中 fentry 函数被定位为 retq 直接返回。
|
|
即使通过 reqt 直接返回,每个函数都调用的时候仍然会带来大概 13% 的性能损耗,在实际运行过程中,ftrace 机制会在内核启动时候将 5 个字节(ff 15 05 2e 00 00 call __fentry__
)直接替换成 nop 指令,在 x86_64 体系中为 nop 指令为: 0F 1F 44 00 00H
。
在启用 ftrace 动态跟踪机制时(CONFIG_DYNAMIC_FTRACE),设置跟踪函数后,内核会对当前 nop 指令进行动态替换(hot hook),替换成跳转到 ftrace_caller 函数,从而实现了动态跟踪。在替换过程中为了避免引发多核异常,首先将第一个直接替换成 0xcc 的中断指令,然后再替换后续的指令,具体实现参见 void ftrace_replace_code(int enable);
1.4 对内核进行验证
我们以内核函数 schedule
为例,使用 gdb 调试带有符号信息的 vmlinux 文件时,我们可直接查看到函数编译后的汇编代码:
__fentry__
函数则直接被定义为了 retq 指令:
call 汇编指令解析:
0xffffffff81c33580 <+0>: e8 1b 41 44 ff call 0xffffffff810776a0 <__fentry__>
e8 代表 call, 1b 41 44 ff 相对于下一条指令的偏移量 (0xffffffff81c33580 + 5), FF 44 41 1B 为负数,补码为 BB BE E5, 0xffffffff810776a0 - 0xffffffff81c33585 = -bbbee5
2. ftrace 中 kprobe 跟踪机制验证
这里,我们打算验证 3 件事情:
- 函数在内核启动后,函数首部的 call 指令会被替换成 nop 指令;
- ftrace 方式下设置 kprobe 函数跟踪后,nop 指令会被替换成相对应的 call 调用;
- kprobe 跟踪停止后,函数头部的 5 个字节会被替换成 nop 指令;(1,2 验证后,则很容易验证)
为了验证内核函数动态替换过程,我首先考虑的是通过内核模块打印函数地址对应的首部 5 个字节。
3. 使用内核模块进行验证
3.1 使用 kallsyms_lookup_name 方式获取
最常见或流行的做法是在内核模块中使用内核函数 kallsyms_lookup_name()
获取到跟踪函数的地址,然后进行打印。
首先,我也想尝试通过这种方式进行,其他获取内核符号地址的方式参见 获取内核符号地址的方式 。内核模块的样例代码参考 hello_kernel_module,代码也非常简单:
|
|
但在编译阶段报错(本地环境 5.11.22-generic):
|
|
在新版内核 ( >= 5.7 ) 中,出于安全考虑 kallsyms_lookup_name
函数不再被导出,在内核模块中不能再直接应用,相关说明可参见文章 Unexporting kallsyms_lookup_name 和提交的 补丁 。 这里 讨论了几种可行的替代方案,另外关于多内核版本下的统一方案可参考 The Linux Kernel Module Programming Guide 中的样例代码 syscall.c。这里为了简化,我使用 kprobe 注册机制(仅支持 Linux 5.11 内核),完整代码如下:
|
|
完整代码参见 get_inst.c。编译并安装后,可通过 dmesg 进行查看:
|
|
这里我们可以看到函数首部的 5 个字节已被替换成 nop 指令(0f 1f 44 00 00
),这个过程是在内核启动时由 ftrace_init()
函数统一处理替换的。同样,新安装的内核模块中导出的函数,首部也会自动被替换成成 nop 指令。
对应到 ftrace pdf 中 schedule 函数的样例如下:
图 未启用 kprobe 跟踪前,函数首部 5 个字节为 nop 指令 <图来自于 ftrace pdf P36>
接着,启用内核函数 schedule 的跟踪,再进行验证:
|
|
在启用内核函数 schedule 函数跟踪后,我们可以看到首部 5 个字节 (nop)已经被替换成了其他函数调用。大体效果如下所示:
图:在注册 kprobe 函数 nop 指令被替换效果 <来自于 ftrace pdf P37>
3.2 直接使用内核函数地址(踩坑笔记,可跳过)
如果不通过 kallsyms_lookup_name
函数,直接使用 /boot/System.map
中的地址是否可以?答案是可以的,但是需要小心 KASLR(Kernel Address Space Layout Randomization)机制。
KASLR 可能会在每次启动时随机化内核代码和数据的地址,目的是保护内核空间不被攻击者破坏,这样以来 /boot/System.map
中列出的静态地址会被随机值调整。如果没有 KASLR,攻击者可能会在固定地址中轻易找到目标地址。如果 /proc/kallsyms
中的符号地址与 /boot/System.map
中的地址不同,说明 KASLR 系统运行的内核中被启用。两个查看需要 root 用户权限才能查看。
|
|
如果启用了 KASLR,我们必须在每次重启机器时注意 /proc/kallsyms
的地址(** 每次重启机器都会发生变化 **)。为了使用 /boot/System.map
中的地址,要确保 KASLR 被禁用。我们可以在启动命令行中添加 nokaslr
来禁用 KASLR,重启生效:
|
|
我们可在内核模块中添加一个 sym 变量获取传入的函数地址,样例代码如下:
|
|
在确保 KASLR 被禁用后,我们编译上述模块并运行,可得到与上述方式一致的结果:
|
|
如果不禁用 KASLR 使用固定地址进行编译,加载驱动则会报错:
|
|
4. 使用 gdb + qemu 进行验证
我将编译内核带上 DEBUG 选项的内核及相关文件保存到了 百度网盘 ,提取码 av28。关于内核编译及调试的详细过程可参考 使用 GDB + Qemu 调试 Linux 内核 。
这里介绍一下如何在 Mac 环境下使用 qemu 软件进行内核调试:
|
|
需要提前下载网盘的文件至本地目录,运行 qemu 进行测试:
|
|
注意这里添加了 -machine type=q35,accel=hvf
标记,在 mac 环境下使用 hvf 加速,如果不启用加速,默认使用 xen 虚拟化指令集。
如果在 qemu-system-x86_64 命令行没有启用 hvf 加速,看到函数前 5 个字节会有所差异,默认为
66 66 66 66 90 data16 data16 data16 xchg %ax,%ax
,这是因为 nop 指令在不同的体系结构会有所不同。
|
|
这里我们对传入头部的函数继续进行跟踪:
|
|
在后续翻页中可以看到调用了 kprobe_ftrace_handler 注册函数。
需要注意地址 0xffffffffc0002000 的函数并不是 ftrace 注册函数 ftrace_caller 或 ftrace_regs_caller,而是依据这两个函数在内存中动态构建的 trampoline(蹦床),将 ftrace_caller 或 ftrace_regs_caller 修改注册函数后的汇编拷贝到这段 trampoline 中,(本次调试 ftrace 函数为 ftrace_regs_caller,事件注册函数为 kprobe_ftrace_handler)。
参考
- 原文作者:DavidDi
- 原文链接:https://www.ebpf.top/post/ftrace_kernel_dynamic/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议. 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 最后更新时间:2024-02-07 00:23:21.207059232 +0800 CST