BPFによるパケットトレース――C言語によるBPFプログラムの作り方、使い方Berkeley Packet Filter(BPF)入門(5)(2/2 ページ)

» 2019年11月05日 05時00分 公開
[味曽野雅史OSSセキュリティ技術の会]
前のページへ 1|2       

パケットトレーシングBPFプログラムの中身

 次に、BPFプログラム本体「sockex1_kern.c」の中身を見てみましょう。

#include <uapi/linux/bpf.h>
#include <uapi/linux/if_ether.h>
#include <uapi/linux/if_packet.h>
#include <uapi/linux/ip.h>
#include "bpf_helpers.h"
 
// 使用するBPFマップの定義
struct bpf_map_def SEC("maps") my_map = {
    .type = BPF_MAP_TYPE_ARRAY,
    .key_size = sizeof(u32),
    .value_size = sizeof(long),
    .max_entries = 256,
};
 
SEC("socket1")
int bpf_prog1(struct __sk_buff *skb)
{
    // 【1】IPヘッダのプロトコル番号を取得
    int index = load_byte(skb, ETH_HLEN + offsetof(struct iphdr, protocol));
    long *value;
 
    // 【2】パケットが送信の場合は無視
    if (skb->pkt_type != PACKET_OUTGOING)
        return 0;
 
    // 【3】index(プロトコル番号)をキーにして、BPFマップをルックアップ
    value = bpf_map_lookup_elem(&my_map, &index);
    if (value)
        // 【4】BPFマップにエントリがあれば、その値をアトミックに更新
        __sync_fetch_and_add(value, skb->len);
 
    return 0;
}
char _license[] SEC("license") = "GPL";

 このプログラムはclangで「sockex1_kern.o」というオブジェクトファイル(ELF形式)にコンパイルされます。以下で動作の詳細について説明します。

・BPFマップの定義

 このプログラムでは、「BPF_MAP_TYPE_ARRAY」という種類のBPFマップを定義しています。これは簡単にいえばキーが数値の配列です。

 BPFマップの値は任意のデータ構造が持てますが、ここではパケットサイズを記録するのでBPFマップの値はlongを保持します。このBPFマップは値の統計を取るのによく利用されます。

 BPFマップの種類に関してはbpf(2)のmanページに記載があります。ただし、最近追加されたBPFマップに関する情報はありません。それらのBPFマップの情報についてはカーネルのコミットログを見るのがいいでしょう。

・引数

 「BPF_PROG_TYPE_SOCKET_FILTER」のBPFプログラムには、「struct __sk_buff」の引数が渡されます。「include/uapic/linux/bpf.h」で定義されます。

struct __sk_buff {
    __u32 len;
    __u32 pkt_type;
    __u32 mark;
    __u32 queue_mapping;
    __u32 protocol;
    __u32 vlan_present;
    __u32 vlan_tci;
    __u32 vlan_proto;
    __u32 priority;
    __u32 ingress_ifindex;
    __u32 ifindex;
    __u32 tc_index;
    __u32 cb[5];
    __u32 hash;
    __u32 tc_classid;
    __u32 data;
    __u32 data_end;
    __u32 napi_id;
 
    /* Accessed by BPF_PROG_TYPE_sk_skb types from here to ... */
    __u32 family;
    __u32 remote_ip4;   /* Stored in network byte order */
    __u32 local_ip4;    /* Stored in network byte order */
    __u32 remote_ip6[4];    /* Stored in network byte order */
    __u32 local_ip6[4]; /* Stored in network byte order */
    __u32 remote_port;  /* Stored in network byte order */
    __u32 local_port;   /* stored in host byte order */
    /* ... here. */
 
    __u32 data_meta;
    __bpf_md_ptr(struct bpf_flow_keys *, flow_keys);
    __u64 tstamp;
    __u32 wire_len;
    __u32 gso_segs;
    __bpf_md_ptr(struct bpf_sock *, sk);
};

 それぞれのフィールドに対してBPFプログラムタイプに応じてBPFプログラムによるread/writeの可否が定義されます。具体的な権限については検証機のコードを確認することになります。「BPF_PROG_TYPE_SOCKET_FILTER」に関してはここでチェックしています。

