libcのopenはopenat(2)を呼ぶ

eBPFを試してみた。 Pythonのサンプルコードを拾ってきてシステムコールをフックしてみた。 ところが open(2) をフックできなかった。一方で openat(2) はフックされた。 環境はこれ。

1
2
$ uname -a
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

システムコールによってkprobeで登録後に実際にイベントが来るものと来ないものがあった。 まとめると下のようになる。本当に基本的なシステムコールでしか試してない。

システムコール kprobe経由でイベントを受け取れた
clone TRUE
fork FALSE
open FALSE
openat TRUE
close TRUE

調べたところlibcの中で open(3)openat(2) を呼び出していた。 具体的にはlibc/sysdeps/unix/sysv/linuxopen.c, openat.c, open64.c, openat64.c に書かれている。 fork も同様に clone(2)呼び出していた

再現方法

システムコールの呼び出しをトレースするスクリプト、システムコールを呼び出すターゲットプログラムの2つを準備する。

トレースするスクリプトはサンプルコードを真似て書いた。 下のスクリプトを準備してsudo python3 ./example.pyする。 (実際には close(2) はgrepで除外した。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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) を呼び出すプログラムを作って確認した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#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 buf[2000];
  read(fd, buf, 2000);
  printf("%s\n", buf);
}

gcc ./target.c -o targetでコンパイルして叩く。 したみたいな事になる。 clone(2), openat(2) が呼ばれている。 kprobeのフックポイントより前に open(2)openat(2) に変わっている。

1
2
3
4
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とカーネル内部。

  • kprobe , ptrace で具体的に何を取得しているか?
  • Cで open() を読んだときに具体的に何が呼ばれるか?

あたりを調べた。

まず strace 経由でシステムコールを確認した。 これによると ptrace でも openat(2) が見えている。

1
2
3
4
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

結論

libc の中で open(3)openat(2) を呼び出していた。具体的にはlibc/sysdeps/unix/sysv/linuxopen.c, openat.c, open64.c, openat64.c に書かれている。 fork(3) も同様に clone(2)呼び出していた

open64.c の場合は下のようになっている。ここで SYSCALL_CANCELsysdeps/unix/sysdep.hで定義されていて実際にシステムコールを発行するマクロに展開されていく。

1
2
3
4
5
6
__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(2) しか取れなかったため。 実際にカーネル側を確認したところユーザープロセスのレジスタを普通にコピーしてそうなオフセットテーブルがあった。となるとユーザープロセスの %rax レジスタは呼び出し時から変わってないはず。そうなるとカーネル内部で do_sys_openat の呼び出しがされても関係がない。

なかなかに判断が遅れた。Linuxもlibcも読み慣れてないのでもっと読んだりフックしたりして遊んでおきたい。 デバッグハックも読み返そうと思った。

最近はScrapboxに書いて放置が多い。 これももともとここに書いたものからコピペして編集した。 PlantUMLとかを簡単に利用できて気楽に書けるUI、アクセスの確認と検索ができてテキストとして管理できるのが嬉しい。 やはりZennだろうか。

参考資料

Kprobeの使い方や他のフック方法との比較などは下が参考になった。

comments powered by Disqus