セキュキャン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カーネルの特定の関数の実行をフックすることができる。調べた限りtracepoint
、kprobe
、fentry
の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.cのx64_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、実は思っていたよりも難しくないかもしれない。あと、ちゃんとしたコードを書かないとバイトコードチェックで弾かれる。賢い。