4

实战eBPF kprobe函数插桩

 1 year ago
source link: https://www.cnxct.com/using-ebpf-kprobe-to-file-notify/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

实战eBPF kprobe函数插桩 – CFC4N的博客

本文内容,如非特殊说明,均基于 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 个参数,大家可自行修改代码验证。

祝大家玩得开心。

参考文献:

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK