Linuxシステムコール傍受方法の比較
アプリケーションはオペレーティング・システムが提供するサービスを要求する際にシステムコールという CPU 命令を使用する。このシステムコールを傍受してカスタム・コードを実行したり、またはその機能を完全に置き換えたりすることは、デバッグ、モニタリング、エミュレーション、仮想化、パフォーマンス・モニタリング、マルウェアの動作解析など、多くの用途がある。この記事では、x64 の CPU を前提にする。Linux でシステムコールをフックする方法はかなりたくさんあるので、いくつかを見ていきましょう。
ptrace
最もよく使われるのは多分ptraceというシステムコールです。これ通じて、トレースするプロセスがトレースされるプロセスにアタッチして、そのプロセスの実行フローを制御したり、メモリ内容を編集したりできるようにするものです。これは gdb などのデバッガーの基礎となっている。
具体的には、トレーサは PTRACE_SYSCALL
を要求することができ、そしてカーネルはトレースされるプロセスのシステムコールをトレースされたものとしてマークする。
トレーサがシステムコールを実行するとき、トレーサは制御を与えられ、システムコールを検査したり修正したりすることができる。同様に、システムコールが戻ってきたとき、トレーサーは再び制御を与えられ、戻り値を変更することができる。
これはシステムコールの傍受を扱う最も堅牢な方法である。ptrace は strace
というユーティリティで使用される。その例:
# 全プロセスのopenのシステムコールを書く
sudo strace -e trace=open,openat -f -p `pgrep -d, .`
eBPF
eBPFは、OS カーネル内でユーザー定義コードをサンドボックスで実行するための仮想マシンです。このコードは、eBPF と呼ばれる限定言語で記述されます。この言語は限定的(非チューリング完全)であるため、コンパイル時に特定の特性(例えば、非終端プログラムは拒否される)を静的にチェックする。デフォルトで Linux にバンドルされている。 カーネル内の特定のイベント(多くの場合、システムコールとネットワーク・アクティビティ)に応答してコードを実行するために使える。一般的なユースケースは、ネットワーク・パケットの検査/フィルタリングである。eBPF はシステムコールの監視には最適だが、着信システムコールの引数やその戻り値の変更はできない。さらに、eBPF は制限されサンドボックス化されているため、システムコールに応答する際にネットワーク I/O のような操作はできない。eBPF の例: For a quick example:
// test.bpf
tracepoint:syscalls:sys_enter_openat {
printf("PID %d (%s) opened file %s\n", pid, comm, str(args->filename));
}
このスクリプトはbpftrace
で実行できる:
sudo bpftrace ./test.bpf
SystemTap
SystemTapは、カーネル内のイベントに応答するハンドラをアタッチするスクリプトのツールです。SystemTap はこのスクリプトをカーネルモジュールにコンパイルし、ロードする。システムコールを傍受する例のスクリプト(*.stp)は以下のとおり:
// openprobe.stp
probe syscall.openat
{
//オープンされたファイルとPIDを書く
printf("PID %d (%s) is opening file: %s\n", pid(), execname(), user_string($filename))
}
そしてstap
で実行:
sudo stap ./openprobe.stp
こんなに簡単なのかと疑問が残るであろう。残念ながら、SystemTap は完璧な解決方法ではなくて、いくつかの問題があるそうだ。例えば、起動時間が長いのやクラッシュが多いこと。
LD_PRELOAD
LD_PRELOAD のトリックを通じてダイナミックライブラリを他のどのライブラリよりも先にロードして、既存のシンボルを上書きすることができる。 例えば、標準ライブラリ libc にあるreadやwriteのラッパー関数を上書きすれば、その背後に使われるシステムコールも上書きしたということなる。今までの方法と違ってカーネルを通らないのでパフォーマンスも良い。
そのデメリットは、すべてのシステムコールを傍受することができないことである。例えば、libc を静的リンクしたプログラムは使用できない。また、すべてのシステムコールが libc
に対応するラッパーがあるとは限らない。例えば、openat2はない。これらの場合、LD_PRELOAD
は使用できない。全プロセスのシステムコールを記録できないため、一つのプロセスの具体例:
// ldpreload_open.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdarg.h>
#include <unistd.h>
// 元のopen関数へのポインタ
static int (*real_open)(const char *pathname, int flags, ...) = NULL;
// openを上書き
int open(const char *pathname, int flags, ...) {
if (!real_open) {
// Load the original open function
real_open = dlsym(RTLD_NEXT, "open");
}
// Log the call
printf("[LD_PRELOAD] PID %d: open called with file: %s\n", getpid(), pathname);
// If O_CREAT is used, we need the mode argument
va_list args;
va_start(args, flags);
int fd;
if (flags & O_CREAT) {
mode_t mode = va_arg(args, mode_t);
fd = real_open(pathname, flags, mode);
} else {
fd = real_open(pathname, flags);
}
va_end(args);
return fd;
}
以上のコードをダイナミックライブラリにコンパイル:
gcc -shared -fPIC -o ldpreload_open.so ldpreload_open.c -ldl
そして LD_PRELOAD でロードして好きなプログラムを実行:
LD_PRELOAD=./ldpreload_open.so ls /
zpoline
zpolineは、ダイナミック・バイナリ書き換えを通じて、完全にユーザー空間でシステムコールを傍受するための新しい研究プロジェクトである。以下のように動作する:
-
プロセスがメイン関数を実行する前に、
zpoline
はすべての syscall 命令をcall %rax
に書き換える。これは、この二つの命令は両方とも 2 バイトなので可能である。 -
プロセスは、代わりに
rax
レジスタに保存されたアドレスにジャンプするようになる。syscall
をする直前にrax
レジスタは、プロセスが実行したいシステムコールの数値を保持している。これは通常無効なアドレスである(例えば、0 はヌルポインタである)が、zpoline
はこれらのアドレスにトランポリンコードを書き込んでおいてくれた。これがzpoline
(zero + trampoline)名前の由来である。 -
プロセスがトランポリンコードに入り、起動時に設定したダイナミックライブラリの関数を呼び出す。この関数は、システムコールの数値を引数として受ける。これから、システムコールに好きなように応答でき、戻り値も自由に選べる。
始める前に全プロセスのバイナリ書き換えはけっこう時間がかかる。その後に生成された子プロセスも書き換えされないといけないのであればなおさら時間がかかる。しかし、実行時のパフォーマンスは非常に良くて、LD_PRELOAD よりわずかに悪いだけだ。したがって、子プロセスを生成しない、長寿命のプロセスに最適である。
次はzpoline
の具体例。リポをクローン:
git clone https://github.com/yasukata/zpoline
zpoline
ライブラリをコンパイル:
make
そして、apps/basic
にあるmain.c
の内容を以下のようにする:
#include <stdio.h>
#include <sys/syscall.h>
typedef long (*syscall_fn_t)(long, long, long, long, long, long, long);
static syscall_fn_t next_sys_call = NULL;
static long hook_function(long a1, long a2, long a3, long a4, long a5, long a6,
long a7) {
if (a1 == SYS_openat) {
printf("openat called with filename:%s\n", (const char *)a3);
}
// printf("output from hook_function: syscall number %ld\n", a1);
return next_sys_call(a1, a2, a3, a4, a5, a6, a7);
}
int __hook_init(long placeholder __attribute__((unused)),
void *sys_call_hook_ptr) {
next_sys_call = *((syscall_fn_t *)sys_call_hook_ptr);
*((syscall_fn_t *)sys_call_hook_ptr) = hook_function;
return 0;
}
make でビルドする:
make
そして以下のように実行する:
# v This is a prerequisite for zpoline to work
sudo sh -c 'echo 0 > /proc/sys/vm/mmap_min_addr'
LD_PRELOAD=./libzpoline.so LIBZPHOOK=./apps/basic/libzphook_basic.so ls /
Summary
リナックスにおけるシステムコールの傍受方法を5つ見てきた。 比較した方法を簡単にまとめると、以下の表のようになる:
引数・戻り値変更可能 | 全システムコール傍受可能 | システムコール禁止可能 | 実行時に性能良い | 起動時間が短い | カーネルコード不要 | |
---|---|---|---|---|---|---|
ptrace | ✔ | ✔ | ✔ | ✖ | ✔ | ✔ |
eBPF | ✖ | ✔ | ✖ | ✔ | ✔ | ✔ |
SystemTap | ✔ | ✔ | ✔ | ✖ | ✖ | ✖ |
LD_PRELOAD | ✔ | ✖ | ✖ | ✔ | ✔ | ✔ |
zpoline | ✔ | ✔ | ✖ | ✔ | ✖ | ✔ |