Kprobes

内核探针

Posted by icecube on June 26, 2020

简介

引入内核版本号v2.6.9
可以将探针插入正在运行的内核,达到调试、跟踪、性能评估,错误注入等目的
在内核绝大多数的位置动态地插入探针而不会给系统带来破坏

工作原理是修改程序二进制文本,利用断点指令替换探测点处的指令
在探测点设置断点,指定一个handler,当断点位置被触发时可以调用指定的handler
探测点其实就是一个内核登记在册的指令地址,内核会跟踪维护所有插入的探测点
在RISC架构上指令大小一致, 但在CISC上,为探测点预留的空间大小为架构最长指令字节数

kprobes分为架构相关和架构无关两部分,这样便于在不同架构之间移植
架构相关部分涉及到CPU的异常处理和单步调试技术
异常可以让cpu陷入到使用者注册的回调函数,单步调试可以单步执行探测点处指令
目前支持的架构 i386 x86_64 sparc64 arm mips ppc (Documentation/kprobes.txt)

内核调试器需要用户干预,需要和用户交互,但是Kprobes对用户是透明的
所以需要最高优先级,在判定为不属于kprobes的事件时,才会继续由其它调试器接手处理

CONFIG_KPROBES
echo 1 > /sys/kernel/debug/kprobes/enabled

类型

  • kprobes 任意位置插入探测点,执行到时触发
  • jprobes 函数入口处检查入参
  • kretprobes 函数出口触发

jprobes 和 kretprobes 都是基于kprobes实现的
jprobes 内核现在已经不支持该特性, 后面不再讨论


kprobes

kprobes结构体

typedef int (*kprobe_pre_handler_t)(struct kprobe *, struct pt_regs *);

struct kprobe {
kprobe_opcode_t *addr;	// 被探测点地址
const char *symbol_name;	// 被探测函数的名字
unsigned int offset;  // 相对于入口处的偏移,  用于探测函数内部的指令,为0表示函数入口
kprobe_pre_handler_t pre_handler;    // 用户自定义,会先于被探测指令的执行
kprobe_post_handler_t post_handler;  // 用户自定义,探测指令执行完之后执行
kprobe_fault_handler_t fault_handler;  // 用户自定义,错误发生在pre/post handler中时执行
kprobe_break_handler_t break_handler; // 执行某kprobe过程中触发了断点指令后会调用该函数,用于实现jprobe
kprobe_opcode_t opcode;	// 保存的被探测点单字节原始指令(会被替换为breakpoint指令)
struct arch_specific_insn ainsn;  // 原始指令备份,用于单步执行,架构相关
u32 flags; 	// 状态标记
};

kprobes API

int register_kprobe(struct kprobe *kp)      //向内核注册kprobe探测点
void unregister_kprobe(struct kprobe *kp)   //卸载kprobe探测点
int register_kprobes(struct kprobe **kps, int num)     //注册探测函数向量,包含多个探测点
void unregister_kprobes(struct kprobe **kps, int num)  //卸载探测函数向量,包含多个探测点
int disable_kprobe(struct kprobe *kp)       //临时暂停指定探测点的探测
int enable_kprobe(struct kprobe *kp)        //恢复指定探测点的探测

int register_kretprobe(struct kretprobe *rp)  
void unregister_kretprobe(struct kretprobe *rp)
int register_kretprobes(struct kretprobe **rps, int num)
void unregister_kretprobes(struct kretprobe **rps, int num)

kprobes 处理流程

  • 保存被探测地址处的原始指令
  • 用breakpoint指令替换原始指令
  • 当执行到BP指令时, 会执行 kprobe pre_handler
  • single-step 单步执行原始指令
  • 执行 kprobe post_handler,如果注册时提供了的话
  • 返回正常执行流程

kprobes 注册

两种方式注册探测点 同时提供要探测函数的符号名称字符串symbol(kallsyms中可以找得到) 和 offset
直接提供一个地址, 可以位于函数体任何位置
二者同时提供注册会失败

  • check_kprobe_address_safe() // 检查地址是否可以探测
  • prepare_kprobe() // 拷贝probed instruction
  • arm_kprobe() // 插入breakpoint 指令
