Linux 内核动态追踪技术的实现

kprobe 是内核提供的动态追踪技术机制,它允许动态安装内核模块的方式安装系统钩子,非常强大。下面先看一个内核中的例子。
首页 新闻资讯 行业资讯 Linux 内核动态追踪技术的实现

[[434928]]

之前的文章介绍了基于 tracepoint 静态追踪技术的实现,本文再介绍基于 kprobe  的动态追踪即使的实现。同样,动态追踪也是排查问题的利器。

kprobe 是内核提供的动态追踪技术机制,它允许动态安装内核模块的方式安装系统钩子,非常强大。下面先看一个内核中的例子。

复制

#include <linux/kernel.h> #include <linux/module.h> #include <linux/kprobes.h>  #define MAX_SYMBOL_LEN  64 // 要 hanck 的内核函数名 static char symbol[MAX_SYMBOL_LEN] = "_do_fork"; module_param_string(symbol, symbol, sizeof(symbol), 0644); static struct kprobe kp = {     .symbol_name    = symbol, };  // 执行系统函数前被执行的钩子 static int __kprobes handler_pre(struct kprobe *p, struct pt_regs *regs){     // ... }  // 执行系统函数的单条指令后执行的钩子(不是执行完系统函数) static void __kprobes handler_post(struct kprobe *p, struct pt_regs *regs,                 unsigned long flags){     // ... }  // 钩子执行出错或者单条执行执行出错时被执行函数static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr){     // ... }  static int __init kprobe_init(void){     int ret;     // 设置钩子     kp.pre_handler = handler_pre;     kp.post_handler = handler_post;     kp.fault_handler = handler_fault;     // 安装钩子     register_kprobe(&kp);     return 0; }  static void __exit kprobe_exit(void){     unregister_kprobe(&kp);     pr_info("kprobe at %p unregistered\n", kp.addr); }  // 安装进内核后的初始化和注销函数 module_init(kprobe_init) module_exit(kprobe_exit) MODULE_LICENSE("GPL");
  • 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.

  • 32.

  • 33.

  • 34.

  • 35.

  • 36.

  • 37.

  • 38.

  • 39.

  • 40.

  • 41.

  • 42.

  • 43.

  • 44.

  • 45.

  • 46.

  • 47.

设置完 kprobe 后,通过 register_kprobe 注册到内核。

复制

