Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載。今回は、最も基本となるC言語によるBPFプログラム作成方法および使用方法について。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
Linuxにおける利用が急速に増えている「Berkeley Packet Filter(BPF)」について、基礎から応用まで幅広く紹介する連載「Berkeley Packet Filter(BPF)入門」。前々回までは、BPFの基本やBPFのプログラムの作成方法を説明しました。前回から、「LinuxのBPFで何ができるのか」について詳しく見ています。
今回からさまざまなBPFプログラムを作成してBPFの使い方を学んでいきましょう。
例として今回はソケットに対するBPFプログラムを作成します。BPFを使うと、カーネル内で受信パケットをフィルタリングしたり、パケットの統計情報を記録したりすることができます。
前々回で説明した通り、BPFプログラムの作成方法は複数あります。ここではBPFについて詳しく知るために、最も基本となるC言語によるBPFプログラム作成方法および使用方法を紹介します。
なお多くの場合は、高レベルのライブラリ(bccなど)を利用することでより楽にBPFプログラムを作成することができます。bccについては次回説明します。
本稿ではUbuntu 18.04.(Linux kernel 5.0.0-31-generic)を利用します。BPFの機能は非常に活発に開発されています。もしBPFプログラムが動作しない場合はカーネルのバージョンを確認してみてください。今回作成するBPFプログラムはLinux 4.1以上であれば動作すると思います。
また、BPFを利用するにはBPFに関するカーネルコンフィグが有効である必要があります。以下のようにしてカーネルのBPFに関する設定が確認できます。
% grep BPF /boot/config-`uname -r` CONFIG_CGROUP_BPF=y CONFIG_BPF=y CONFIG_BPF_SYSCALL=y CONFIG_BPF_JIT_ALWAYS_ON=y CONFIG_IPV6_SEG6_BPF=y CONFIG_NETFILTER_XT_MATCH_BPF=m CONFIG_BPFILTER=y CONFIG_BPFILTER_UMH=m CONFIG_NET_CLS_BPF=m CONFIG_NET_ACT_BPF=m CONFIG_BPF_JIT=y CONFIG_BPF_STREAM_PARSER=y CONFIG_LWTUNNEL_BPF=y CONFIG_HAVE_EBPF_JIT=y CONFIG_BPF_EVENTS=y CONFIG_BPF_KPROBE_OVERRIDE=y CONFIG_TEST_BPF=m
Ubuntu 18.04ではBPF機能が有効化されています。
LinuxカーネルのソースにはBPFのサンプルプログラムが含まれています(ソースディレクトリ)。
BPFの動作を知るのに便利なサンプルプログラムです。今回は、このサンプルプログラムを動かしてBPFの動作を見ていきます。
このサンプルコードですが、カーネルのビルドシステムを利用しているため、そのままではサンプルプログラムのみを取得してコンパイルすることはできません。システムが利用しているカーネルのソースコードを取得してコンパイルするのがいいでしょう。
Ubuntuであれば、以下のようにして最新のソースが取得可能です。
sudo apt get linux-source-5.0.0 cp /usr/src/linux-source-5.0.0/linux-source-5.0.0.tar.bz2 . tar jxvf linux-source-5.0.0.tar.bz2
あるいは、gitリポジトリから特定のバージョンのものを取得することもできます。
git clone git://kernel.ubuntu.com/ubuntu/ubuntu-bionic.git -b Ubuntu-hwe-5.0.0-31.33_18.04.1 --depth 1
システムのカーネルのバージョンは「cat /proc/version_signature」コマンドで調べることができます。また「-b Ubuntu-hwe-5.0.0-31.33_18.04.1」コマンドでリポジトリの特定のタグをチェックアウトしています。
指定可能なタグはこちらから確認できます(Ubuntu bionicの場合。それ以外はこちらから探します)。
ソースが取得できたら、以下のようにしてBPFのサンプルプログラムをコンパイルします。
sudo apt install build-essential clang llvm flex bison libelf-dev cd <カーネルソースディレクトリ> make olddefconfig make headers_install make samples/bpf/ # 末尾に"/"を付ける
samples/bpf以下にサンプルプログラムがコンパイルされます。BPFプログラムはLLVMやClangを用いてBPFのバイトコードを持つELFオブジェクトにコンパイルされています。
最近ではコンテナの利用がだいぶ普及しました。そこで、コンテナ内でBPFプログラムを実行してみたいと思われる方もいるかもしれません。しかし、BPFはカーネルの機能を利用するものなので、一般にサンドボックス環境であるコンテナ内ではBPFの利用は制限されます。
Dockerはデフォルトで「seccomp」を利用してコンテナ内でのbpfシステムコールの利用を制限しています。この制限は「--privileged」オプションを付けてコンテナを起動することで回避可能です。
ただし、当然ながらこの手法はコンテナに特権を与えることになるのでその点は注意が必要です。
サンプルプログラムのディレクトリ内では、「xxx_user.c」がユーザーランドのプログラム、「xxx_kern.c」がそのプログラム内で利用されるBPFプログラムという構成になっています。
今回は、その中のソケットに対するサンプルプログラム「sockex1_user.c」「sockex1_kern.c」を見ていきます。
このプログラムは、BPFを用いてIPヘッダのプロトコルタイプ別に受信したパケットの累計のデータサイズを記録します。
「sockex1_user.c」は「sockex1」というプログラムにコンパイルされます。プログラムを実行すると、1秒ごとにプロトコル別に受信したパケットサイズを表示します。
% sudo ./sockex1 TCP 0 UDP 0 ICMP 0 bytes TCP 0 UDP 0 ICMP 196 bytes TCP 0 UDP 0 ICMP 392 bytes TCP 0 UDP 0 ICMP 588 bytes TCP 0 UDP 0 ICMP 784 bytes
なお、環境によっては出力が全て0になるかもしれません。筆者が試した環境(Ubuntu 18.04.3)だと、「ping localhost」が送信するパケットにIPv6の「Hop-by-hopヘッダ」が付属していてICMPパケットとして計数されていませんでした。そのような場合、「sockex1_user.c」の下記コードを
f = popen("ping -c5 localhost", "r");
下記に変えて実行してみてください(最新版のカーネルではこれは修正されています)。
f = popen("ping -4 -c5 localhost", "r");
以下で具体的なプログラムの内容を見ていきます。
まず、ユーザーランド側のプログラム「sockex1_user.c」を見てみましょう(コメントは筆者によるものです)。
// SPDX-License-Identifier: GPL-2.0 #include <stdio.h> #include <assert.h> #include <linux/bpf.h> #include <bpf/bpf.h> #include "bpf_load.h" #include "sock_example.h" #include <unistd.h> #include <arpa/inet.h> int main(int ac, char **argv) { char filename[256]; FILE *f; int i, sock; snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]); // 【1】sockex1_kern.oとしてコンパイルされたBPFプログラムをロード // & BPFマップの作成 if (load_bpf_file(filename)) { printf("%s", bpf_log_buf); return 1; } // 【2】ループバックデバイスをopen sock = open_raw_sock("lo"); // 【3】ここで【2】のソケットに対して【1】でロードしたBPFプログラムをアタッチ assert(setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, prog_fd, sizeof(prog_fd[0])) == 0); // 【4】実験のためにループバックデバイスに対してpingコマンドでパケット送信 f = popen("ping -c5 localhost", "r"); (void) f; // 【5】1秒ごとにBPFマップを確認してTCP、UDP、ICMPに関して情報を出力 for (i = 0; i < 5; i++) { long long tcp_cnt, udp_cnt, icmp_cnt; int key; key = IPPROTO_TCP; assert(bpf_map_lookup_elem(map_fd[0], &key, &tcp_cnt) == 0); key = IPPROTO_UDP; assert(bpf_map_lookup_elem(map_fd[0], &key, &udp_cnt) == 0); key = IPPROTO_ICMP; assert(bpf_map_lookup_elem(map_fd[0], &key, &icmp_cnt) == 0); printf("TCP %lld UDP %lld ICMP %lld bytes\n", tcp_cnt, udp_cnt, icmp_cnt); sleep(1); } return 0; }
ここでのポイントはプログラムタイプが「BPF_PROG_TYPE_SOCKET_FILTER」のBPFプログラムをロードして(上記sockex1_user.cの【1】)、それを「setsockopt()」を利用してソケットに対してアタッチしている(上記sockex1_user.cの【4】)という点です。
「bpf_map_lookup_elem()」は、BPFマップにアクセスする関数です。ソケットにアタッチしたBPFプログラムがソケットのパケット受信のイベントに応じて、このBPFマップの情報をアップデートしていきます。
ユーザー空間からBPFプログラムの操作に利用するのがbpf(2)システムコールです。上記のサンプルプログラムではライブラリ関数を利用しているので直接bpfシステムコールを呼び出していませんが、bpfシステムコールでは以下のようなことができます。
このサンプルプログラムが利用しているライブラリ関数は「libppf」と呼ばれ、「tools/lib/bpf」にあります。またlibpfは、カーネルソースから独立したリポジトリもあります。bpfシステムコールの利用方法が知りたい方はこのlibbpfを参照するといいでしょう。
作成したBPFプログラムのアタッチは、BPFのプログラムタイプごとに方法が異なります。ソケット(「BPF_PROG_TYPE_SOCKET_FILTER」)の場合は「setsockopt(2)」を利用します。BPFのロード処理に関しては詳しくは後述します。
今回ソケットに対してeBPFのプログラムをアタッチしていますが、ソケットには従来のcBPFのプログラムをアタッチすることもできます。前者には「setsockopt(SO_ATTACH_BPF)」を、後者には「setsockopt(SO_ATTACH_FILTER)」を利用します。
連載第3回で説明したように、「tcpdump」を利用すると理解しやすいフィルター式をcBPFに変換することが可能です。
パケットフィルタリングが目的であればこちらを利用した方が楽かもしれません。
Copyright © ITmedia, Inc. All Rights Reserved.