ブログ

eBPFプログラム作成の技術:入門書

Google Cloudとコンテナの継続的なセキュリティ

本文の内容は、2019年2月27日にSysdigのGianluca Borelloが投稿したブログ(https://sysdig.com/blog/the-art-of-writing-ebpf-programs-a-primer/)を元に日本語に翻訳・再構成した内容となっております。

eBPFプログラムの作成に興味がありますか? このブログでは、これが焦点になります-eBPFプログラムを書くプロセス。 参考までに、このシリーズの第1部では、eBPFの汎用アーキテクチャとsysdigでのサポートについてハイレベルで見ていき、さまざまな部分がどのように連携するかを理解することが目標でした。 次は、検証プロセスとeBPF仮想マシンについて説明します。eBPFが提供するランタイム安全機能を実現する重要な要素です。

以下のeBPFの演習は、すべて例に基づいています。システムコールデータをインターセプトするためのコードを段階的に構築していきます。 予期しないエラーが発生した場合、内部で何が起こっているのかを停止して分析します。

eBPFについて知っておくべきことがたくさんあります。 表面をかろうじてなぞっていければと思います。複雑なeBPFプログラムを作成するには、このブログで共有する内容よりもはるかに多くのコンテキストが必要ですが、本ブログ「入門書」と位置付けられると考えられます。また、bccが提供するPython/Luaインターフェース、または、bpftraceが提供するような高レベルの言語を使用してeBPFプログラムを作成すると、間違いなくプロセスがよりユーザーフレンドリーになります。 この投稿の目的は、あまり多くのステップを抽象化することなく、問題の核心触れていきます。

eBPFを使用したシステムコールのデコード

最初の実験

この例では、非常にシンプルで広く使用されているシステムコールopenatをデコードします。このシステムコールは、パス名を渡すことでLinuxでファイルを開くために使用されます。システムコールは、適切なファイル記述子を返すか、エラーの場合は負の数を返します。これがそのプロトタイプです。

int openat(int dirfd, const char *pathname, int flags, mode_t mode);

この演習では、システムコールへの入力の引数をデコードします。最も興味深いのは、パス名そのものです。これは非常に簡単な作業のように見えますが、eBPFでジョブを完全に実行するためのコードを記述すると、いくつかの複雑さが隠されることがわかります。

私たちが書くコードは、基本的に少しの修正を加えたeBPFプロジェクトで使用できます。カスタムeBPFローダーであるbcc(カーネルにはbpf_load.cなどのいくつかのサンプルまたはsysdigが付属しています。sysdigを使用する場合、次に記述するすべてのコードは、現在のコンテンツをコメントアウトしながらprobe.cファイル内に置くだけで、典型的なsysdig eBPFプログラムと干渉しないようになります。コンパイルは、GitHubページの指示に従って実行できます。

最も簡単な例から始めて行きましょう!

__attribute__((section("raw_tracepoint/sys_enter"), used))

void bpf_openat_parser()

{

}

ここで、空の関数 bpf_openat_parserは、openatシステムコールを入力するたびに実行させます。その上の属性はどうですか? その1つは、bpf_openat_parser関数のオブジェクトコードを、最終オブジェクトファイルのraw_tracepoint/sys_enterという名前の別個の実行可能およびリンク可能フォーマット(ELF)セクションに入れるようにLLVMに指示するために使用するコンパイラー属性です。すぐにわかるように、これは、bpf_openat_parser関数(us)を作成した開発者と、そのようなeBPFプログラムをアタッチするシステムイベントを知る必要があるsysdigプロセス内のeBPFローダーとの間の暗黙的なプロトコルの一部です。

プログラムをコンパイルした後(この例ではsysdigを使用しているので、標準の「make」で実行できます)、ClangとLLVMはソースコードを処理し、eBPFプログラムを含む単一のオブジェクトファイルを生成します。sysdigの場合、これはdriver/bpf/probe.o にあります。ELFファイルであることがわかっているため、セクションを調べることができます。

$ llvm-readelf -sections driver/bpf/probe.o
There are 203 section headers, starting at offset 0x2d8890:


Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
...
  [179] raw_tracepoint/sys_enter_openat PROGBITS 0000000000000000 1074b8 000008 00  AX  0   0  8
...

予期した通り、ソースコードに挿入した文字列にちなんで名付けられたELFセクションを見つけることができます。 その内容も検査できます。

$ llvm-objdump -no-show-raw-insn -section=raw_tracepoint/sys_enter -S driver/bpf/probe.o
Disassembly of section raw_tracepoint/sys_enter:
bpf_openat_parser:
       0:       exit

ここで、eBPFバイトコードとの最初の出会いがあります。ELFセクションには、bpf_openat_parser関数のeBPFバイトコードが含まれています。関数は空であるため、プログラムは1つの単一の命令-- exitで構成されます。これにより、プログラムが終了し、eBPF仮想マシンに制御を通常のカーネル実行フローに戻すように指示します。

eBPFローダー

このプログラムをどのように実行させるのか?これは、ユーザー空間コンポーネントであるeBPFローダーの責任です。これは、sysdigにscapライブラリーに組み込まれています。eBPFプログラムを含むELFファイルは、次の操作を実行するeBPFローダーへの入力として渡されます。

  1. ELFセクションを解析し、特定のキーワードで始まるセクションを選択します。たとえば、sysdigで使用されるキーワードはraw_tracepointです。これは、ELFセクションに未加工のトレースポイントカーネルイベントにアタッチする必要があるeBPFプログラムが含まれていることをローダーに示します。生のトレースポイントがある場合、eBPFプログラムが接続できる他のカーネルイベントタイプ(kprobes、uprobes、トレースポイントなど)とは対照的に、柔軟性を犠牲にして最高のパフォーマンスを実現します。
  2. ELFセクション名の他の部分は、イベント名として解釈されます。この場合、イベント名はsys_enterです。これは、新しいシステムコールの呼び出しが行われるたびに呼び出されるrawトレースポイントを識別します。これは本質的に、カーネルがシステムイベントとして直接認識する文字列であり、一意に識別するために使用できます。ホストでperf listを実行すると、サポートされているトレースポイントの完全なリストを簡単に調べることができます。
  3. カーネルイベントが検証されると、eBPFプログラムがカーネルにロードされます。これは、bpfシステムコールを介して行われます。このステップ中に、カーネルはプログラムが安全に実行できることを確認し、オプションでJITプロセスを介してプログラムをマシンコードに変換します。bpfシステムコールは、ロードされたeBPFプログラムを識別するファイル記述子、またはエラーを返します。
  4. 最後に、カーネルイベントタイプに応じてbpfシステムコール、またはperf_event_openシステムコールを使用して、eBPFローダーは、前のステップで識別されたイベントにロードされたeBPFプログラムをアタッチするようカーネルに指示します。

全体は少し注意が必要ですが、ほとんどは定型的なものです。その後、プログラムはカーネルでイベントがトリガーされるたびに呼び出されます。

eBPFベリファイア

何が起こるかを把握しましたので、sysdigの実行に進み、eBPFプログラムが正しくロードおよびアタッチされるかどうかを確認します。

$ sudo sysdig
0: (95) exit
R0 !read_ok
bpf_load_program() err=13 event=sys_enter

うまくいきませんでした。これは、eBPFベリファイアとの最初の出会いです。 ベリファイアは、プログラムをロードできなかったことを通知し(上記のステップ3)、理由はR0!read_okです。これはどういう意味でしょうか? R0は11個のeBPF仮想マシンレジスタ(R0〜R10)の1つです。 ベリファイアは、その値を読み取れないことを教えてくれています。ここでは、eBPFプログラムの要件に違反した事が起きました。各eBPFプログラムは、実行の終了時に常に整数値を返す必要があり、この戻り値はR0に格納する必要があります。ほとんどの場合、カーネルは実際にプログラムの戻り値を使用し、その値に基づいて動作するため、戻り値が必要です。たとえば、eBPFプログラムを使用してネットワークパケットをフィルタリングする場合、戻り値は、パケットをドロップ/受け入れるブール値として解釈されます。

ここでのベリファイアは、プログラムの実行中にR0が書き込まれなかったことを検出したために文句を言います。「ガベージ」が含まれています。eBPFプログラムが実行時に取る可能性のあるすべての実行ブランチを効果的にシミュレートすることにより、これを検出します。各ブランチのレジスタの値とタイプを追跡し、それらが読み取られた場合に適切に初期化されるようにします。

関数のプロトタイプを変更して整数を返すことで、この最初の間違いを簡単に修正できます。

__attribute__((section("raw_tracepoint/sys_enter"), used))
int bpf_openat_parser()
{
        return 0;
}


$ llvm-objdump -no-show-raw-insn -section=raw_tracepoint/sys_enter -S driver/bpf/probe.o
Disassembly of section raw_tracepoint/sys_enter:
bpf_openat_parser:
       0:       r0 = 0
       1:       exit

今回、eBPFプログラムが2つの命令の長さになったことがわかります。最初のものは、実際に終了する前に戻り値レジスタを0に初期化します。今回sysdigを実行しても、失敗することはありません。

eBPFのメモリアクセス

eBPFプログラムは完全に空なので、これは特に役立ちません。システムコールに渡された引数に実際にアクセスする必要があります。これを行うには、「コンテキスト」の概念を導入する必要があります。各eBPFプログラムは、開始時にR1レジスタ内のコンテキストへのポインターを渡されます。コンテキストは基本的に、eBPFプログラムをアタッチする特定のイベントタイプに応じて異なる意味を想定する構造であり、eBPF仮想マシンによって直接処理されます。この例で使用している種類の生のトレースポイントの場合、コンテキストはこのタイプの構造体へのポインターです。

struct bpf_raw_tracepoint_args {
        __u64 args[0];
};

この構造体には単一のメンバーargsがあります。これは、カーネルで静的に呼び出されたときにトレースポイントに渡されるすべての引数を含む未宣言サイズの配列で、8バイトの符号なし整数にキャストされます。それでは、システムコールトレースポイントのargsの値は何でしょうか? カーネルツリーに移動して、sys_enterトレースポイントの定義をgrepすると、次のようになります。

TRACE_EVENT_FN(sys_enter,
        TP_PROTO(struct pt_regs *regs, long id),
...

これは、sys_enterトレースポイントを介してeBPFプログラムが呼び出されるたびに、コンテキストの最初の2つの引数に、呼び出し時のCPUレジスタの保存されたコピーへのポインター(pt_regs)と、システムコールのidが含まれることを示します それが呼び出されています。

これら2つのトレースポイント引数からシステムコール引数、特にパス名を取得するにはどうすればよいでしょうか? 幸いなことに、System V ABIは、ユーザーとカーネル間のシステムコール呼び出し中に引数を交換するためのプロトコルを義務付けており、やりとりはCPUレジスタを介して行われます。 特に、規則は次のとおりです。

  • ユーザーレベルのアプリケーションは、シーケンス%rdi、%rsi、%rdx、%rcx、%r8、および%r9を渡すための整数レジスタとして使用します。
  • カーネルインターフェイスは、%rdi、%rsi、%rdx、%r10、%r8、および%r9を使用します。

つまり、openatシステムコールの場合、文字列へのポインターの形式で(システムコールの2番目の引数であるため)rsiレジスタでパス名引数を検索します。カーネルソースからわかるように、rsiは当然、BPFプログラムに渡されるpt_regs構造に存在する値の1つです)。

struct pt_regs {
...
        unsigned long si;
...

これで、次のような、より本質的なBPFプログラムを作成するためのすべての資料が揃いました。

__attribute__((section("raw_tracepoint/sys_enter"), used))
int bpf_openat_parser(struct bpf_raw_tracepoint_args *ctx)
{
        unsigned long syscall_id = ctx->args[1];
        volatile struct pt_regs *regs;
        volatile const char *pathname;


        if (syscall_id != __NR_openat)
                return 0;
       
        regs = (struct pt_regs *)ctx->args[0];
        pathname = (const char *)regs->si;


        return 0;
}

トレースポイント引数から呼び出されたシステムコールIDを取得し、それをopenatシステムコールIDと比較します。これは固定されており、カーネルABIの一部です。 次に、最初のトレースポイント引数からレジスタ構造にアクセスし、その値を使用して、siレジスタに保持されているパス名引数の値を逆参照します。これは、文字列への適切なポインタにキャストします。volatileキーワードは、最適化されたコードの生成中にコンパイラーがそれらの割り当てを削除しないようにするためのものです。

eBPFバイトコードの観点からこれがどのように見えるか見てみましょう:

$ llvm-objdump-7 -no-show-raw-insn -section=raw_tracepoint/sys_enter -S driver/bpf/probe.o
Disassembly of section raw_tracepoint/sys_enter:
bpf_openat_parser:
       0:       r2 = *(u64 *)(r1 + 8)
       1:       if r2 != 257 goto +2
       2:       r1 = *(u64 *)(r1 + 0)
       3:       r1 = *(u64 *)(r1 + 104)


LBB81_2:
       4:       r0 = 0
       5:       exit

分析するための新しいものがたくさんあります。 特に:

  • 命令0:システムコールIDを逆参照しています。これは、トレースポイント引数構造体の2番目のメンバーです。 引数配列アドレスはR1(コンテキスト)に格納されているため、2番目のメンバーはコンテキストからオフセット8にアクセスすることで取得されます。
  • 命令1:システムコールIDとopenat ID(257)を比較し、一致しない場合は、先に進んでプログラムを終了します。
  • 命令2:これは手順0と同じですが、ここではコンテキストからオフセット0にある配列の最初のメンバーを参照します。これにはpt_regs構造体ポインターが含まれます。
  • 命令3:前の命令で取得したpt_regs構造からsiレジスタ値を逆参照します。これはたまたまオフセット104にあります。したがって、R1には最終的にパス名文字列へのポインタが含まれます。

このプログラムを実行しようとするとどうなるでしょうか?下記のように得られます。

$ sudo sysdig
3: (79) r1 = *(u64 *)(r1 +104)
R1 invalid mem access 'inv'
bpf_load_program() err=13 event=sys_enter

うまくいきませんでした。 ベリファイアは命令3を許容しなかったようです。

私たちがやろうとしていたことについて少し考えてみましょう。命令3では、pt_regs構造体にポインターを逆参照することでアクセスしていました。しかし、それは本当に安全でしょうか? pt_regsがNULLまたは偽の領域(0x42424242など)を指している場合はどうなるでしょうか? その場合、eBPF仮想マシンがそのようなコードを実行する場合、または翻訳されたJITコードがネイティブマシン命令を使用してメモリアクセスを実行しようとする場合は、カーネルスペース内で無効なメモリアクセスを取得します。カーネルクラッシュにつながります。そのため、eBPFベリファイアは、この潜在的に危険なアクションの実行を阻止しています。ここでの解決策は、後で説明するように、チェックされたアクセスを使用して、潜在的に安全でないメモリを適切に間接参照することです。

トリビアの質問として、なぜベリファイアは命令0または命令2について文句を言わなかったのでしょうか?命令3のように、これらもメモリを逆参照していました。違いは、これらの命令がコンテキスト構造のメンバーを逆参照していたことです。アクセスで使用されるオフセットが構造自体のサイズを超えて実行されない限り、クラッシュを生成することは決してありません。つまり、eBPFベリファイアは、実行される可能性のある各ブランチに対して各レジスタが指すメモリを追跡し、潜在的に安全でない可能性のあるアクセスを拒否します。これを理解することは、頭痛のないeBPFプログラムを書くための鍵です。

eBPFヘルパー

前出の問題の解決策は、eBPFヘルパーを介してチェックされたメモリアクセスを行うことです。 標準の仮想実行環境に加えて、eBPFでは、eBPFヘルパーと呼ばれるカーネル関数の固定セットを呼び出すこともできます。eBPFヘルパーは、eBPFプログラムに代わって何らかの操作をネイティブに実行します。これらの関数はCのカーネル内に実装されているため、ハードコーディングされており、カーネルABIの一部です。これらのヘルパーの1つはbpf_probe_readです。 memcpyの安全なバージョンと考えることができます。任意のメモリポインタを渡すことができ、クラッシュすることなくそのようなメモリを読み取ろうとします。メモリの読み取りが安全でない場合、単純かつ安全にエラーを返します。 実装の詳細は非常に興味深いものであり、Linuxでのページフォールトハンドラーの動作に関連しています。

これは、BPFプログラムを次のように変更できることを意味します。

__attribute__((section("raw_tracepoint/sys_enter"), used))
int bpf_openat_parser(struct bpf_raw_tracepoint_args *ctx)
{
        unsigned long syscall_id = ctx->args[1];
        struct pt_regs *regs;
        const char *pathname;


        if (syscall_id != __NR_openat)
                return 0;


        regs = (struct pt_regs *)ctx->args[0];
        bpf_probe_read(&pathname, sizeof(pathname), &regs->si);


        return 0;
}

ご覧のとおり、bpf_probe_readの構文は、従来のmemcpyと非常によく似ています。 今回はヘルパーを使用して安全でないメモリを逆参照しているため、メモリアクセスが機能します。

バイトコードを見てみましょう。

$ llvm-objdump-7 -no-show-raw-insn -section=raw_tracepoint/sys_enter -S driver/bpf/probe.o
Disassembly of section raw_tracepoint/sys_enter:
bpf_openat_parser:
       0:       r2 = *(u64 *)(r1 + 8)
       1:       if r2 != 257 goto +6
       2:       r3 = *(u64 *)(r1 + 0)
       3:       r3 += 104
       4:       r1 = r10
       5:       r1 += -8
       6:       r2 = 8
       7:       call 4


LBB81_2:
       8:       r0 = 0
       9:       exit

これは、2〜7の範囲の命令を除いて、前出のものと似ています。これらの指示はヘルパーの呼び出しに関係していますが、これは以前にはありませんでした。eBPF呼び出し規約では、ヘルパー関数への引数はレジスタR1〜R5を使用して順番に渡す必要があります。 次のように命令を分析できます。

  • 命令2-3:R3にはregs-> siのアドレスが入力され、memcpy(ソースコードの3番目のパラメーター)のように、データのコピー元のアドレスを示します。
  • 命令4-5:ここでは、データがコピーされるローカル変数「pathname」のアドレスに設定されているR1を設定しています。これもmemcpy(ソースコードの最初のパラメーター)と同じです。ここで、コンパイラは初めてR10を使用しています。R10は特殊レジスターであり、仮想マシンによってeBPFプログラムの「フレームポインター」に自動的に初期化されます。これは、eBPFプログラムがローカル変数を格納するために使用できるスタックの先頭を指します。スタックのサイズは512バイトに制限されています。ここでは、R1をR10 - 8に設定しています。これは、regs-> siのコンテンツを保持する8バイトのローカルスタック変数用のスペースを予約していることを意味します。
  • 命令6:R2は単純に8に設定されます。これは、コピーするデータのサイズに対応します(ソースコードの2番目のパラメーター)
  • 命令7:ヘルパーが呼び出されます。 各eBPFヘルパーは、enumを介してカーネルABIにストーンで設定された一意の整数によって識別されます)。 bpf_probe_readヘルパーのidが4であることがわかります。

文字列とeBPF

パス名文字列ポインタができたので、それを使って何かをしましょう。通常、これにはその値をユーザー空間に送信することが含まれます。そのためには、まず最初に文字列をどこかにコピーして、一時バッファーに保存します。eBPFプログラムスタックは、このバッファをホストするのに最適な場所のようです。文字列の読み取りは、以前と同様に、本質的に安全ではない可能性のあるメモリの参照解除を意味するため、別のヘルパーを使用する必要があります。

この場合、bpf_probe_read_strを使用できます。bpf_probe_read_strは、文字列を認識することを除き、bpf_probe_readに似ています。これは、文字列の最後で停止することを意味し、より効率的です(コピーされた文字列の長さも返します)。これは、移植作業の一環としてSysdigによってカーネルに導入されたeBPFヘルパーです。

__attribute__((section("raw_tracepoint/sys_enter"), used))
int bpf_openat_parser(struct bpf_raw_tracepoint_args *ctx)
{
        unsigned long syscall_id = ctx->args[1];
        struct pt_regs *regs;
        const char *pathname;
        char buf[64];
        int res;


        if (syscall_id != __NR_openat)
                return 0;


        regs = (struct pt_regs *)ctx->args[0];
        bpf_probe_read(&pathname, sizeof(pathname), &regs->si);
        res = bpf_probe_read_str(buf, sizeof(buf), pathname);


        return 0;
}

bpf_probe_>read_strの使用法は非常に簡単です。 64バイトのローカル変数を予約し、それにパス名をコピーします。 バイトコードを見てみましょう。

$ llvm-objdump-7 -no-show-raw-insn -section=raw_tracepoint/sys_enter -S driver/bpf/probe.o
Disassembly of section raw_tracepoint/sys_enter:
bpf_openat_parser:
       0:       r2 = *(u64 *)(r1 + 8)
       1:       if r2 != 257 goto +11
       2:       r3 = *(u64 *)(r1 + 0)
       3:       r3 += 104
       4:       r1 = r10
       5:       r1 += -8
       6:       r2 = 8
       7:       call 4
       8:       r3 = *(u64 *)(r10 - 8)
       9:       r1 = r10
      10:       r1 += -80
      11:       r2 = 64
      12:       call 45


LBB81_2:
      13:       r0 = 0
      14:       exit

これも非常に簡単です。追加のeBPF命令は9〜12の範囲にあり、bpf_probe_read_strヘルパーに適切な引数を設定するだけです。この場合、コンパイラはアドレスR10〜80で始まるスタックにbuf変数を配置することを決定します。そのため、次の64バイトに文字列の内容が書き込まれます。

この例を実行すると動作します。bpf_trace_printkbpf_perf_event_outputなどの他のヘルパーを使用することで、カーネルログにコピーしたばかりのパス名を出力するか、ユーザースペースと共有する高性能リングバッファーにそれぞれプッシュすることができます(sysdigの機能)。

ただし、このプログラムには重大な欠陥があります。完全なパス名を適切に保持するには、64バイトでは不十分な場合があります。切り捨てられたデータを処理することは、システムコールの監査目的でシステムコールのインスツルメンテーションを実行する場合には理想的ではありません(Falcoのように)。一時バッファのサイズを適切に設定して、より長いパスを保持できるようにすることをお勧めします。定数PATH_MAXを使用できます。これは4096に拡張され、Linuxでサポートされる最大パス長を保持する必要があります。ただし、bufのサイズをPATH_MAXに変更しようとすると、コンパイル時にこのエラーが発生します。

error: :0:0: in function bpf_openat_parser i32 (%struct.bpf_raw_tracepoint_args*): Looks like the BPF stack limit of 512 bytes is exceeded. Please move large on stack variables into BPF per-cpu array map.

eBPF仮想環境が提供するスタックはわずか512バイトです。4096バイトの変数を予約すると、確かにスタック違反が発生します。また、プログラムを実行する場合は、他のカーネルメモリを確実に上書きします。したがって、安全でない操作です。幸運なことに、コンパイラがそれを早く見つけたのです。コンパイラがそれをキャッチしなかったとしても、eBPFベリファイアはそのような状態を検出し、プログラムのロードを妨げていたでしょう。

eBPFマップ

これはどのように解決できるのでしょうか? 一時バッファをスタックから離れた別の場所に保存する必要があります。 eBPF仮想環境では、通常のCユーザー/カーネルプログラムで行うような外部メモリの割り当てやグローバル変数の使用はできません。ただし、eBPFマップを使用する可能性はあります。eBPFマップは、ヘルパーの追加セットを介してeBPFプログラムからアクセス可能なキー/値データ構造であり、呼び出し間で持続します。カーネルは、さまざまなタイプのマップを提供します(ここで説明するハッシュテーブル、配列など、ここに記述しています)。ここで使用できるのは、CPUごとの配列マップです。このように、eBPFプログラムを呼び出すたびに、プログラムの全期間にわたって使用できるマップの独自のスロットが取得されます。eBPFプログラムは実行中にプリエンプトされることはないため、CPUごとのマップに保存することは安全であり、競合状態や破損したデータにつながることはありません。 調整されたプログラムは次のようになります。

__attribute__((section("maps"), used))
struct bpf_map_def tmp_storage_map = {
        .type = BPF_MAP_TYPE_PERCPU_ARRAY,
        .key_size = sizeof(u32),
        .value_size = PATH_MAX,
        .max_entries = 1,
};


__attribute__((section("raw_tracepoint/sys_enter"), used))
int bpf_openat_parser(struct bpf_raw_tracepoint_args *ctx)
{
        unsigned long syscall_id = ctx->args[1];
        struct pt_regs *regs;
        const char *pathname;
        char *map_value;
        u32 map_id;
        int res;


        if (syscall_id != __NR_openat)
                return 0;


        regs = (struct pt_regs *)ctx->args[0];


        res = bpf_probe_read(&pathname, sizeof(pathname), &regs->si);


        map_id = 0;
        map_value = bpf_map_lookup_elem(&tmp_storage_map, &map_id);
        if (!map_value)
                return 0;


        res = bpf_probe_read_str(map_value, PATH_MAX, pathname);


        return 0;
}

これはもう少し複雑に見えるので、分析しましょう。最初のセクションは、eBPFローダーが適切に検出して設定できるように、別のELFセクションに配置されるマップ定義です(これはbpfシステムコールでも発生します)。マップは、単一のエントリを持つタイプCPUごとの配列で宣言されていることがわかります(したがって、各CPUは独自の単一スロットを取得します)。マップのサイズはPATH_MAXであり、システムコール。他のシステムコール引数のスペースやプロセスのpidなど、他のフィールドを追加することでこれを複雑にすることができます(これはsysdigが行うことです)。

bpf_openat_parser関数では、bpf_map_lookup_elemヘルパーを使用して、実行時にeBPFプログラムが実行されている特定のCPUに割り当てられたマップスロットを取得できます。NULLの場合も返されるので、eBPFベリファイアは文句を言いません。

最後に、bpf_probe_read_strを呼び出すだけです。ただし、以前のようにスタックバッファを宛先ポインタとして渡す代わりに、マップストレージエリアへのポインタを渡します。BPFヘルパー引数をマップエリアに直接ポイントできます。 この機能は、Sysdigによってカーネルに追加されたもう1つの改善点です。

eBPFの可変メモリアクセス

このプログラムは正常に動作し、ベリファイアに受け入れられます。eBPFを使用する際に知っておくと便利なちょっとした注意点が足りません。ドキュメントには、宛先バッファが元の文字列自体よりも小さい場合でも、bpf_probe_read_strはコピーされた文字列を正しくNULLで終了し、NULLを含むコピーされた文字列の最終的な長さを返します。

この例では、安全性を高めるために、NULLの自動終了を1秒間忘れ、手動で文字列を終了します。これを行うには、bpf_probe_read_strの呼び出しを次のように変更します。

res = bpf_probe_read_str(map_value, PATH_MAX, pathname);
        if (res > 0)
                map_value[res - 1] = 0;

かなり合理的なCコード。ヘルパーの戻り値が正の場合、PATH_MAX以下になることが確実であるため、上記のコードは安全である必要があります。

しかしながら、ベリファイアは好みません:

$ sudo sysdig
...
20: (85) call bpf_probe_read_str#45
R1_w=map_value(id=0,off=0,ks=4,vs=4096,imm=0) R2_w=inv4096 R3_w=inv(id=0) R6=map_value(id=0,off=0,ks=4,vs=4096,imm=0) R10=fp0,call_-1
21: (67) r0 <<= 32
22: (c7) r0 s>>= 32
23: (b7) r1 = 1
24: (6d) if r1 s> r0 goto pc+3
R0=inv(id=0,umin_value=1,umax_value=9223372036854775807,var_off=(0x0; 0x7fffffffffffffff)) R1=inv1 R6=map_value(id=0,off=0,ks=4,vs=4096,imm=0) R10=fp0,call_-1
25: (0f) r6 += r0
26: (b7) r1 = 0
27: (73) *(u8 *)(r6 -1) = r1
R0=inv(id=0,umin_value=1,umax_value=9223372036854775807,var_off=(0x0; 0x7fffffffffffffff)) R1_w=inv0 R6_w=map_value(id=0,off=0,ks=4,vs=4096,umin_value=1,umax_value=9223372036854775807,var_off=(0x0; 0x7fffffffffffffff)) R10=fp0,call_-1
R6 unbounded memory access, make sure to bounds check any array access into a map
bpf_load_program() err=13 event=sys_enter

これは、多くのダイジェスト出力のように見えます。特に、検証プロセス中に各命令がシミュレートされた後、ベリファイアがレジスタの内容の要約を出力するため、実際に実行するのは非常に簡単です。

  • 命令20:bpfprobereadstrが呼び出されます。呼び出し後、R0には「res」変数があります。 ベリファイアは、文字列を含むマップ値がR1とR6に格納されていることも通知します(キーワード「R6 = mapvalue」と「vs = 4096」を探し、スロットのサイズがPATH_MAXであることを示します)。
  • 命令24:「res」がゼロ以下の場合、文字列の終了コードを超えてジャンプします。そうしないと意味がないからです。「res」変数を含むR0は、ブランチチェック(「uminvalue = 1」)に合格したため、適切な下限1を持つスカラー値(「inv」)の検証によって識別されますが、 既知の上限(「umaxvalue = 9223372036854775807」)。 つまり、明示的なチェックを行わない限り、検証者はbpfproberead_strヘルパーの戻り値について何も想定していません。
  • 命令25:「res」変数を含むR0は、「mapvalue」変数を含むR6へのオフセットとして使用されます。 この命令は、R6をCに相当する&mapvalue [res]に設定します。
  • 命令27:命令25で計算した値を取得し、-1のオフセットを追加し、そのアドレスが指すメモリに0を書き込みます。 これは、&map_value [res-1]の位置のコードに記述した明示的なNULL終了です。

願わくばもっと明確になってきました。命令27は失敗します。これは、if条件の1の下限に対してのみ検証されたため、ベリファイアが「res」変数の上限を認識していないためです。オフセットとして「res」を使用してNULL終了を行うことにより、潜在的に安全でないメモリアクセスを行っています。今回を除いて、「res」がPATH_MAXよりも大きくないため、事実ではないことがわかります。ベリファイアは残念ながらこれを知りません。

ソリューション? 変数の可能な範囲全体がPATH_MAX内にあることをベリファイアに理解させる。 これを行うには、次のような従来のCコードでは不要な無償チェックを追加します。

      res = bpf_probe_read_str(map_value, PATH_MAX, pathname);
        if (res > 0 && res <= PATH_MAX)
                map_value[res - 1] = 0;


ただし、このコードを試すと、コードが機能せず、ベリファイアによって再度同様のエラーで拒否されることがわかります。これは、ベリファイアがまだ理解できない方法でコンパイラがそのブランチを再配置するのが好きな方法によるものです。この動作を自宅で試してみてください。そのため、「科学」ではなく「eBPFプログラムを書く技術」と呼んでいます:-)。 より適切に機能する解決策は、PATH_MAXが2の累乗(4096)であるという事実を活用し、上限チェックを変数がオフセットとして使用されるポイントのできるだけ近くに移動することです。

       res = bpf_probe_read_str(map_value, PATH_MAX, pathname);
        if (res > 0)
                map_value[(res - 1) & (PATH_MAX - 1)] = 0;

これは機能しますが、「res」値が正であり、PATH_MAX(ヘルパーによって保証される)より大きくないことがわかっていることを考慮すると、以前のより明示的なチェックと意味的に同じです。つまり、ベリファイアによるコードの検証を支援しているだけです。バイトコードは次のようになります。

25: (07) r0 += 4095
26: (57) r0 &= 4095
27: (bf) r1 = r6
28: (0f) r1 += r0
29: (b7) r2 = 0
30: (73) *(u8 *)(r1 +0) = r2
R0_w=inv(id=0,umax_value=4095,var_off=(0x0; 0xfff))


R1_w=map_value(id=0,off=0,ks=4,vs=4096,umax_value=4095,var_off=(0x0; 0xfff)) R2_w=inv0


R6=map_value(id=0,off=0,ks=4,vs=4096,imm=0) R10=fp0,call_-1

R0レジスタは、1を減算し、4095でビット単位ANDを行った後、正しく追跡された4095の上限(「umax_value = 4095)」を持っているため、マップ値ポインターへのオフセットとして使用できます。

これは、Sysdigによって提供されたカーネルのもう1つの改善であり、検証時にサイズが厳密にわからないデータを処理するプロセスをはるかに簡単にします。これは、システムコールの計測に不可欠です。

この時点で、抽出したフルパス名をカーネルトレースログに出力して、この旅を終了しましょう。

char fmt[] = "path_name:%s\n";
        bpf_trace_printk(fmt, sizeof(fmt), map_value);

コードを実行し、カーネルトレースログ(/sys/kernel/debug/tracing/trace_pipe)を確認すると、次のように表示されます。

htop-1960  [001] .... 20839.191270: 0: path_name:/proc/124286/task
htop-1960  [001] .... 20839.191283: 0: path_name:/proc/124286/statm
htop-1960  [001] .... 20839.191292: 0: path_name:/proc/124286/stat
htop-1960  [001] .... 20839.191308: 0: path_name:/proc/124290/task
htop-1960  [001] .... 20839.191321: 0: path_name:/proc/124290/statm
htop-1960  [001] .... 20839.191331: 0: path_name:/proc/124290/stat
htop-1960  [001] .... 20839.191443: 0: path_name:/proc/loadavg
htop-1960  [001] .... 20839.191472: 0: path_name:/proc/uptime
tmux: server-936   [003] .... 20839.964390: 0: path_name:/proc/124286/cmdline

任務完了!

まとめ

これで、このeBPFシリーズの第2部は終わりです。 コアテクノロジーが内部でどのように機能し、どのようにプログラミングできるかを直接見てきました。

前述のように、eBPFプログラムを作成する他のいくつかの重要な側面、たとえば、ループを実行できないことや、eBPFでトレース可能なユースケース以外に記述できる他のすべてのプログラムタイプについては取り上げていません。また、このコンテンツは静的ですが、eBPFは確かに静的ではありません。 ベリファイアは、新しいカーネルがリリースされるたびにますます賢くなり、eBPFプログラムデベロッパーの生活を楽にします。したがって、このコンテンツは将来のある時点で確かに時代遅れになる可能性があります。サードパーティのユーザーが実行する可能性のあるさまざまなカーネルバージョンをサポートするeBPFプログラムを作成する場合、可能な限り下位互換性を保つためにこれらの癖に対処する必要があります。

膨大な量のeBPFコードの動作を確認したい場合は、sysdigリポジトリをご覧になり、今後の追加のeBPFコンテンツにご注目ください。

Sysdigに関するお問い合わせはこちらから

最近の投稿

カテゴリー

アーカイブ

ご質問・お問い合わせはこちら

top