int register_kprobe(struct kprobe *p){     int ret;     struct kprobe *old_p;     struct module *probed_mod;     kprobe_opcode_t *addr;      // 通过系统函数名找到对应的地址,内核维护了这个数据     addr = kprobe_addr(p);     // 记录这个地址     p->addr = addr;     p->flags &= KPROBE_FLAG_DISABLED;     p->nmissed = 0;     INIT_LIST_HEAD(&p->list);     // 之前是否已经存在钩子,是的话就插入存在的列表,否则插入一个新的记录     old_p = get_kprobe(p->addr);     if (old_p) {         /* Since this may unoptimize old_p, locking text_mutex. */         ret = register_aggr_kprobe(old_p, p);         goto out;     }     // 把被 hack 的系统函数的指令保存到 probe 结构体,因为下面要覆盖这块内存     /*         prepare_kprobe =>             unsigned long addr = (unsigned long) p->addr;             unsigned long *kprobe_addr = (unsigned long *)(addr & ~0xFULL);             memcpy(&p->opcode, kprobe_addr, sizeof(kprobe_opcode_t));             memcpy(p->ainsn.insn, kprobe_addr, sizeof(kprobe_opcode_t));     */     ret = prepare_kprobe(p);      INIT_HLIST_NODE(&p->hlist);     // 插入内核维护的哈希表     hlist_add_head_rcu(&p->hlist,                &kprobe_table[hash_ptr(p->addr, KPROBE_HASH_BITS)]);     // hack 掉系统函数所在内存的内容     arm_kprobe(p); }
  • 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.

  • 32.

  • 33.

  • 34.

  • 35.

  • 36.

  • 37.

注册一个 probe,首先是通过被 hack 的函数名找到对应的地址,然后保存这个地址对应内存的信息,接着把 probe 插入哈希表,最后调用  arm_kprobe 函数 hack 掉系统函数所在内存的内容。看一下 arm_kprobe。

复制

void arch_arm_kprobe(struct kprobe *p){     // #define INT3_INSN_OPCODE 0xCC     u8 int3 = INT3_INSN_OPCODE;     // 把 int3 的内存复制到 addr     text_poke(p->addr, &int3, 1);     text_poke_sync();     perf_event_text_poke(p->addr, &p->opcode, 1, &int3, 1); }
  • 1.

  • 2.

  • 3.

  • 4.

  • 5.

  • 6.

  • 7.

  • 8.

0xCC 是 intel 架构下 int3 对应的指令。所以这里就是把被 hack 函数对应指令的前面部分改成 int3。完成  hack。当执行到系统函数的时候,就会执行 int3,从而触发 trap,并执行对应的处理函数  do_int3(这里比较复杂,我也没有深入分析,大概是这个流程)。

复制

static bool do_int3(struct pt_regs *regs){     kprobe_int3_handler(regs);}int kprobe_int3_handler(struct pt_regs *regs){     kprobe_opcode_t *addr;     struct kprobe *p;     struct kprobe_ctlblk *kcb;     addr = (kprobe_opcode_t *)(regs->ip - sizeof(kprobe_opcode_t));      kcb = get_kprobe_ctlblk();     // 通过地址从 probe  哈希表拿到对应的 probe 结构体     p = get_kprobe(addr);      set_current_kprobe(p, regs, kcb);     kcb->kprobe_status = KPROBE_HIT_ACTIVE;      // 执行 pre_handler 钩子      if (!p->pre_handler || !p->pre_handler(p, regs))         setup_singlestep(p, regs, kcb, 0); }
  • 1.

  • 2.

  • 3.

  • 4.

  • 5.

  • 6.

  • 7.

  • 8.

  • 9.

  • 10.

  • 11.

  • 12.

  • 13.

  • 14.

  • 15.

  • 16.

  • 17.

  • 18.

执行完。pre_handler 钩子后,会通过 setup_singlestep 设置单步执行 flag。

复制

static void setup_singlestep(struct kprobe *p, struct pt_regs *regs,                  struct kprobe_ctlblk *kcb, int reenter){     // 修改寄存器的值     // 设置 eflags 寄存器的 tf 位,允许单步调试     regs->flags |= X86_EFLAGS_TF;     regs->flags &= ~X86_EFLAGS_IF;     // 设置下一条指令为系统函数的指令     if (p->opcode == INT3_INSN_OPCODE)         regs->ip = (unsigned long)p->addr;     else         regs->ip = (unsigned long)p->ainsn.insn; }
  • 1.

  • 2.

  • 3.

  • 4.

  • 5.

  • 6.

  • 7.

  • 8.

  • 9.

  • 10.

  • 11.

  • 12.

setup_singlestep 首先设置了允许单步调试,也就是说执行下一条指令后会触发一个 trap,从而执行一个处理函数。并设置了下一条指令为被  hack 函数对应的指令,这是在注册 probe 时保存下来的。触发单步调试的 trap 后,最终会执行到 kprobe_debug_handler

复制

int kprobe_debug_handler(struct pt_regs *regs){     struct kprobe *cur = kprobe_running();     struct kprobe_ctlblk *kcb = get_kprobe_ctlblk();     // 恢复指令为系统函数的指令     resume_execution(cur, regs, kcb);     regs->flags |= kcb->kprobe_saved_flags;     // 执行 post 钩子     if ((kcb->kprobe_status != KPROBE_REENTER) && cur->post_handler) {         kcb->kprobe_status = KPROBE_HIT_SSDONE;         cur->post_handler(cur, regs, 0);     } }
  • 1.

  • 2.

  • 3.

  • 4.

  • 5.

  • 6.

  • 7.

  • 8.

  • 9.

  • 10.

  • 11.

  • 12.

在单步调试的 trap 处理函数中,会执行 post 钩子,并恢复真正的系统函数执行。这就完成了整个过程。

我们可以看到 kprobe 可以在系统函数执行前执行我们的钩子,另外内核还提供了另外一个机制 kretprobe  用于在系统函数执行后返回前安装钩子。下面通过一个例子大致看一下 kretprobe。

复制

struct my_data {     ktime_t entry_stamp; };  // 记录函数执行开始时间 static int entry_handler(struct kretprobe_instance *ri, struct pt_regs *regs){     struct my_data *data;     data = (struct my_data *)ri->data;     data->entry_stamp = ktime_get();     return 0; }  // 记录函数执行结束时间 static int ret_handler(struct kretprobe_instance *ri, struct pt_regs *regs){     unsigned long retval = regs_return_value(regs);     struct my_data *data = (struct my_data *)ri->data;     s64 delta;     ktime_t now;      now = ktime_get();     delta = ktime_to_ns(ktime_sub(now, data->entry_stamp));     return 0; }  static struct kretprobe my_kretprobe = {     // 函数返回前执行     .handler        = ret_handler,     // 函数开始前执行     .entry_handler      = entry_handler,     .data_size      = sizeof(struct my_data),     /* Probe up to 20 instances concurrently. */     .maxactive      = 20, };  static char func_name[NAME_MAX] = "_do_fork"; module_param_string(func, func_name, NAME_MAX, S_IRUGO); my_kretprobe.kp.symbol_name = func_name; // 注册 register_kretprobe(&my_kretprobe);
  • 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.

  • 32.

  • 33.

  • 34.

  • 35.

  • 36.

  • 37.

  • 38.

  • 39.

我们可以看到可以通过 kretprobe 计算系统函数的耗时。kretprobe 是基于 kprobe 实现的,主要逻辑是通过通过 kprobe 注册一个  pre_handler,在 pre_handler 中 hack  掉函数的栈,因为函数执行时,返回地址是存在栈中的,把这个内存改成一段内核的代码,等到函数执行完后,弹出返回地址时,就会执行内核 hack  的代码,从而执行我们的钩子,执行完后再跳回到真正的返回地址继续执行。

 

总结:内核通过劫持的方式实现了 kprobe,基于 kprobe  的动态追踪技术可谓是非常复杂而强大,我们可以利用这个机制,动态修改逻辑,收集信息。不过实现过于复杂,涉及到对 CPU  架构和内存模型的了解,本文也是大致分析了一下流程,有兴趣的同学可以自行查看源码。

 

30    2021-11-15 04:00:07    Linux 内核 动态