實戰 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 函數,可以簡單地得出如下結論:
-
超過 6 個參數的函數調用,需要用到棧傳遞
-
前 6 個參數,分別使用 di、si、dx、cx、r8、r9
-
使用棧傳遞的參數,是從右向左壓棧,此例中先壓入 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 操作後,棧和寄存器的狀態如下:
實戰 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 2 如下輸出
爲了保持嚴謹性,可以使用 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 輸出:
inotify 輸出:
輸出符合預期,剩下的第 8 個參數,大家可自行修改代碼驗證。
祝大家玩得開心。
參考文獻:
-
https://eyakubovich.github.io/2022-04-19-ebpf-kprobe-params/
-
https://eli.thegreenplace.net/2011/09/06/stack-frame-layout-on-x86-64
-
https://man7.org/linux/man-pages/man7/inotify.7.html
-
https://github.com/iovisor/bcc/blob/master/src/cc/frontends/clang/b_frontend_action.cc
-
https://elixir.bootlin.com/linux/latest/source/arch/x86/include/asm/ptrace.h#L346
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Yxa60oZ3XUbr4qXcXC22Qw