实战eBPF kprobe函数插桩

本文作者为团队小伙伴阿松,在linux文件监控领域实战经验丰富。本次引入ebpf在文件监控上应用,提升文件变更的关联进程信息等。在实现过程中,分享了ebpf kbproe时,被插桩函数超多参数获取的解决方案。
本文内容,如非特殊说明,均基于 4.18 内核,x86-64 cpu 架构。
插桩的程序类型选择
说起 ebpf 大家都不陌生,就内核而言,hook 会尽可能选在 tracepoint,如果没有 tracepoint,会考虑使用 kprobe。
tracepoint 的范围有限,而内核函数又太多,基于各种需求场景,kprobe 的出场机会较多;但需要注意的,并不是所有的内核函数都可以选作 hook 点,inline 函数无法被 hook,static 函数也有可能被优化掉;如果想知道究竟有哪些函数可以选做 hook 点,在 linux 机器上,可以通过less /proc/kallsyms查看。
使用 ebpf 时,内核代码 kprobe 的书写范例如下:
sec(kprobe/vfs_write)int kprobe_vfs_write(struct pt_regs *regs){    struct file *file    file = (struct file *)pt_regs_parm1(regs);    // ...}  
其中 pt_regs 的结构体如下:
struct pt_regs {/* * c abi says these regs are callee-preserved. they aren't saved on kernel entry * unless syscall needs a complete, fully filled struct pt_regs. */ unsigned long r15; unsigned long r14; unsigned long r13; unsigned long r12; unsigned long bp; unsigned long bx;/* these regs are callee-clobbered. always saved on kernel entry. */ unsigned long r11; unsigned long r10; unsigned long r9; unsigned long r8; unsigned long ax; unsigned long cx; unsigned long dx; unsigned long si; unsigned long di;/* * on syscall entry, this is syscall#. on cpu exception, this is error code. * on hw interrupt, it's irq number: */ unsigned long orig_ax;/* return frame for iretq */ unsigned long ip; unsigned long cs; unsigned long flags; unsigned long sp; unsigned long ss;/* top of stack page */};  
通常来说,我们要获取的参数,均可通过诸如 pt_regs_parm1 这样的宏来拿到,宏定义如下:
#define pt_regs_parm1(x) ((x)->di)#define pt_regs_parm2(x) ((x)->si)#define pt_regs_parm3(x) ((x)->dx)#define pt_regs_parm4(x) ((x)->cx)#define pt_regs_parm5(x) ((x)->r8)  
可以看到,上述的宏只能获取 5 个参数;但是在最近的一个项目中,就遇到了如何获取超过 5 个参数的难题,这也是本文的由来,如果你也有类似的困惑,本文也许是为你准备的。
如何获取插桩函数中第 6 个参数
上述的 5 个宏已经可以覆盖大多数的获取小于 5 个参数的需求,不知道大家有没有想过,使用 ebpf 时如果获取的参数个数大于 5 个怎么办呢?
如下的内核函数__get_user_pages(幸运的是,该 static 函数并未被优化掉):
static long __get_user_pages(struct task_struct *tsk, struct mm_struct *mm,  unsigned long start, unsigned long nr_pages,  unsigned int gup_flags, struct page **pages,  struct vm_area_struct **vmas, int *nonblocking)  
在希望对这个函数进行 hook 的时候犯了难,该函数总共有 8 个参数,如果想拿到最后 3 个参数,该如何操作呢?
且看 bcc 是如何操作的。
bcc 代码中明确表明:只支持寄存器参数。那什么是寄存器参数呢?其实就是内核函数调用约定中的前 6 个参数要通过寄存器传递,只支持这前六个寄存器参数。
constexpr int max_calling_conv_regs = 6;const char *calling_conv_regs_x86[] = {  di, si, dx, cx, r8, r9};bool btypevisitor::visitfunctiondecl(functiondecl *d) {    if (d->param_size() > max_calling_conv_regs + 1) {      error(get_beginloc(d->getparamdecl(max_calling_conv_regs + 1)),            too many arguments, bcc only supports in-register parameters);      return false;    }}  
bcc 中使用如下的代码对用户写的bpf text进行rewrite,覆盖的参数刚好是前 6 个参数,分别保存于di, si, dx, cx, r8, r9寄存器:
const char *calling_conv_regs_x86[] = {  di, si, dx, cx, r8, r9};void btypevisitor::genparamdirectassign(functiondecl *d, string& preamble,                                        const char **calling_conv_regs) {  for (size_t idx = 0; idx = 1) {      // move the args into a preamble section where the same params are      // declared and initialized from pt_regs.      // todo: this init should be done only when the program requests it.      string text = rewriter_.getrewrittentext(expansionrange(arg->getsourcerange()));      arg->addattr(unavailableattr::createimplicit(c, ptregs));      size_t d = idx - 1;      const char *reg = calling_conv_regs[d];      preamble +=   + text +  = ( + arg->gettype().getasstring() + ) +                  fn_args_[0]->getname().str() + -> + string(reg) + ;;    }  }}  
看到这里,大家应该明白,之所以能使用 bcc 提供的如此简便的 python 接口(内核函数前面加上前缀 kprobe__,第一个参数永远是struct pt_regs *,然后需要使用几个内核参数就填写几个)来做一些监控工作,是因为 bcc 在幕后做了大量的 rewirte 工作,respect!
int kprobe__tcp_v4_connect(struct pt_regs *ctx, struct sock *sk) {    [...]}  
之前总是由于 ebpf 给的限制(按照 ebpf 的 calling convention,只有 5 个参数可以传递),以为更多的参数是无法获取的。实际上可以回忆下,实际上按照 amd64 的调用约定,最多是可以通过寄存器传递 6 个参数的。
这么看下来,获取第 6 个参数的方案其实也是很简单,手动添加如下的宏即可:
#define pt_regs_parm6(x) ((x)->r9)  
插桩函数超过 6 个参数怎么办
amd64 的调用约定同样规定了,超过 6 个的参数,都会在栈上传递,具体可以参考regs_get_kernel_argument
那么如果参数超过 6 个,处理方案呼之欲出:从栈上获取。
regs_get_kernel_argument该函数在新版本的内核中才有,实现如下:
static inline unsigned long regs_get_kernel_argument(struct pt_regs *regs,           unsigned int n){ static const unsigned int argument_offs[] = {#ifdef __i386__  offsetof(struct pt_regs, ax),  offsetof(struct pt_regs, dx),  offsetof(struct pt_regs, cx),#define nr_reg_arguments 3#else  offsetof(struct pt_regs, di),  offsetof(struct pt_regs, si),  offsetof(struct pt_regs, dx),  offsetof(struct pt_regs, cx),  offsetof(struct pt_regs, r8),  offsetof(struct pt_regs, r9),#define nr_reg_arguments 6#endif }; if (n >= nr_reg_arguments) {  n -= nr_reg_arguments - 1;  return regs_get_kernel_stack_nth(regs, n); } else  return regs_get_register(regs, argument_offs[n]);}  
从上述的代码可以看到,常用的前 6 个参数,确实是在寄存器中获取,分别是di, si, dx, cx, r8, r9,这也印证了我们之前的想法,且和 bcc 中的行为是一致的。
从regs_get_kernel_argument中也可以看到,从第 7 个参数开始,便开始从栈上获取了,关键函数为:regs_get_kernel_stack_nth,这个函数在 4.18 内核中也有,如下:
static inline unsigned long regs_get_kernel_stack_nth(struct pt_regs *regs, unsigned int n){ unsigned long *addr = (unsigned long *)kernel_stack_pointer(regs); addr += n; if (regs_within_kernel_stack(regs, (unsigned long)addr))  return *addr; else  return 0;}// 等价于bpf提供的帮助宏 #define pt_regs_sp(x) ((x)->sp)static inline unsigned long kernel_stack_pointer(struct pt_regs *regs){ return regs->sp;}  
regs_get_kernel_stack_nth是标准的栈上操作获取,只不过内核提供了一些地址合法性的检查,不考虑这些的话,在 ebpf 中其实可以一步到位;使用如下函数,便能返回栈上的第 n 个参数(从 1 开始)。
static __always_inline unsigned long regs_get_kernel_stack_nth(struct pt_regs *regs,            unsigned int n){ unsigned long *addr;  unsigned long val; addr = (unsigned long *)pt_regs_sp(x) + n; if (addr) {     bpf_probe_read(&val, sizeof(val), addr);     return val; } return 0;}  
捎带提一句,在 amd64 中,ebpf calling abi 使用了 r1-r5 来传递参数,且做了如下的寄存器映射约定,方便 jit 转换为 native code,提高效率。
r0 – rax      return value from functionr1 – rdi      1st argumentr2 – rsi      2nd argumentr3 – rdx      3rd argumentr4 – rcx      4th argumentr5 – r8       5th argumentr6 – rbx      callee savedr7 - r13      callee savedr8 - r14      callee savedr9 - r15      callee savedr10 – rbp     frame pointer  
而 r0 - r10,是 bpf 虚拟机的内部的特殊标识符(函数调用等地方使用),如果 jit 可用,bpf code 会被翻译为native code。
linux amd64 调用约定
demo 验证
那 amd64 的 abi 是如何操作的呢?可以使用如下的代码进行验证:
# cat myfunc.cint utilfunc(int a, int b, int c){    int xx = a + 2;    int yy = b + 3;    int zz = c + 4;    int sum = xx + yy + zz;    return xx * yy * zz + sum;}int myfunc(int a, int b, int c, int d,            int e, int f, int g, int h){    int xx = (a + b) * c * d * e * (f + (g * h));    int zz = utilfunc(xx, 2, xx % 2);    return zz + 20;}int main() {    myfunc(1, 2, 3, 4, 5, 6, 7, 8);    return 0;}  
gcc -c -g myfunc.c进行编译汇编得到 myfunc.o
ebpf 字节码反汇编
objdump -s myfunc.o反汇编,查看调用约定是不是和我们从教科书上看到的一致
先看 main 函数,可以简单地得出如下结论:
超过 6 个参数的函数调用,需要用到栈传递
前 6 个参数,分别使用 di、si、dx、cx、r8、r9
使用栈传递的参数,是从右向左压栈,此例中先压入 8,再压入 7
00000000000000c4 :int main() {  c4: f3 0f 1e fa           endbr64  c8: 55                    push   %rbp  c9: 48 89 e5              mov    %rsp,%rbp    myfunc(1, 2, 3, 4, 5, 6, 7, 8);  cc: 6a 08                 push   $0x8    #栈上传递参数  ce: 6a 07                 push   $0x7    #栈上传递参数  d0: 41 b9 06 00 00 00     mov    $0x6,%r9d #如下是寄存器传递参数  d6: 41 b8 05 00 00 00     mov    $0x5,%r8d  dc: b9 04 00 00 00        mov    $0x4,%ecx  e1: ba 03 00 00 00        mov    $0x3,%edx  e6: be 02 00 00 00        mov    $0x2,%esi  eb: bf 01 00 00 00        mov    $0x1,%edi #第1个参数,寄存器传递  f0: e8 00 00 00 00        call   f5   f5: 48 83 c4 10           add    $0x10,%rsp    return 0;  f9: b8 00 00 00 00        mov    $0x0,%eax}  fe: c9                    leave  ff: c3                    ret  
用户空间程序调用
再看被 main 调用的 myfunc 函数的反汇编:
和 main 函数的调用参数排列一致,参数1-6是寄存器传递,参数7-8是栈上传递
int myfunc(int a, int b, int c, int d,            int e, int f, int g, int h){  50: f3 0f 1e fa           endbr64  54: 55                    push   %rbp  55: 48 89 e5              mov    %rsp,%rbp  58: 48 83 ec 28           sub    $0x28,%rsp  5c: 89 7d ec              mov    %edi,-0x14(%rbp) #第1个参数,从edi中复制到栈上  5f: 89 75 e8              mov    %esi,-0x18(%rbp)  62: 89 55 e4              mov    %edx,-0x1c(%rbp)  65: 89 4d e0              mov    %ecx,-0x20(%rbp)  68: 44 89 45 dc           mov    %r8d,-0x24(%rbp)  6c: 44 89 4d d8           mov    %r9d,-0x28(%rbp) #第6个参数    int xx = (a + b) * c * d * e * (f + (g * h));  70: 8b 55 ec              mov    -0x14(%rbp),%edx  73: 8b 45 e8              mov    -0x18(%rbp),%eax  76: 01 d0                 add    %edx,%eax      # a+b  78: 0f af 45 e4           imul   -0x1c(%rbp),%eax #(a+b) * c  7c: 0f af 45 e0           imul   -0x20(%rbp),%eax #(a+b) * c * d  80: 0f af 45 dc           imul   -0x24(%rbp),%eax #(a+b) * c * d * e  84: 89 c2                 mov    %eax,%edx  86: 8b 45 10              mov    0x10(%rbp),%eax  #栈上第1个参数 g  89: 0f af 45 18           imul   0x18(%rbp),%eax  # g*h  8d: 89 c1                 mov    %eax,%ecx  8f: 8b 45 d8              mov    -0x28(%rbp),%eax # 参数f  92: 01 c8                 add    %ecx,%eax        # (g*h) + f  94: 0f af c2              imul   %edx,%eax        # ((g*h) + f) * (a+b) * c * d * e  97: 89 45 f8              mov    %eax,-0x8(%rbp)  
寄存器堆栈状态
main 函数调用 myfunc,做完 prolog 操作后,栈和寄存器的状态如下:
main-myfunc
实战 kprobe 获取 6 个以上参数
说了那么多,到底是不是符合预期呢?尝试使用 bcc 验证下,为了方便验证,换了一个比较容易从用户态验证的 hook 点:inotify_handle_event
如果在 bcc 中使用了超过 6 个的参数,则会报错,比如函数 kprobe__inotify_handle_event 的原型如下:
int kprobe__inotify_handle_event(struct pt_regs *ctx, struct fsnotify_group *group,                         struct inode *inode,                         u32 mask, const void *data, int data_type,                         const unsigned char *file_name, u32 cookie,                         struct fsnotify_iter_info *iter_info)  
当在 bcc 中做超过 6 个参数的获取时,得到如下错误:
error: too many arguments, bcc only supports in-register parameters  
如果只使用前 6 个寄存器的参数,如下代码即可:
#!/usr/bin/pythonfrom bcc import bpf# load bpf programb = bpf(text=#include int kprobe__inotify_handle_event(struct pt_regs *ctx, struct fsnotify_group *group,                         struct inode *inode,                         u32 mask, const void *data, int data_type,                         const unsigned char *file_name){  char comm[128];  int pid = bpf_get_current_pid_tgid() >> 32;  bpf_get_current_comm(comm, sizeof(comm));  bpf_trace_printk(pid is:%d, comm is: %s\n, pid, comm);  bpf_trace_printk(file is: %s\n, file_name);  return 0;})b.trace_print()  
但是我们可以使用如下的方式,拿到剩下的参数(以 cookie 为例):
unsigned long cookie;  bpf_probe_read(&cookie, 8, (unsigned long*)pt_regs_sp(ctx) + 1);  
完整代码如下:
#!/usr/bin/pythonfrom bcc import bpf# load bpf programb = bpf(text=#include int kprobe__inotify_handle_event(struct pt_regs *ctx, struct fsnotify_group *group,                         struct inode *inode,                         u32 mask, const void *data, int data_type,                         const unsigned char *file_name){  char comm[128];  unsigned long cookie;  int pid = bpf_get_current_pid_tgid() >> 32;  bpf_probe_read(&cookie, 8, (unsigned long*)pt_regs_sp(ctx) + 1);  bpf_get_current_comm(comm, sizeof(comm));  bpf_trace_printk(pid is:%d, comm is: %s\n, pid, comm);  bpf_trace_printk(cookie is %d, file is: %s\n, cookie, file_name);  return 0;})b.trace_print()  
shell 1 运行 bcc 代码
./get-stack-arg.py  
shell 2 使用 inotify-tools 验证
[root@rmed ~]# inotifywait -m ./  
shell 3 做如下的操作
[root@rmed ~]# mv testfilea testfileb  
shell 1 如下输出
shell 1 output
shell 2 如下输出
shell 2 output
为了保持严谨性,可以使用https://man7.org/linux/man-pages/man7/inotify.7.html[1] 中的代码进行验证,
主要是做了如下改动,增加对in_moved_from | in_moved_to的监控:
diff --git a/inotify.c b/inotify.cindex 08fa55a..7116a9a 100644--- a/inotify.c+++ b/inotify.c@@ -61,6 +61,10 @@                    if (event->mask & in_close_write)                        printf(in_close_write: );+                   if (event->mask & in_moved_from)+                       printf(in_moved_from: );+                   if (event->mask & in_moved_to)+                       printf(in_moved_to: );                    /* print the name of the watched directory. */                    for (int i = 1; i len)                        printf(%s, event->name);+                   if (event->cookie)+                       printf(cookie: %d, event->cookie);                    /* print type of filesystem object. */                    if (event->mask & in_isdir)@@ -123,7 +129,7 @@            for (i = 1; i < argc; i++) {                wd[i] = inotify_add_watch(fd, argv[i],-                                         in_open | in_close);+                                         in_open | in_close | in_moved_from | in_moved_to);                if (wd[i] == -1) {                    fprintf(stderr, cannot watch '%s': %s,                            argv[i], strerror(errno));@@ -182,3 +188,4 @@  
同样的,使用 bcc 和自己编译的 inotify 工具验证。
bcc 输出:
ebpf-bcc-inotify
inotify 输出:
ebpf-inotify
输出符合预期,剩下的第 8 个参数,大家可自行修改代码验证。
祝大家玩得开心。


基于NiosII的高精度数控直流稳压电源设计
美光表示下半年旗舰手机内存有望提升到16GB
一文读懂AW516x zigbee如何自组网功能
移动电源电芯优缺点分析
谷歌生命科学子公司Verily将关闭上海办事处,退出中国
实战eBPF kprobe函数插桩
新车碰撞评估测试为高级驾驶辅助系统(ADAS)建立安全评级
六类网线有效传输距离是多少?
爱普特面向通用市场推出兼容RV32 EMC指令架构的RISC-V MCU新品
欧司朗首推引领特殊用户体验潮流的汽车照明产品
中国科学院稀土研究院-PLC采集项目案例
ADI 音频处理专家与 Damson Global 世界级音频系统设计师之间的故事
如何为现有工厂提供IIoT解决方案
这家公司自主研发的新型低成本加速度传感器迎来“爆发式”需求
夏普功臣要辞职?或将继续由日本人统领
iPhone11ProMax的峰值充电功率有多少
源科即将推出MCP新品rSSD
无功补偿电容器损坏的原因及解决方案
区块链技术正在改变我们生活中的各个方面
跑步机控制板BGA芯片用底部填充胶