check_kprobe_address_safe
  • 被探测地址必须落在内核文本段范围内
  • 不能落在指定的黑名单范围内,即实现kprobes的关键代码路径
    _kprobes_text_start ~ __kprobes_text_end
    宏__kprobes标记的内核函数会放在kprobe text
    宏NOKPROBE_SYMBOL声明的接口会放在_kprobe_blacklist
    • 不能落在jump lable保留的地址空间范围内(jump label涉及tracepoint的一种处理机制)
  • 不能落在已经加载完成的模块的init text范围,因为这块内存已经释放了
prepare_kprobe
  • 需要保存原始指令
  • 内核使用executable page保存probed instructions
  • 总是按照指令最大字节数拷贝指令
  • 调整相对跳转指令(如果需要的话)

x86_64架构有 x86 NX support特性, page会被标记为不包含可执行代码属性
因为kprobe保存的原始指令后续还需要single-step执行,所以必须保存到可执行属性页面
所以会给分配的page设置标记位 PAGE_KERNEL_EXEC
内核使用自定义的cache策略在可执行页面上为probed instructition分配对应的slot
DEFINE_INSN_CACHE_OPS(insn); __get_insn_slot 分配对应的 kprobe_insn_cache
然后把原始指令拷贝到slot上
kprobe成员变量struct arch_specific_insn ainsn就用于存储原始指令在exec page上slot的字节首地址

arm_kprobe
  • 写入断点指令 (arch/x86/kernel/alternative.c)
  • 禁中断
  • 利用fixmap(FIX_TEXT_POKE0)拿到临时虚拟地址
  • 原子地插入breakpoint指令
  • 清掉fixmap并刷新TLB
  • 无效化icache和prefetch (cpuid汇编指令)
  • 开中断

最后一步就是调用text_poke插入断点指令 (x86上就是 int3 -> 0xcc )  
#define BREAKPOINT_INSTRUCTION 0xcc
text_poke(p->addr, ((unsigned char []){BREAKPOINT_INSTRUCTION}), 1);

x86 single-step

在单步执行某条指令前产生一次int3异常 (do_int3), 就是最初插入的0xCC指令码

在异常处理流程里面 do_int3 -> kprobe_int3_handler -> setup_singlestep

  • 把CPU的EFLAGS寄存器的TF(调试位)设置1
  • 把IF(中断屏蔽位)设置为0
  • EIP调整原始指令所在地址
  • do_int3异常返回,执行单步指令,因为TF置位, 指令执行完就会触发调试异常do_debug
  • do_debug -> resume_execution调整返回地址
  • do_debug里会调用post_handler(如果注册时有提供的话)
  • 恢复正常流程

kretprobe

函数返回时触发kretprobes,可以用于跟踪返回值,统计耗时等
每个kretprobe需要提供两个handler,一个entry时触发,一个返回时触发
结合trampoline机制实现返回跟踪

kretprobe 结构体 

typedef int (*kretprobe_handler_t) (struct kretprobe_instance *, struct pt_regs *);

 struct kretprobe {                                                                                                                                            
     struct kprobe kp;  
     kretprobe_handler_t handler;
     kretprobe_handler_t entry_handler;
     int maxactive;           // 并行实例最大数量
     int nmissed;             // 探测点触发丢失统计计数
     size_t data_size;        // 实例私有数据
     struct hlist_head free_instances; // 空闲实例链表
     raw_spinlock_t lock;
 };                

kretprobe 处理流程

x86_64的返回地址位于栈上,所以修改返回地址时都会操作栈

  • 触发断点
  • kretprobe pre_handler被调用pre_handler_kretprobe
  • 保存函数返回地址并修改栈上返回地址为kretprobe_trampoline
  • 被探测函数返回时 就会返回到kretprobe_trampoline
  • kretprobe_trampoline -> trampoline_handler会执行注册kretprobe时提供的handler并把rpi归还到freelist
  • kretprobe_trampoline返回前, 恢复之前保存的寄存器,把原始返回地址写入栈,最终返回正常执行流程

trampoline直接用的汇编代码,汇编代码负责保存所有的寄存器
然后调用trampoline_handler恢复寄存器,最后返回kprobe保存的实际返回地址。