・パケットデータに対するアクセス

 eBPFでのメモリアクセス方法は、アクセス対象により異なります。下記表に主要なメモリアクセス方法をまとめます。

アクセス対象 Read
コンテキスト(引数のポインタ) BPF_LD & BPF_MEM
スタック、BPFマップのデータ BPF_LD & BPF_MEM
パケットデータ(通常) bpf_skb_load_bytes()(BPF_LD & BPF_ABS、BPF_LD & BPF_IND)
パケットデータ(direct packet access) BPF_LD & BPF_MEM
それ以外のメモリ領域 bpf_probe_read()

 命令セットの詳細は連載第2回をご確認ください。eBPFには汎用(はんよう)的なメモリアクセス命令「BPF_LD | BPF_MEM」が存在するものの、この命令でアクセスできる範囲はプログラム実行前に検証器が安全であると確認できるものに限られます。一般にパケットデータやその他カーネル内のデータ構造にアクセスするときは、境界値チェックを行う専用のヘルパー関数を利用します。

 このサンプルプログラムでは、「load_byte()」という関数でパケットのデータを読み出しています。この「load_byte()」はtools/testing/selftests/bpf/bph_helpers.hで定義されます。

unsigned long long load_byte(void *skb,
                 unsigned long long off) asm("llvm.bpf.load.byte");

 ここで分かる通り、「load_byte()」はLLVMの組み込み命令(「llvm.bpf.load.byte」)になります。この組み込み命令は、「BPF_ABS | BPF_LD」を使ったコードにコンパイルされます。BPFプログラムをロードする際、検証器はこの命令をヘルパー関数呼び出しに変換します。

 このように、BPFでのメモリアクセスは注意しなければならないことが幾つかあります。一般に既存のサンプルプログラムの方法をまねするのがいいでしょう。

 ちなみに、「load_byte()」において境界外アクセスを行った場合は、その時点でBPFプログラムの実行が終了し、戻り値として0(=パケットはドロップ)が返されます。また、XDPなどでは「Direct Packet access」と呼ばれる、BPFプログラムからパケットデータを直接参照する手法が利用ができます。Direct Packet Accessに関しては以前の記事を参照してください。

・BPFマップに対するアクセス

 「socket1_user.c」と同様に、「bpf_map_lookup_elem()」でBPFマップにアクセスしていますが、こちらはbpfシステムコールではなく、BPFヘルパー関数を利用してアクセスします。

 ここで呼び出している「bpf_map_lookup_elem()」の実体は下記です

static void *(*bpf_map_lookup_elem)(void *map, const void *key) =
    (void *) BPF_FUNC_map_lookup_elem;

 「BPF_FUNC_map_lookup_elem」はどこで定義されているかというと、「inxlude/uapi/linux/bpf.hです。

#define __BPF_ENUM_FN(x) BPF_FUNC_ ●x
enum bpf_func_id {
        __BPF_FUNC_MAPPER(__BPF_ENUM_FN)
        __BPF_FUNC_MAX_ID,
};
#undef __BPF_ENUM_FN
 
...
 
#define __BPF_FUNC_MAPPER(FN)       
        FN(unspec),                 
        FN(map_lookup_elem),        
        FN(map_update_elem),        
        ...

 実のところ、「BPF_MAP_LOOKUP_ELEM」はただの数字ですが、これがBPFプログラムとしてコンパイルされると「BPF_CALL 1」のようになり、BPFのVMによってヘルパー関数が実行されます。

 BPFプログラムから呼び出し可能なヘルパー関数も検証機によってチェックされます。「BPF_PROG_TYPE_SOCKET_FILTER」ではここでチェックしています。

 また、BPFプログラムから利用可能なヘルパー関数に関しては下記に情報があります。

