Linuxシステムコール傍受方法の比較
Sjors Holtrop

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 にあるreadwriteのラッパー関数を上書きすれば、その背後に使われるシステムコールも上書きしたということなる。今までの方法と違ってカーネルを通らないのでパフォーマンスも良い。

そのデメリットは、すべてのシステムコールを傍受することができないことである。例えば、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は、ダイナミック・バイナリ書き換えを通じて、完全にユーザー空間でシステムコールを傍受するための新しい研究プロジェクトである。以下のように動作する:

  1. プロセスがメイン関数を実行する前に、zpolineはすべての syscall 命令をcall %raxに書き換える。これは、この二つの命令は両方とも 2 バイトなので可能である。

  2. プロセスは、代わりにraxレジスタに保存されたアドレスにジャンプするようになる。syscallをする直前に raxレジスタは、プロセスが実行したいシステムコールの数値を保持している。これは通常無効なアドレスである(例えば、0 はヌルポインタである)が、zpolineはこれらのアドレスにトランポリンコードを書き込んでおいてくれた。これがzpoline(zero + trampoline)名前の由来である。

  3. プロセスがトランポリンコードに入り、起動時に設定したダイナミックライブラリの関数を呼び出す。この関数は、システムコールの数値を引数として受ける。これから、システムコールに好きなように応答でき、戻り値も自由に選べる。

始める前に全プロセスのバイナリ書き換えはけっこう時間がかかる。その後に生成された子プロセスも書き換えされないといけないのであればなおさら時間がかかる。しかし、実行時のパフォーマンスは非常に良くて、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