eBPFを掘り下げる~Traceeを支える技術~ #AquaSecurity #eBPF #Tracee #セキュリティ #オープンソース
この記事は1年以上前に投稿されました。情報が古い可能性がありますので、ご注意ください。
本ブログは「Aqua Security」社の技術ブログで2020年1月6日に公開された「 A Deep Dive into eBPF: The Technology that Powers Tracee 」の日本語翻訳です。
A Deep Dive into eBPF: The Technology that Powers Tracee
Aqua Security の Tracee は、軽量かつ使いやすいコンテナおよびシステムのトレースに優れたオープンソースツールです。Tracee を使用すれば、他のシステムプロセスを除外することなくコンテナ内でのみ生成されたイベントをトレースできます。
Tracee は eBPF テクノロジーを利用しています。eBPF を使用すると、ユーザはシステムの可観測性を支援するプログラムを実行できます。このブログでは eBPF とは何か、それを使用する理由及びその目的について説明します。またシステムコールをトレースするために eBPF を実装する方法の例を紹介します。
Tracee の詳細については、Liz Rice が公開したこのブログ投稿をご覧ください。
※日本語翻訳はコチラ。
それでは eBPF を深く掘り下げてこのテクノロジーの用途を学びましょう。
eBPF とは
eBPF は Extended Berkeley Packet Filter の略です。この言葉から実際のテクノロジーの能力をあまり表現しきれていません。 Brendan Gregg の言葉はこれを簡潔にまとめています。
「eBPF が Linux へ行うことは、JavaScript が HTML に対して行うことに等しい」。
eBPF およびトレース全般に関する彼の研究をチェックすることをお勧めします。
eBPF は元々 BPF のアイデアに由来します。BPFは有用なネットワークパケットをカーネルから直接キャプチャします。そしてパケットをユーザスペースにコピーせずに、ネットワークタップを介して送り出します。
BPF は不要なトラフィックを除外し監視されているパケットのみを取得します。不要なパケットをユーザー空間にコピーしてからふるいにかけるようなオーバーヘッドを削減します。
BPF はどのパケットをフィルタリングするかを決定するためにカーネルでコードを実行します。eBPF はこの機能をパケットフィルタリング以上に大幅に拡張し、カーネルで任意の eBPF コードを実行できるようにします。eBPF プログラムはネットワークパケットの到着だけでなく、さまざまな種類のイベントによってトリガーできます。たとえばプログラムを「kprobe」イベントにアタッチすることにより、カーネル関数の開始時に eBPF コードをトリガーできます。カーネルで実行されるため、eBPF コードは非常に高いパフォーマンスを発揮します。
初期の頃 eBPF プログラムは、バイトコードを手動で記述する必要がありました。これはエラーが発生しやすく非常に骨の折れる作業です。幸いなことに BCC(BPF Compiler Collection)などのツールキットを使用して、Go や Python などのモダンな言語で eBPF プログラムを作成できます。これについてはこのブログの後半で詳しく説明します。
なぜ eBPF を使う必要があるのか
eBPF プログラムはさまざまなイベントによってトリガーできるため、Tracee などの eBPF ベースのアプリケーションはシステムで何が起こっているかを発見するのに役立ちます。例えば、同様のことを行う既存のツールとして「ps」コマンドがあります。ただしキャッチするタイミングの粒度が異なります。監視ツールのサンプリング間隔よりも実行時間が短いプログラムは検出されません。eBPF コードはサンプリングベースではなくイベントトリガーであり、カーネルで非常に高速に実行されます。そのため、eBPF ベースの可観測ツールは従来のサンプリング手段よりもはるかに正確です。
eBPF の強みの例は重要なシステムディレクトリへのファイルの書き込みなど、アプリケーションの異常な動作を識別することによるワークロードの監視です。eBPF コードはファイルイベントに応じて実行され、期待された動作か否かを確認できます。
eBPF でできること
このブログ用に execve() システムコールをトレースすることで eBPF の機能が分かるようなサンプルプログラムを作成しました。
execve() システムコールは、プログラムがファイルを読み取ろうとする試みをトリガーします。私が書いた eBPF プログラムは execve() を呼び出すすべてのプロセスを監視し、それらについて報告します。このプログラムを実行してから、システムで実行されるすべての新しい実行可能ファイルを確認できます。
execve() システムコールの監視は、予期しない実行可能ファイルを検出するのに役立ちます。たとえばコンテナ内からシェルを生成する execve() システムコールを監視するとします。コンテナは、通常マイクロサービスのような短期間のサービス用途として利用されます。インタラクティブなシェルの生成は、コンテナ自身が行うことではありません。これは攻撃者がシステムを乗っ取ろうとしていることを示している可能性があります。
論より証拠
BPF プログラムはカーネルスペースとユーザスペースプログラムの2つの部分で構成されています。カーネルスペースプログラムは関連するイベントをキャプチャし、ユーザスペースで利用できるようにします。ユーザスペースプログラムはカーネルスペースプログラムによって生成されたイベントを取得し、さらに使用するためにそれらを解析します。
この例では最初にカーネルスペースコード、カーネル内での実行方法、およびユーザスペースにイベントを送信する方法を確認します。次にユーザスペースコード、受信したイベントをどのように解析できるかを見ていきます。
開発を支援するためにコミュニティは BCC などのフレームワークを考案しました。次のようなすべての execve システムコールをキャプチャするために、BCC フレームワークにコールバックを登録できます。
int syscall__execve(struct pt_regs *ctx, |
次の関数は BCC のシステムコースを使用して実行されるコマンドをキャプチャします。
bpf_get_current_comm(&data.comm, sizeof(data.comm)); |
またコマンド名・リターンコード・親 UID・プロセス UID などのイベントの詳細を、ユーザスペースプログラムが読み取れるperfのリングバッファに保存します。
BPF_PERF_OUTPUT(events); |
同様にユーザスペースコードについては、 BCC 用の Golang bindingsを使用します。Go 言語環境を準備して BCC プログラムを設定しましょう。
type Snoopy struct { m *bpf.Module } func main() { if err := s.LoadAndAttachProbes(); err != nil { |
このコードスニペットでは新しい構造体を作成し、bpf モジュールを内部でインスタンス化します。カーネルで実行される BPF コードを保持する source パラメータを渡します。次に LoadAndAttachProbes() 関数を呼び出します。probe について少し学んでいきましょう。
Probe
- probe は BPF プログラムを接続できるカーネルによって定義された拡張ポイントです。
- probe は接続されているサブシステムに関する情報を収集および送信するように設計されています。
- BPF にはさまざまな種類の probe があります。ここで使用するのは kprobe と kretprobe です。
それぞれ何を意味し何ができるかを学びましょう。
kprobe
kprobe は execve() のようなカーネル関数の呼び出しのトレースを担当する probe の特別なプレフィックスです。この例では kprobe は syscall__execve に該当します。
kretprobe
kretprobe はカーネル関数の戻りのトレースを担当する probe の特別なプレフィックスです。この例では do_ret_sys_execve に該当します。
LoadAndAttachProbes() 関数を見て新しい BPF プログラムでカーネルにアタッチする方法を確認しましょう。
func (s Snoopy) LoadAndAttachProbes() error { fnName := bpf.GetSyscallFnName("execve") kprobe, err := s.m.LoadKprobe("syscall__execve") if err := s.m.AttachKprobe(fnName, kprobe, -1); err != nil { kretprobe, err := s.m.LoadKprobe("do_ret_sys_execve") if err := s.m.AttachKretprobe(fnName, kretprobe, -1); err != nil { |
最初は少し複雑に見えるかもしれませんが、上記のコードスニペットは kprobe と kretprobe をロードして BPF プログラムにアタッチするだけです。正常にロードされたら残りのプログラムを実行できます。
table := bpf.NewTable(s.m.TableId("events"), s.m)
perfMap, err := bpf.InitPerfMap(table, s.dataChannel) |
先へ進む前に、 BPF プログラムを実行するコンテキストでの tables の意味を理解する必要があります。
BPF プログラムは maps を使用してユーザスペースとカーネルスペースの間で通信します。maps はデータの保存に使用される一般的な key-value データ構造です。この例では Hash Table は BPF maps です。
ここではタイプ Hash Table の BPF map をインスタンス化し、event を定義します。すべての event が受信される data channel も渡します。それらを読み取るには goroutine で次のように event loop を実行します。
func (s Snoopy) eventLoop() { for { data := <-s.dataChannel var event execveEvent p := eventPayload{ |
ここで先ほど作成した dataChannel から読み取り、ストリームの着信バイトの binary.Read() 操作を実行していることがわかります。さらにバイトバッファを eventPayload に変換して標準出力します。ではこのプログラムを実行して何が起こるか見てみましょう。
注:スーパーユーザ特権で BPF プログラムを実行する必要があります。
# go run main.go
前に説明したようにコンテナ内で生成される各シェルを監視します。バックグラウンドで nginx コンテナをデプロイしましょう。
$ docker run -d nginx
次に bash で nginx コンテナに入り、 sh を実行してみましょう。
$ docker exec -it 9e02dfdeb94f /bin/bash
$ sh
新しいプロセスごとの BPF プログラムの出力内容を確認しましょう。
sh コマンドの親プロセス ID(PPID)は bash コマンドのプロセス ID(PID)と一致しますが、bash コマンドの PPID は docker コマンドの PID と一致しません。これは docker コマンドが Docker デーモンにリクエストを投げて、Docker デーモンがコンテナ内でシェルを起動するためです。
最後に
上記の例からわかるように eBPF プログラムはシステム監視ソフトウェアの非常に強力な基盤です。数行のコードでシステムを計測し、内部で何が起こっているかを追跡できます。
セキュリティツールに、 eBPF ベースのアプローチをする際には注意すべき点があります。ツール開始後の、新しいプロセスまたはファイルアクセスイベントの開始を監視することしかできないことです。新しいプロセスが起動することを防ぐことは出来ません。これらの監視結果はワークロードが予期しない方法で動作する場合に通知することができ、それを使用してアクションをトリガーしたり、プロセスやコンテナをシャットダウンしたりできます。ただしこの予期しない動作が悪意のある操作である場合、すでに被害を受けている可能性があります。
この防止機能の欠如は eBPF ベースのセキュリティツールの避けられない欠点です。対照的に、Aqua Cloud Native Security Platform は強力な独自技術を使用しています。Aqua CSPは、予期しない動作が発生したときに通知するだけでなく、(Drift Prevention 機能によって)その発生を防ぐことができます。