實戰 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 < 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[1] 中的代碼進行驗證,

主要是做了如下改動,增加對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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Yxa60oZ3XUbr4qXcXC22Qw