• 一道思考题所引起动态跟踪 ‘学案’


    本文地址: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()。

      kprobe_arch.png

    • 如内核基于 ftrace 对函数进行 trace,则会函数头上预留了 callq <__fentry__> 的 5 个字节(在启动的时候被替换成了 nop)。kprobe 跟踪机制会复用 ftrace 跟踪预留的 5 个字节,将其替换成 ftrace_caller ,而不再使用 int3 软中断指令替换。

    不论上述那种方式,kprobe 实现原理基本一致:进行目标指令替换,替换的指令可以使程序跳转到一个特定的 handler 里,然后再去执行注册的 probe 的函数。

    本文,我将基于 ftrace 机制对整个动态替换的机制进行验证。如对 ftrace 不熟悉,建议提前阅读 Linux 原生跟踪工具 Ftrace 必知必会

    1. 基础知识

    1.1 默认编译

    我们用 C 语言实现一个非常简单程序进行简单验证:

    #include <stdio.h>
    #include <stdlib.h>
    
    int a() {
        return 0;
    }
    
    int main(int argc, char ** argv){
        return 0;
    }
    

    在默认参数编译后的代码如下,可见函数头部没有特殊定义。

    $ gcc -o hello hello.c 
    $ objdump -S hello
    ...
    0000000000001129 <a>:
        1129:	f3 0f 1e fa          	endbr64
        112d:	55                   	push   %rbp
        112e:	48 89 e5             	mov    %rsp,%rbp
        1131:	b8 00 00 00 00       	mov    $0x0,%eax
        1136:	5d                   	pop    %rbp
        1137:	c3                   	ret
    ...
    

    1.2 使用 -pg 选项

    使用 -pg 参数编译后,我们可以看到在函数头部增加了对 mcount 函数的调用,这种机制常用用于运行程序性能分析:

    $ gcc -pg -o hello.pg hello.c 
    $ objdump -S hello.pg
    ...
    00000000000011e9 <a>:
        11e9:	f3 0f 1e fa          	endbr64
        11ed:	55                   	push   %rbp
        11ee:	48 89 e5             	mov    %rsp,%rbp
        11f1:	ff 15 f1 2d 00 00    	call   *0x2df1(%rip)        # 3fe8 <mcount@GLIBC_2.2.5>
        11f7:	b8 00 00 00 00       	mov    $0x0,%eax
        11fc:	5d                   	pop    %rbp
        11fd:	c3                   	ret
    ...
    

    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 体系结构默认使用该方式

    # echo 'void foo(){}' | gcc -x c -S -o - - -pg -mfentry
    $ gcc -pg -mfentry -o hello.pg.entry hello.c
    $ objdump -S hello.pg.entry
    00000000000011e9 <a>:
        11e9:	f3 0f 1e fa          	endbr64
        11ed:	ff 15 05 2e 00 00    	call   *0x2e05(%rip)        # 3ff8 <__fentry__@GLIBC_2.13>
        11f3:	55                   	push   %rbp
        11f4:	48 89 e5             	mov    %rsp,%rbp
        11f7:	b8 00 00 00 00       	mov    $0x0,%eax
        11fc:	5d                   	pop    %rbp
        11fd:	c3                   	ret
    

    这里我们以 fentry 为例,该函数调用会占用 5 个字节。 Linux 内核中 fentry 函数被定位为 retq 直接返回。

    SYM_FUNC_START(__fentry__)
            retq
    SYM_FUNC_END(__fentry__)
    

    即使通过 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 文件时,我们可直接查看到函数编译后的汇编代码:

    schedule_fentry

    __fentry__ 函数则直接被定义为了 retq 指令:

    fentry.png

    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 件事情:

    1. 函数在内核启动后,函数首部的 call 指令会被替换成 nop 指令;
    2. ftrace 方式下设置 kprobe 函数跟踪后,nop 指令会被替换成相对应的 call 调用;
    3. kprobe 跟踪停止后,函数头部的 5 个字节会被替换成 nop 指令;(1,2 验证后,则很容易验证)

    为了验证内核函数动态替换过程,我首先考虑的是通过内核模块打印函数地址对应的首部 5 个字节。

    3. 使用内核模块进行验证

    3.1 使用 kallsyms_lookup_name 方式获取

    最常见或流行的做法是在内核模块中使用内核函数 kallsyms_lookup_name() 获取到跟踪函数的地址,然后进行打印。

    首先,我也想尝试通过这种方式进行,其他获取内核符号地址的方式参见 获取内核符号地址的方式 。内核模块的样例代码参考 hello_kernel_module,代码也非常简单:

    static int __init hello_init(void)
    {
        char *func_addr = (char *)kallsyms_lookup_name("schedule");
      
        // 判断地址是否合法,然后进行打印
    }
    

    但在编译阶段报错(本地环境 5.11.22-generic):

    ERROR: modpost: "kallsyms_lookup_name" [hello_kernel_module/hello.ko] undefined!
    

    在新版内核 ( >= 5.7 ) 中,出于安全考虑 kallsyms_lookup_name 函数不再被导出,在内核模块中不能再直接应用,相关说明可参见文章 Unexporting kallsyms_lookup_name 和提交的 补丁 这里 讨论了几种可行的替代方案,另外关于多内核版本下的统一方案可参考 The Linux Kernel Module Programming Guide 中的样例代码 syscall.c。这里为了简化,我使用 kprobe 注册机制(仅支持 Linux 5.11 内核),完整代码如下:

    #include <linux/init.h>
    #include <linux/module.h>
    #include <linux/kprobes.h>
    static struct kprobe kp = {
        .symbol_name = "kallsyms_lookup_name"
    };
    
    static int __init hello_init(void)
    {
        typedef unsigned long (*kallsyms_lookup_name_t)(const char *name);
        int i = 0;
        kallsyms_lookup_name_t kallsyms_lookup_name;
        register_kprobe(&kp);
        kallsyms_lookup_name = (kallsyms_lookup_name_t) kp.addr;
        unregister_kprobe(&kp);
    
        char *func_addr = (char *)kallsyms_lookup_name("schedule");
    
        pr_info("fun addr 0x%lx\n", func_addr);
        for (i = 0; i < 5; i++)
        {
    			 pr_info("0x%02x ", (u8)func_addr[i]);
        }
        
        return 0;
    }
    

    完整代码参见 get_inst.c。编译并安装后,可通过 dmesg 进行查看:

    $ sudo insmod ./hello.ko
    $ dmesg -T
    [Sat Apr  9 12:11:25 2022] fun addr 0xffffffff9eea3eb0
    [Sat Apr  9 12:11:25 2022] 0x0f 0x1f 0x44 0x00 0x00
    

    这里我们可以看到函数首部的 5 个字节已被替换成 nop 指令(0f 1f 44 00 00),这个过程是在内核启动时由 ftrace_init() 函数统一处理替换的。同样,新安装的内核模块中导出的函数,首部也会自动被替换成成 nop 指令。

    对应到 ftrace pdf 中 schedule 函数的样例如下:

    ftrace_ex1.png

    图 未启用 kprobe 跟踪前,函数首部 5 个字节为 nop 指令 <图来自于 ftrace pdf P36>

    接着,启用内核函数 schedule 的跟踪,再进行验证:

    $ cd /sys/kernel/debug/tracing
    $ sudo echo 'p:schedule schedule' >> kprobe_events
    $ sudo cat kprobe_events
    p:kprobes/schedule schedule
    
    $ sudo echo 1 >  events/kprobes/schedule/enable
    $ insmod ./hello.ko
    $ demsg -T
    [Sun Apr 10 20:07:12 2022] 0xe8 0x7b 0x5a 0xd9 0x20
    [Sun Apr 10 20:07:12 2022] fun addr 0xffffffff9fa33580
    
    $ sudo echo 0 >  events/kprobes/schedule/enable
    

    在启用内核函数 schedule 函数跟踪后,我们可以看到首部 5 个字节 (nop)已经被替换成了其他函数调用。大体效果如下所示:

    ftrace_ex2.png 图:在注册 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 用户权限才能查看。

    $ grep GRUB_CMDLINE_LINUX_DEFAULT /etc/default/grub
    GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
    
    $ sudo grep schedule$ /boot/System.map-$(uname -r)
    ffffffff81c33580 T schedule
    
    $ grep schedule$ /proc/kallsyms 
    ffffffff9fa33580 T schedule
    
    # 如果系统未启用 KASLR(内核地址空间随机地址)功能,两者地址会相等,否则会不一致。
    

    如果启用了 KASLR,我们必须在每次重启机器时注意 /proc/kallsyms 的地址(** 每次重启机器都会发生变化 **)。为了使用 /boot/System.map 中的地址,要确保 KASLR 被禁用。我们可以在启动命令行中添加 nokaslr 来禁用 KASLR,重启生效:

    $ grep GRUB_CMDLINE_LINUX_DEFAULT /etc/default/grub
    GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
    $ sudo perl -i -pe 'm/quiet/ and s//quiet nokaslr/' /etc/default/grub
    $ grep quiet /etc/default/grub
    GRUB_CMDLINE_LINUX_DEFAULT="quiet nokaslr splash"
    $ sudo update-grub
    

    我们可在内核模块中添加一个 sym 变量获取传入的函数地址,样例代码如下:

    #include <linux/init.h>
    #include <linux/module.h>
    #include <linux/kallsyms.h>
    
    static unsigned long sym = 0;
    module_param(sym, ulong, 0644);
    
    static int __init hello_init(void)
    {
        char *func_addr = 0;
        int i = 0;
        if (sym != 0)
        {
    			func_addr = (char *)sym;
    			for ( i = 0; i < 5; i++)
    				pr_info("0x%02x ", (u8)func_addr[i]);
        }
    
        pr_info("fun addr 0x%p\n", func_addr);
        return 0;
    }
    module_init(hello_init);
    

    在确保 KASLR 被禁用后,我们编译上述模块并运行,可得到与上述方式一致的结果:

    $ addr=`grep -w "schedule" /proc/kallsyms|cut -d " " -f 1`
    $ insmod ./hello.ko sym=0x$addr
    
    $ dmesg -T
    [Sun Apr 10 20:50:51 2022] 0xe8 0x7b 0x5a 0xd9 0x20
    [Sun Apr 10 20:50:51 2022] fun addr 0x000000005aad203e
    
    $ rmmod hello
    

    如果不禁用 KASLR 使用固定地址进行编译,加载驱动则会报错:

    $ sudo dmesg -T
    [Fri Apr  8 17:39:47 2022] BUG: unable to handle page fault for address: ffffffff810a3eb2
    [Fri Apr  8 17:39:47 2022] #PF: supervisor read access in kernel mode
    [Fri Apr  8 17:39:47 2022] #PF: error_code(0x0000) - not-present page
    

    4. 使用 gdb + qemu 进行验证

    我将编译内核带上 DEBUG 选项的内核及相关文件保存到了 百度网盘 ,提取码 av28。关于内核编译及调试的详细过程可参考 使用 GDB + Qemu 调试 Linux 内核

    这里介绍一下如何在 Mac 环境下使用 qemu 软件进行内核调试:

    $ brew install qemu
    $ brew link qemu
    

    需要提前下载网盘的文件至本地目录,运行 qemu 进行测试:

    $ cat run.sh
    #!/bin/bash
    
    qemu-system-x86_64 -machine type=q35,accel=hvf -kernel ./bzImage -initrd  ./rootfs_root.img -append "nokaslr console=ttyS0" -s c
    
    $ ./run.sh
    

    注意这里添加了 -machine type=q35,accel=hvf 标记,在 mac 环境下使用 hvf 加速,如果不启用加速,默认使用 xen 虚拟化指令集。

    gdb_schedule_before

    如果在 qemu-system-x86_64 命令行没有启用 hvf 加速,看到函数前 5 个字节会有所差异,默认为 66 66 66 66 90 data16 data16 data16 xchg %ax,%ax,这是因为 nop 指令在不同的体系结构会有所不同。

    # cd /sys/kernel/debug/tracing
    # echo 'p:schedule schedule' >> kprobe_events
    # echo 1 >  events/kprobes/schedule/enable
    

    gdb_schedule_after

    这里我们对传入头部的函数继续进行跟踪:

    (gdb) x/100i 0xffffffffc0002000
    

    在后续翻页中可以看到调用了 kprobe_ftrace_handler 注册函数。
    kprobe_ftrace_handler

    需要注意地址 0xffffffffc0002000 的函数并不是 ftrace 注册函数 ftrace_caller 或 ftrace_regs_caller,而是依据这两个函数在内存中动态构建的 trampoline(蹦床),将 ftrace_caller 或 ftrace_regs_caller 修改注册函数后的汇编拷贝到这段 trampoline 中,(本次调试 ftrace 函数为 ftrace_regs_caller,事件注册函数为 kprobe_ftrace_handler)。

    参考

  • 相关阅读:
    SSL证书安装指引
    腾讯云中ssL证书的配置安装
    微信小程序:微信登陆(ThinkPHP作后台)
    TPshop学习(8)微信支付
    LNMP安装Let’s Encrypt 免费SSL证书方法:自动安装与手动配置Nginx
    ruby文档
    tesseract-ocr图片识别开源工具
    Python读写文件
    百度贴吧的网络爬虫(v0.4)源码及解析
    中文分词库
  • 原文地址:https://www.cnblogs.com/davad/p/16216295.html
Copyright © 2020-2023  润新知