eBPFを試してみた
#勉強
pythonでeBPFを使ってsyscallをフックしてみた。ところがopen(2)をフックできなかった。
一方でopenat(2)はフックできた。システムコールによってkprobeで登録後に実際にイベントが来るものと来ないものがあった。まとめると下のようになる。本当に基本的なシステムコールでしか試してない。
table:syscall-event-hook
システムコール kprobe経由でイベントを受け取れた
clone TRUE
fork FALSE
open FALSE
openat TRUE
close TRUE
環境はこれ。
code:uname-a.sh
Linux test 4.15.0-124-generic #127-Ubuntu SMP Fri Nov 6 10:54:43 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
実験方法
システムコールの呼び出しをトレースするスクリプト、システムコールを呼び出すターゲットプログラムの2つを準備する。
トレースするスクリプトはサンプルコードを真似て書いた。
下のスクリプトを準備してsudo python3 ./example.pyする。(実際にはclose(2)はgrepで除外した。)
code:example.py
from bcc import BPF
import time
bpf_code = """
int trace_clone(void *ctx) {
bpf_trace_printk("Hello: clone(2)\\n");
return 0;
}
int trace_fork(void *ctx) {
bpf_trace_printk("Hello: fork(2)\\n");
return 0;
}
int trace_openat(void *ctx) {
bpf_trace_printk("Hello: openat(2)\\n");
return 0;
}
int trace_open(void *ctx) {
bpf_trace_printk("Hello: open(2)\\n");
return 0;
}
int trace_close(void *ctx) {
bpf_trace_printk("Hello: close(2)\\n");
return 0;
}
"""
bpf = BPF(text=bpf_code)
for name in "close", "open", "openat", "clone", "fork":
syscall_name = bpf.get_syscall_fnname(name)
print(syscall_name)
bpf.attach_kprobe(event = syscall_name, fn_name = "trace_{}".format(name))
bpf.trace_print()
open(2)が見えなかったのでCで明示的にopen(2)を呼び出すプログラムを作って確認した。
code:target.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
int fd = open("./target.c", O_RDONLY);
char buf2000;
read(fd, buf, 2000);
printf("%s\n", buf);
}
gcc ./target.c -o targetでコンパイルして叩く。したみたいな事になる。clone(2), openat(2)が呼ばれている。
kprobeのフックポイントより前にopen(2)がopenat(2)に変わっている。
code:output.txt
b' bash-6017 000 .... 2058.442792: 0x00000001: Hello: clone(2)'
b' target-6992 001 .... 2058.443792: 0x00000001: Hello: openat(2)'
b' target-6992 001 .... 2058.443894: 0x00000001: Hello: openat(2)'
b' target-6992 001 .... 2058.444372: 0x00000001: Hello: openat(2)'
変換場所を探す
open(2)がopenat(2)になる場所を探した。候補はlibcとカーネル内部。
というわけでstrace経由でsyscallを確認してみた。これによるとptraceでもopenat(2)が見えている。
code:strace-target.sh
strace ./target 2>&1 1>/dev/null | grep open
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "./target.c", O_RDONLY) = 3
というわけで探した。kprobe, ptraceで具体的に何を取得しているか? Cでopen()を読んだときに具体的に何が呼ばれるか。あたりを調べた。
結論
libcの中でopen(3)がopenat(2)を呼び出していた。具体的にはlibc/sysdeps/unix/sysv/linuxの open.c, openat.c, open64.c, openat64.c に書かれている。forkも同様にclone(2)を呼び出していた。
open64.cの場合は下のようになっている。ここでSYSCALL_CANCELはsysdeps/unix/sysdep.hで定義されていて実際にシステムコールを発行するマクロに展開されていく。
code:open64.c
__libc_open64 (const char *file, int oflag, ...)
{
// ...
return SYSCALL_CANCEL (openat, AT_FDCWD, file, oflag | EXTRA_OPEN_FLAGS,
mode);
}
補足
カーネルの側でも同様にopen(2)の実際の処理であるdo_sys_openがdo_sys_openatを呼び出している。
open(2)のハンドラ定義は直下のSYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)がopen(2)でそのなかでdo_sys_open()を呼んでいる。
今回のeBPFはKprobeを使いftraceを利用してシステムコールの実行イベントをフックして動いている。
sys_openシンボルの先頭がフックされる場所で通過してないのは当然だ。ここからもlibcで置き換えられていると疑うのが自然だった。ちなみにftraceではftrace_modify_code_directがカーネル内の命令を置き換えている。
実際にlibcを疑い始めたのはptrace(2)でPTRACE_GETREGSを使った際にopenatしか取れなかったため。
実際にカーネル側を確認したところユーザープロセスのレジスタを普通にコピーしてそうなオフセットテーブルがあった。となるとユーザープロセスの%raxレジスタは呼び出し時から変わってないはず。そうなるとカーネル内部でdo_sys_openatの呼び出しがされても関係がない。
なかなかに判断が遅れた。Linuxもlibcも読み慣れてないのでもっと読んだりフックしたりして遊んでおきたい。
参考資料
Kprobeの使い方や他のフック方法との比較などは下が参考になった。
perf, ftraceのしくみ
linux syscall table