zebian.log

技術系備忘録とか

eBPFに入門して簡単なシステムコールロガーを実装した

セキュキャン2023でSysmonForLinuxを使った経験があり、プログラムの挙動ログを自作ロガーで取りたいなと思ったので、Go+ebpf-goで簡単なシステムコールロガーを実装した。eBPFもGoも初心者なのでコードが汚いのは御愛嬌。

コード全体

コードはここに書いた。記事作成時点のコードであって、最新版ではないので注意。

main.go

package main

import (
    "bytes"
    _ "embed"
    "encoding/binary"
    "encoding/json"
    "fmt"
    "os"
    "os/signal"
    "syscall"

    "github.com/cilium/ebpf"
    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/ringbuf"
    "github.com/cilium/ebpf/rlimit"
)

//go:embed bpf_hook_syscall.o
var bpfBin []byte

type BpfObject struct {
    Events         *ebpf.Map     `ebpf:"events"`
    HookX64SysCall *ebpf.Program `ebpf:"hook_x64_sys_call"`
}

type SyscallEvent struct {
    Timestamp uint64
    SyscallNr uint32
    Pid       uint32
}

func (o *BpfObject) Close() error {
    if err := o.Events.Close(); err != nil {
        return err
    }

    if err := o.HookX64SysCall.Close(); err != nil {
        return err
    }

    return nil
}

func main() {
    spec, err := ebpf.LoadCollectionSpecFromReader(bytes.NewReader(bpfBin))
    if err != nil {
        panic(err)
    }

    if err := rlimit.RemoveMemlock(); err != nil {
        panic(err)
    }

    var o BpfObject
    if err := spec.LoadAndAssign(&o, nil); err != nil {
        panic(err)
    }
    defer o.Close()

    link, err := link.AttachTracing(link.TracingOptions{
        Program: o.HookX64SysCall,
    })
    if err != nil {
        panic(err)
    }
    defer link.Close()

    rd, err := ringbuf.NewReader(o.Events)
    if err != nil {
        panic(err)
    }

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    var event SyscallEvent
    var events []SyscallEvent

l:
    for {
        select {
        case <-sigCh:
            // received signal
            break l
        default:
        }

        // read record
        record, err := rd.Read()
        if err != nil {
            if err == ringbuf.ErrClosed {
                panic(err)
            }
            continue
        }

        // parse record
        if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.NativeEndian, &event); err != nil {
            fmt.Printf("Failed to parse syscall event: %s\n", err)
            continue
        }

        events = append(events, event)
    }

    // export json log
    file, err := os.Create("syscall_events.json")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    encoder := json.NewEncoder(file)

    if err := encoder.Encode(events); err != nil {
        panic(err)
    }

    fmt.Printf("Exported syscall events log\n")
}

bpf_hook_syscall.c

// +build ignore

#define __TARGET_ARCH_x86

#include <linux/bpf.h>
#include <linux/version.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char _license[] SEC("license") = "Dual MIT/GPL";

struct syscall_event
{
    __u64 timestamp;
    __u32 syscall_nr;
    __u32 pid;
};

struct
{
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24);
} events SEC(".maps");

// linux/arch/x86/entry/syscall_64.c
// long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
SEC("fentry/x64_sys_call")
int BPF_PROG(hook_x64_sys_call, const struct pt_regs *regs, unsigned int nr)
{
    struct syscall_event *event;
    event = bpf_ringbuf_reserve(&events, sizeof(struct syscall_event), 0);

    // failed to reserve space in ringbuf
    if (!event)
    {
        return 0;
    }

    event->timestamp = bpf_ktime_get_ns();
    event->syscall_nr = nr;
    event->pid = bpf_get_current_pid_tgid() >> 32;

    bpf_ringbuf_submit(event, 0);

    return 0;
}

ビルド

clang -O2 -g -c -target bpf bpf_hook_syscall.c
go build

システムコールをフックする方法

eBPFではLinuxカーネルの特定の関数の実行をフックすることができる。調べた限りtracepointkprobefentryの3種類のやり方がある。カーネルがこれらの動作をサポートしている必要があるが、自分の環境ではサポートされているにもかかわらずtracepointとkprobeが動かなかった(もしかしたら実装が悪かっただけかも)。fentryはLinux5.5以降で使える。

フック関数

