实战eBPF kprobe函数插桩

本文内容,如非特殊说明,均基于 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 < fn_args_.size(); idx++) {
    ParmVarDecl *arg = fn_args_[idx];

    if (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 function
R1 – rdi      1st argument
R2 – rsi      2nd argument
R3 – rdx      3rd argument
R4 – rcx      4th argument
R5 – r8       5th argument
R6 – rbx      callee saved
R7 - r13      callee saved
R8 - r14      callee saved
R9 - r15      callee saved
R10 – rbp     frame pointer

而 R0 – R10,是 bpf 虚拟机的内部的特殊标识符(函数调用等地方使用),如果 jit 可用,bpf code 会被翻译为native code

Linux Amd64 调用约定

demo 验证

那 Amd64 的 ABI 是如何操作的呢?可以使用如下的代码进行验证:

# cat myfunc.c
int 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 函数,可以简单地得出如下结论:

  1. 超过 6 个参数的函数调用,需要用到栈传递
  2. 前 6 个参数,分别使用 di、si、dx、cx、r8、r9
  3. 使用栈传递的参数,是从右向左压栈,此例中先压入 8,再压入 7
00000000000000c4 <main>:

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 <main+0x31>
  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/python

from bcc import BPF

# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>
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/python

from bcc import BPF

# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>
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 中的代码进行验证,

主要是做了如下改动,增加对IN_MOVED_FROM | IN_MOVED_TO的监控:

diff --git a/inotify.c b/inotify.c
index 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 < argc; ++i) {
@@ -75,6 +79,8 @@
                    if (event->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\n",
                            argv[i], strerror(errno));
@@ -182,3 +188,4 @@

同样的,使用 BCC 和自己编译的 inotify 工具验证。

BCC 输出:

ebpf-bcc-inotify

inotify 输出:

ebpf-inotify

输出符合预期,剩下的第 8 个参数,大家可自行修改代码验证。

祝大家玩得开心。

参考文献:

  1. https://eyakubovich.github.io/2022-04-19-ebpf-kprobe-params/
  2. https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64
  3. https://man7.org/linux/man-pages/man7/inotify.7.html
  4. https://github.com/iovisor/bcc/blob/master/src/cc/frontends/clang/b_frontend_action.cc
  5. https://elixir.bootlin.com/linux/latest/source/arch/x86/include/asm/ptrace.h#L346

知识共享许可协议CFC4N的博客CFC4N 创作,采用 署名—非商业性使用—相同方式共享 4.0 进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:实战eBPF kprobe函数插桩