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

前言:之前的文章介绍了基于 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");

代码看起来比较多,首先定义了一个 kprobe 结构体。

struct kprobe {
    
    
	// 用于插入哈希表,kprobe 子系统维护了一个哈希表
	struct hlist_node hlist;

	// hack 同一个系统函数的钩子列表
	struct list_head list;

	// hack 的系统函数的地址
	kprobe_opcode_t *addr;

	// hack 的系统函数名
	const char *symbol_name;
	// 保存不同类型的钩子
	kprobe_pre_handler_t pre_handler;
	kprobe_post_handler_t post_handler;
	kprobe_fault_handler_t fault_handler;

	// 保存被 hack 的系统函数的原数据,因为这部分数据会被内核覆盖
	kprobe_opcode_t opcode;
	struct arch_specific_insn ainsn;

	// 某些标记
	u32 flags;
};

设置完 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);
}

注册一个 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);
}

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);
}

执行完。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;
}

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);
	}
}

在单步调试的 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);

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

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

猜你喜欢

转载自blog.csdn.net/THEANARKH/article/details/121321183