// linux/arch/x86/entry/syscall_64.c
// long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
SEC("fentry/x64_sys_call")
int BPF_PROG(hook_x64_sys_call, const struct pt_regs *regs, unsigned int nr)
{
    ...
    return 0;
}

SEC("fentry/<関数名>")のようにフックしたい関数を登録する。

Linuxシステムコール関数はinclude/linux/syscalls.hで見ることができるが、sys_*関数を登録して実行してみたところ、関数が見つからないと言われてしまった(理由は謎。関数の実装がCではなくアセンブリだったから?)。

仕方がないので呼び出し元であるarch/x86/entry/syscall_64.cx64_sys_call関数をフックすることにした。

フック関数はマクロを使ってint BPF_PROG(<フック関数名>, <フックしたい関数の引数>, ...)のように実装する。Cのマクロの混沌を実感した。

イベントを記録する

struct syscall_event
{
    __u64 timestamp;
    __u32 syscall_nr;
    __u32 pid;
};

イベントログ用の構造体。上からタイムスタンプ、システムコール番号(x64_sys_call関数の第2引数をそのまま持ってきた)、システムコールを実行したプロセスのpid。

これらのイベントログをeBPFプログラムを呼び出したGo側に送りたい。BPFにはMapsという機能があり、そこでデータを呼び出し元とやり取りすることができる。ハッシュテーブルやリングバッファなど、データ構造も選択可能。今回はリングバッファを利用した。定義は次の通り。

struct
{
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24);
} events SEC(".maps");

フック関数内では、次のようにイベントの記録を行う。

... {
    struct syscall_event *event;
    event = bpf_ringbuf_reserve(&events, sizeof(struct syscall_event), 0);

    // failed to reserve space in ringbuf
    if (!event)
    {
        return 0;
    }

    event->timestamp = bpf_ktime_get_ns();
    event->syscall_nr = nr;
    event->pid = bpf_get_current_pid_tgid() >> 32;

    bpf_ringbuf_submit(event, 0);

    return 0;
}

リングバッファ上で記録用の領域を確保し、それぞれ値を書き込む。bpf_get_current_pid_tgid関数は戻り値が64ビットの値で、上位32ビットがpidなので右シフトする。

フック関数の登録とイベントの取得

今度はGo側の実装。

type BpfObject struct {
    Events         *ebpf.Map     `ebpf:"events"`
    HookX64SysCall *ebpf.Program `ebpf:"hook_x64_sys_call"`
}

...

var o BpfObject
if err := spec.LoadAndAssign(&o, nil); err != nil {
    panic(err)
}
defer o.Close()

link, err := link.AttachTracing(link.TracingOptions{
    Program: o.HookX64SysCall,
})
if err != nil {
    panic(err)
}
defer link.Close()

eBPFプログラムをオブジェクトに定義し、フック関数が実行されるように登録する。

type SyscallEvent struct {
    Timestamp uint64
    SyscallNr uint32
    Pid       uint32
}

...

rd, err := ringbuf.NewReader(o.Events)
if err != nil {
    panic(err)
}

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

var event SyscallEvent
var events []SyscallEvent

l:
    for {
        select {
        case <-sigCh:
            // received signal
            break l
        default:
        }

        // read record
        record, err := rd.Read()
        if err != nil {
            if err == ringbuf.ErrClosed {
                panic(err)
            }
            continue
        }

        // parse record
        if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.NativeEndian, &event); err != nil {
            fmt.Printf("Failed to parse syscall event: %s\n", err)
            continue
        }

        events = append(events, event)
    }

次にリングバッファから送られるイベントログを取得するために、eBPFプログラムで定義したイベントログ用構造体と同じものを定義する。やり方によっては構造体の定義は自動化できるらしい。

リングバッファとのコネクションを生成し、無限ループでデータが送られてくるのを待機する。signal.Notifyによって外からSIGINTを送ると無限ループを抜けるようにした。

送られてきたイベントログはこんな感じ。

// export json log
file, err := os.Create("syscall_events.json")
if err != nil {
    panic(err)
}
defer file.Close()

encoder := json.NewEncoder(file)

if err := encoder.Encode(events); err != nil {
    panic(err)
}

fmt.Printf("Exported syscall events log\n")

最後にイベントログをJsonにエクスポートして終了。

感想

eBPF、実は思っていたよりも難しくないかもしれない。あと、ちゃんとしたコードを書かないとバイトコードチェックで弾かれる。賢い。

参考

zenn.dev zenn.dev ebpf-go.dev eunomia.dev