・BPFマップの更新

 「__sync_fetch_and_add()」でアトミックにBPFマップの値を更新しています。この関数はLLVMの組み込み関数ですが、最終的にはeBPFのxadd命令を使った処理にコンパイルされます。

 「BPF_MAP_TYPE_ARRAY」は更新時に排他制御を行いません。BPFマップは別のスレッドからアクセスされることがあるので、「__sync_fetch_and_add()」を利用してアトミックに更新するのが基本です。

 パフォーマンスが非常に重要な場合は「BPF_MAP_TYPE_PERCPU_ARRAY」のような各CPU専用のBPFマップが利用できます。また、あえてアトミックに更新しないという選択をすることもあります。他のBPFマップ、例えば「BPF_MAP_TYPE_HASH」はアトミックに要素を更新します。

 なお、「BPF_MAP_TYPE_ARRAY」のBPFマップの中身は最初全てゼロ初期化されています。

・戻り値

 「BPF_PROG_TYPE_SOCKET_FILTER」の場合、戻り値は有効なパケット長を表します。戻り値が0の場合、そのパケットはドロップされます。「sockex1_kern.c」では0を返しているので、これは全てのパケットがドロップされることになります。

 「BPF_PROG_TYPE_SOCKET_FILTER」の戻り値はunsignedとして解釈され、戻り値がパケットサイズよりも大きくても問題はありません。そこでパケットを許可するBPFプログラムは「-1」を返すことがあります。

・BPFプログラムのライセンス

 「sockex1_kern.c」の末尾にある下記がこのプログラムのライセンス情報です。この情報はBPFプログラムをbpfシステムコールでロードする際に利用されます。

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

 BPFプログラムはカーネルモジュールと同じようにカーネル内で動作するために、このようなライセンス明示が必要です。GPL以外のライセンスを選択することも可能ですが、GPL互換のライセンスでない場合、一部のヘルパー関数を呼ぶことができなくなります。

 ヘルパー関数とライセンスの関係はこちらにまとまっています。

BPFプログラムのロード処理

 ユーザーランドのプログラム「sockex1_user.c」は「load_bpf_file()」関数でプログラムをロードしています。この関数内でbpfシステムコールを読んでBPFプログラムをロードしていますが、この関数が行うのはそれだけはありません。下記のことをしています。

  1. BPFプログラムのELFファイル「sockex1_kern.o」を開く
  2. ELFファイルの「maps」セクションの情報に基づいて、BPFマップを作成、BPFマップのファイルディスクリプタを得る
  3. ELFファイルの再配置情報に基づいて、BPFプログラムがBPFマップにアクセスしている箇所(BPFマップにアクセスするためのヘルパー関数の引数)に、BPFマップのファイルディスクリプタ番号を埋め込む
  4. BPFプログラムをセクション名に応じた処理をしてカーネルにロードする

 「sockex1_kern.c」には関数やBPFマップ定義の前に「SEC()」が付いていました。これはコンパイラの属性で、ELFの特定のセクションに該当のオブジェクトが格納されることになります。

 BPFプログラムからBPFマップを利用するには、BPFプログラムにBPFマップのファイルディスクリプタを埋め込む必要があります。このファイルディスクリプタは一般にはBPFプログラムをコンパイルした段階では分かりません。そこで、いったんELFオブジェクトの形に情報を保存しておいて、BPFプログラムをロードする際にその情報に基づいてプログラムをパッチしてロードしているわけです。

コラム BPFとELFオブジェクト

 BPFプログラムのロードをする際に、このようにELFオブジェクトを利用しなければならないという決まりはありません。カーネル的にはシステムコールのインタフェースが定義されているだけです。

 ただし、ELFオブジェクトを利用する方法が一般に広く用いられています。下記のようなツールがELFオブジェクトを利用しています。

 なお、これらのツールで利用されるELFオブジェクトの形式(セクション名の命名規則など)が現状あまり統一されていません。ELFオブジェクトの統一は最近のBPFの話題の一つです。