static void __used kretprobe_trampoline_holder(void)                                                                                                          
 {
     asm volatile (
             ".global kretprobe_trampoline\n"
             "kretprobe_trampoline: \n"
             /* We don't bother saving the ss register */
             "   pushq %rsp\n"
             "   pushfq\n"
             SAVE_REGS_STRING
             "   movq %rsp, %rdi\n"
             "   call trampoline_handler\n"
             /* Replace saved sp with true return address. */
             "   movq %rax, 152(%rsp)\n"
             RESTORE_REGS_STRING
             "   popfq\n"
            "   ret\n");
}

kretprobe_instance

函数可以递归
针对同一个函数,可能存在多个返回探针实例,并且同时在多个cpu上运行
SMP上,每个cpu都有可能在执行该函数
函数可能持有互斥锁和信号量的情况下,因抢占而释放cpu,或者调用schedule显式释放cpu

kprobes需要跟踪记录每一个实例的真实返回地址,struct kretprobe_instance 用于这个目的

struct kretprobe_instance {
     struct hlist_node hlist;
     struct kretprobe *rp;
     kprobe_opcode_t *ret_addr;
     struct task_struct *task;
     char data[0];
 };    

pre_handler_kretprobe中会保存返回地址,该函数不能休眠,不能在这里分配instance

krpobes模块自己不知道会有多少个实例,这个要使用该返回探针的用户自己决定
这个就由struct kretprobe结构体成员变量 maxactive记录
在调用register_kretprobe之前,需要用户自己设置maxactive的值

  • 如果被探测函数非递归,并且持有spinlock,maxactive = 1就够了
  • 如果被探测函数非递归,并且持有信号量,或者引起抢占,maxactive = NR_CPUS设置为cpu个数
  • 如果用户注册时提供的maxactive <= 0 ,maxactive会被kprobes设置为一个默认值
  • 如果打开了CONFIG_PREEMPT配置项,maxactive设置为max(10, 2*NR_CPUS);否则设置为NR_CPUS

第一次注册kretprobe是设置nmissed为0
如果maxactive设置的太小,也不会导致问题,顶多miss掉,不会触发handler
因为没有足够的kretprobe_instance去跟踪用户注册的返回探测点。这时就会递增nmissed值

kprobes reentry

kprobes使用kprobe_ctlblk处理可重入事件
kprobes的一个应用场景就是用户提供自定义handler收集数据信息,这肯定会调用其它内核函数,例如:
a. foo()函数上设置过探测点,foo()又调用了bar() ,bar也被设置过探测点,这就发生kprobe重入了
b. kprobe正在处理时有可能再次触发异步事件(比如IRQ handler,多数架构处理kprobe事件时是使能中断的)

应对:

  • 添加状态信息标识重入
  • 添加重入次数统计计数
  • 添加辅助结构体kprobe_ctlblk,记录被重入的kprobe事件的状态和标识信息
     /* per-cpu kprobe control block */
     struct kprobe_ctlblk {
       unsigned long kprobe_status; // 记录是否发生重入
       unsigned long kprobe_old_flags; // prev kprobe flags
       unsigned long kprobe_saved_flags;  // current kprobe flags
       ...
       struct prev_kprobe prev_kprobe;   //用于保存中间kprobe事件(即被抢占、被重入的kprobe)
     };
    

定义percpu结构体struct kprobe变量current_kprobe,用于标记是否当前正在处理重入事件
非NULL表示发生了重入,current_kprobe则指向cpu当前正在处理的事件
为NULL表示当前cpu没有在处理kprobe事件,这个是常规处理流程
kprobe_status变量表明发生重入,同时递增nmissed变量(可用于警告用户发生事件丢失)。

struct prev_kprobe用于保存中间kprobe事件(即被抢占、被重入的kprobe)
同时prev_kprobepre_handler/post_handler就会被跳过,不再执行,防止发生死循环
当重入kprobe处理完之后,借助于上述辅助变量,继续执行被重入的kprobe事件

kprobe state machine


optimization

一个探测点需要触发两个异常,一个断点异常和一个single-step异常
断点异常是必需的, 因为要让内核陷入异常处理流程,但是single-step时的异常就不见得了

single-step out-of-line处理流程:

  1. kprobes 单步执行指令拷贝,紧接着trap异常把控制权返回给kprobe
  2. 执行post_handler,如果有注册的话
  3. 第一步执行完之后,PC寄存器,返回地址,或者还有其他值,有可能是错误的, 因为执行指令拷贝和执行原始指令是有差异的 kprobes需要搞定fixup这种情况
  4. kprobes从trap异常返回,继续执行探测点后面的指令

