libbpfgoによるeBPFプログラムの作り方 #aqua #セキュリティ #eBPF
この記事は1年以上前に投稿されました。情報が古い可能性がありますので、ご注意ください。
本ブログは「Aqua Security」社の技術ブログで2021年4月6日に公開された「 How to Build eBPF Programs with libbpfgo 」の日本語翻訳です。
libbpfgoによるeBPFプログラムの作り方
ここ数年、私は BCC というプロジェクトを使って、bpf プログラムのコンパイル、ロード、操作をしてきました。そして最近になり、libbpf と呼ばれる ebpf プロジェクトを構築するためのより良い方法について学びました。libbpf ベースのプログラムを開発する際に利用できるいくつかの良いリソースがありますが、始めようとするとかなり圧倒されてしまいます。この記事の目的は、libbpf とは何か、どうやって使い始めるかについて、簡単かつ効果的に説明することです。
libbpf は、ユーザースペースと bpf プログラムの両方にインポートできるライブラリです。libbpf は、bpf プログラムをロードして操作するための API を開発者に提供します。Linux カーネルのソースツリーで管理されているため、信頼できるパッケージです。
libbpf の性質と使い方を説明するために、プロセスが mmap() システムコールを使用するたびに通知される簡単な bpf プログラムを書いてみましょう。次に、コンパイルされた bpf プログラムをロードし、その出力をリッスンするユーザースペースプログラムを C 言語で書きます。
bpfプログラムの作成
まず以下のようにヘッダファイルをインポートします。
#include <bpf/bpf_helpers.h> #include "vmlinux.h" #include "simple.h" |
bpf_helpers.h ヘッダファイルは、libbpf の一部です。bpf_helpers.h には、bpf のプログラムで使用できる便利な関数が多く含まれています。vmlinux.h については、補足的なブログ記事を書いていますので、「vmlinux.hとは何か。なぜeBPFプログラムにとって重要なのか。」をご覧ください。
simple.h には、bpf とユーザースペースのコードに含めたい構造体を定義しています。これは次のような単純なものです。
struct process_info { int pid; char comm[100]; }; |
次に、bpf プログラムが呼び出されるたびに、出力をユーザースペースに送信する手段が必要になります。そのために、リングバッファを設定します。
struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 1 << 24); } events SEC(".maps"); |
使用できる bpf プログラムマップには多くの種類があります。リングバッファは、カーネルスペースからユーザスペースへデータを転送するための信頼できる方法です。以前にも bpf プログラムを書いたことがあるのであれば、おそらく perfbuffers も見たことがあるでしょう。上記で記載したブログ記事では、perf ではなくリングバッファを使うことのメリットについて書かれています。
最後に、実際の bpf プログラムを見てみましょう。
SEC("kprobe/sys_execve") int kprobe__sys_execve(struct pt_regs *ctx) { __u64 id = bpf_get_current_pid_tgid(); __u32 tgid = id >> 32; proc_info *process; // Reserve space on the ringbuffer for the sample process = bpf_ringbuf_reserve(&events, sizeof(proc_info), ringbuffer_flags); if (!process) { return 0; } process->pid = tgid; bpf_get_current_comm(&process->comm, 100); bpf_ringbuf_submit(process, ringbuffer_flags); return 0; } |
bpf のプログラム自体はこの関数だけで、基本的には main() です。もちろん main() 以外の関数を定義してインポートできますが、その場合は __always_inline 属性でマークされていなければなりません。これは複雑さを制限し、bpf プログラムを必ず終了させるため、再起的な関数の実行を避けるためです。
では、このプログラムをブレークダウンしてみましょう。
SEC("kprobe/sys_mmap")
これは、bpf_helpers.h で定義されたセクションマクロです。すべてのプログラムには、コンパイルされたバイナリのどの部分へプログラムを配置するかを libbpf に伝えるため、このマクロが必要になります。これは基本的にプログラムの修飾名です。厳密なルールはありませんが、libbpf で定義されている慣習に従うべきです。上記の SEC ラベルは、リングバッファの定義でも見ることができます。
int kprobe__sys_mmap(struct pt_regs *ctx)
ここでは、プログラム名が kprobe__sys_mmap であることがわかります。プログラム名には好きな名前をつけて、この名前をユーザースペース側からの識別子として使うことができます。bpf プログラムの種類ごとに、bpf プログラムで使用するためにアクセスできる独自の「コンテキスト」があります。bpf プログラムを添付できるさまざまなものと、そのために利用できるコンテキストについての詳しい説明は、BPF プログラムの種類に投稿されています。
この kprobe bpf プログラムの場合、struct pt_regs があり、呼び出したプロセスの仮想レジスタにアクセスできます。
ここから先はシンプルです。
proc_info *process process = bpf_ringbuf_reserve(&events, sizeof(proc_info), ringbuffer_flags); if (! process) { return 0; } process->pid = tgid; bpf_get_current_comm(&process->comm, 100); bpf_ringbuf_submit(process, ringbuffer_flags); return 0; |
リングバッファ上に struct process_info のスペースを確保し、プロセス ID とプロセス名を読み込んで、リングバッファ上に送信します。以上です。
libbpfgoのユーザースペース側の使用
ユーザースペースのプログラムの目的は、コンパイルされた bpf プログラムをロードし適切な kprobe にアタッチすること、リングバッファからの出力を待って完了後にクリーンアップすることです。
libbpf は C 言語のライブラリなので、Go などの高レベル言語でのバインディングも簡単に作成できます。ユーザースペースのコードを純粋な C 言語で書きたい場合は、こちらの記事を参考にしてください。
最初のステップは、bpf のコードをオブジェクトファイルにコンパイルすることです。
clang -g -O2 -c -target bpf -o mybpfobject.o mybpfcode.bpf.c
これで、libbpf 自体のラッパーである libbpfgo を使うことができます。libbpfgo の目的は、libbpf のすべてのパブリック API を実装することで、Go 言語から簡単に使えるようにすることです。Aqua のオープンソースプロジェクトの1つである tracee が必要としている機能から始めましたが、他のすべての機能もすぐに提供される予定です。
コードで実装する前に、これからやることを大まかに見てみましょう。
以下のように記述して、オブジェクトファイルを読み込むことができます。
bpfModule, err := bpf.NewModuleFromFile("mybpfobject.o") if err != nil { panic(err) } defer bpfModule.Close() bpfModule.BPFLoadObject() |
次に、使用したい bpf プログラムを探し(複数を一つのオブジェクトファイルにまとめることができます)、それを必要なフック(ここでは __x64_sys_mmap カーネル関数)にアタッチします。
prog, err := bpfModule.GetProgram("kprobe__sys_mmap") if err != nil { panic() } _, err = prog.AttachKprobe("__x64_sys_mmap") if err != nil { panic() } |
そして最後に、リングバッファからの出力を聞くための Go チャネルを設定し、イベントを受信したらそれを出力します。
eventsChannel := make(chan []byte) rb, err := bpfModule.InitRingBuf("events", eventsChannel) if err != nil { panic() } rb.Start() for { eventBytes := <-eventsChannel pid := int(binary.LittleEndian.Uint32(eventBytes[0:4])) // Treat first 4 bytes as LittleEndian Uint32 comm := string(bytes.TrimRight(eventBytes[4:], "\x00")) // Remove excess 0's from comm, treat as string fmt.Printf("%d %v\n", pid, comm) } |
ビルドは以下のように行います。
CC=gcc CGO_CFLAGS="-I /usr/include/bpf" CGO_LDFLAGS="/usr/lib64/libbpf.a" go build -o libbpfgo-prog
ここには必要な依存関係がいくつかありますが、幸いなことに、ほとんどのパッケージマネージャで提供されています。/usr/include/bpf は、私のシステムでの libbpf のソースコードへのパスです。libbpf.a は手動でビルドするか、パッケージ(libbpf-dev-static のような形)で提供されます。できあがったバイナリは libbpfgo-prog という名前になります。
最後に、これを root で実行するか、ケイパビリティCAP_BPF/CAP_TRACING (linux 5.8+): で実行することができます。
[*] sudo ./libbpfgo-prog 6400 sh 6399 sh 6401 node 6403 sh 6402 sh 6404 node ... |
まとめ
この記事では、libbpf で定義されているヘルパーを使って bpf プログラムを書く方法を説明しました。私たちの bpf プログラムは、プロセス ID やコマンドなどの情報を読み取り、それを共有リングバッファに書き込みます。それを clang でコンパイルし、libbpfgo を使ってカーネルにロードし、リングバッファからの出力を待ちます。
ぜひ一度 libbpfgo のドキュメントをチェックしてください。使用例を見るには、libbpfgo テストが良いでしょう。このブログ記事のために書かれたすべてのコードは、GitHub で確認することができます。