BPFプログラムからのデバッグ出力

 「bpf_trace_printk()」と呼ばれるヘルパー関数を利用して、BPFプログラム側からデバッグ出力をすることができます。下記のように「sockex1_kern.c」を修正して実行してみます。

#define bpf_printk(fmt, ...)                                    
({                                                              
               char ____fmt[] = fmt;                            
               bpf_trace_printk(____fmt, sizeof(____fmt),       
                                ##__VA_ARGS__);                 
})
 
SEC("socket1")
int bpf_prog1(struct __sk_buff *skb)
{
    ...
    if (value) {
        __sync_fetch_and_add(value, skb->len);
        bpf_printk("index=%d, value=%ld\n" index, *value);
    }
    ...
}

 別のターミナルから以下のようにして出力が確認できます。

% sudo cat /sys/kernel/debug/tracing/trace_pipe
           <...>-35581 [000] .... 24491.646598: 0: index=1, value=98
           <...>-35581 [000] .Ns1 24491.646618: 0: index=1, value=196
           <...>-35581 [000] .... 24492.653635: 0: index=1, value=294
           <...>-35581 [000] .Ns1 24492.653648: 0: index=1, value=392
           <...>-35581 [000] .... 24493.677616: 0: index=1, value=490
           <...>-35581 [000] .Ns1 24493.677628: 0: index=1, value=588
           <...>-35581 [000] .... 24494.701535: 0: index=1, value=686
           <...>-35581 [000] .Ns1 24494.701562: 0: index=1, value=784
           <...>-35581 [000] .... 24495.725581: 0: index=1, value=882
           <...>-35581 [000] .Ns1 24495.725605: 0: index=1, value=980

 なお、ここで「bpf_printk()」をdefineして利用している理由は、BPFプログラムはグローバル変数が利用できないため、「bpf_trace_printk()」に与える文字列引数をスタック上に配置する必要があるからです。

 また、「bpf_trace_printk()」はあくまでデバッグ目的です。値を出力する場合はBPFマップや「bpf_perf_event_output()」を利用します。

コラム  BPFの検証機がチェックする項目

 上記のプログラムを以下のように書いた場合、BPFプログラムのロードに失敗します(コンパイルは通ります)。

    if (value)
        __sync_fetch_and_add(value, skb->len);
    bpf_printk("index=%d, value=%ld\\n" index, *value);

 これは、もし「value」がNULLだった場合、NULLポインタ参照になるためです。BPFをロードする際に、検証機はこのように安全でないプログラム実行がされないようにチェックしています。どのように検証が成されるかについては以前の記事を参照してください。

 なお今回の場合、キーのindexは「load_byte()」で読み込んだ1byteの0〜255の間の値なので、「bpf_map_lookup_elem()」がNULLを返すことはないでしょう。


まとめ

 今回はLinuxに付随のBPFのサンプルプログラムを利用してBPFの動作を見ていきました。ぜひ他のサンプルプログラムを動作させたり改変したりしてみてください。

 例えば、以下のことをしてみると面白いと思います。

  • 特定の種類のパケットのみをドロップする
  • パケットの到着間隔を記録する(ヒント:「bpf_ktime_get_ns()」ヘルパー関数を利用する)
  • 「sock1_kern.c」をIPv6のhop-by-hopオプションに対応させる

 さて、このサンプルプログラムはBPFの使用方法の勉強には最適ですが、自分でBPFプログラムを作成するのに利用するのは少々困難です。

 次回は代表的なBPFプログラム作成のためのライブラリであるBCC(BPF Compiler Collection)を利用してBPFプログラムを作成する方法を紹介します。

筆者紹介

味曽野 雅史(みその まさのり)

東京大学 大学院 情報理工学系研究科 博士課程

オペレーティングシステムや仮想化技術の研究に従事。


前のページへ 1|2       

Copyright © ITmedia, Inc. All Rights Reserved.

アイティメディアからのお知らせ

スポンサーからのお知らせPR

注目のテーマ

4AI by @IT - AIを作り、動かし、守り、生かす
Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。