如果注册时没有提供post handler,第2步是可以去掉的。多数指令并不需要fixup过程,因而第3步也可以去掉。
如果只剩第1步和第4步,就可以用一条跳转指令替换。跳转指令可以附加到拷贝指令的后面。
注册kprobe之后, 即arm_kprobe插入过breakpoint指令之后进行优化处理

 /*
  * Internal structure for direct jump optimized probe
  */
 struct optimized_kprobe {                                                                                                                                     
     struct kprobe kp;
     struct list_head list;  /* list for optimizing queue */
     struct arch_optimized_insn optinsn;
 };

arch_optimize_kprobes

优化跳转指令写入探测点

 void arch_optimize_kprobes(struct list_head *oplist)                                                                                                          
 {                                     
     struct optimized_kprobe *op, *tmp;
     u8 insn_buf[RELATIVEJUMP_SIZE];   
     list_for_each_entry_safe(op, tmp, oplist, list) { // optinsn.insn就是handler
         s32 rel = (s32)((long)op->optinsn.insn - ((long)op->kp.addr + RELATIVEJUMP_SIZE)); // 计算处相对跳转偏移
         WARN_ON(kprobe_disabled(&op->kp));
         /* Backup instructions which will be replaced by jump address */
         memcpy(op->optinsn.copied_insn, op->kp.addr + INT3_SIZE, RELATIVE_ADDR_SIZE);   
         insn_buf[0] = RELATIVEJUMP_OPCODE;
         *(s32 *)(&insn_buf[1]) = rel;
         text_poke_bp(op->kp.addr, insn_buf, RELATIVEJUMP_SIZE, op->optinsn.insn); // 把跳转到handler的完整指令写入到p->addr里
         list_del_init(&op->list);     
     }                                 
 }     

text_poke_bp

分开写入的原因是一次性写入多个字节保证不了原子性

 /**
  * text_poke_bp() -- update instructions on live kernel on SMP
  * Modify multi-byte instruction by using int3 breakpoint on SMP.
  * We completely avoid stop_machine() here, and achieve the
  * synchronization using int3 breakpoint.
  *
  * The way it is done:
  *  - add a int3 trap to the address that will be patched // 只把addr处首字节替换为int3(0xcc)
  *  - sync cores   // 同步,使得其它cpu可见
  *  - update all but the first byte of the patched range // 把跳转指令的地址写进去
  *  - sync cores  // 同步,使得其它cpu可见
  *  - replace the first byte (int3) by the first byte of  // 把int3(0xcc) 替换为跳转指令(0xe9)
  *    replacing opcode
  *  - sync cores
  */

usage - kernel module

自己写一个内核模块,调用注册接口,插入探测点

static struct kprobe kp = {
    .symbol_name = "kernel_thread",
    .pre_handler = handler_pre,
    .post_handler = handler_post,
    .fault_handler = handler_fault,
};
register_kprobe(&kp);

static struct kprobe kp0 = {
    .symbol_name    = "loopback_xmit",
    .pre_handler = NULL,
    .post_handler = NULL,
    .fault_handler = NULL,
    .offset = 100,
};
register_kprobe(&kp0);

usage - kprobes events

直接 tracefs 下使用 kprobe events接口

echo > set_event && echo > kprobe_events  
echo 'p:N cxt_save+37 s=+0x944(%r12):u8 t=+0x945(%r12):u8 ct=+0x946(%r12):u8 ts=%ax:u32 NR=+120(%di) p=+0x5c0(%r12):string' >> kprobe_events  
echo 'p:X cxt_rest+35 s=+0x944(%r12):u8 t=+0x945(%r12):u8 ct=+0x946(%r12):u8 ts=%ax:u32 NR=+120(%di) p=+0x5c0(%r12):string' >> kprobe_events  
echo 'comm == world' > events/kprobes/filter && echo 1 > events/kprobes/enable
echo 1 > tracing_on  
echo > trace  

[参考链接]
< Probing the Guts of Kprobes >
https://www.kernel.org/doc/Documentation/kprobes.txt
https://cloud.tencent.com/developer/article/1463357