Packet CaptureとBPF、その先にあるXDPに触る

この記事は[DeNA 21 新卒 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dena-21-shinsotsu)の9日目の記事です。記事の内容は個人の見解であり、所属する組織を代表するものではありません。

## Packet Captureとは
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114435/1b3d9c8b-1da6-ba9a-4659-f8115348c1b8.png)

Wikipediaでは[LANアナライザ](https://ja.wikipedia.org/wiki/LAN%E3%82%A2%E3%83%8A%E3%83%A9%E3%82%A4%E3%82%B6)と紹介されていたり、その呼び方は様々です。この記事では「特定のPCに関わるネットワーク通信を監視・記録を行うソフト」と定義します。
コンピュータが他のコンピュータと通信を行う際、所謂「パケット」という単位でデータの送出/受信を行います。`tcpdump`等のpacket capture toolを利用することで、気軽にコンピュータがどのような通信を行っているか閲覧することが出来ます。

では、そもそもこのpacket captureはどのように動作しているのでしょうか？通常アプリケーションでは特定のポートのみで待つ、といった挙動になるため、コンピュータに届いた全てのパケットを閲覧することは到底不可能です。

### Packet Captureの原理
「どのようにしてPacket Captureが動作しているか」を知るためには「パケットがどのような処理を受けてアプリケーションにデータが渡されるか」を知る必要があります。以下のスライドが分かりやすいかと思います。

- [はてなにおけるLinuxネットワークスタックパフォーマンス改善](https://speakerdeck.com/yuukit/linux-network-performance-improvement-at-hatena)

ざっくり理解するのであれば、以下の内容を理解していれば問題ないです。

1. パケットを受け取ると、まずKernelの処理が入る
2. Kernel内部で(TCP/IPに則るなら)Ethernet/IP/(TCP・UDP)のパースを行う
3. 出来たデータをportで待っているUser空間のプロセスに渡す

tcpdumpは、この2の中で一瞬割り込み処理を入れることで成り立っています。このような、Kernel空間での処理に割り込みを入れ、Packet Filteringを行う技術に[BPF](https://en.wikipedia.org/wiki/Berkeley_Packet_Filter)というものがあります。

### BPF(Berkeley Packet Filter)
BPFはUser空間で定義したプログラムをKernel空間の割り込み処理の決まった箇所で呼び出せるという技術です。Kernel空間の中でVMを作成して実行するため、使用できる命令セットに制約があったり、プログラム作成時に全ての命令に到達可能であることを保証している必要があったりします。

Packet Captureに用いられるtcpdumpもこれを利用しています。実際に、tcpdumpに`-d`や`-dd`、`-ddd`といったオプションを付けることでBPFで用いる「取り出すかどうか判別するコード」を見ることが出来ます。dの数が増えるほど人間向きで無くなります。以下のコードはアセンブリで「TCPのプロトコル番号が6であることを利用し、jeqをしている」くらいは読み取れます。

```bash
$ sudo tcpdump -d tcp
(000) ldh      [12]
(001) jeq      #0x86dd          jt 2	jf 7
(002) ldb      [20]
(003) jeq      #0x6             jt 10	jf 4
(004) jeq      #0x2c            jt 5	jf 11
(005) ldb      [54]
(006) jeq      #0x6             jt 10	jf 11
(007) jeq      #0x800           jt 8	jf 11
(008) ldb      [23]
(009) jeq      #0x6             jt 10	jf 11
(010) ret      #262144
(011) ret      #0
```

また、BPFではKernel空間とUser空間のプログラム同士でデータのやり取りをすることも可能です。tcpdumpではざっくりと「受け取ってfilterにかけてヒットしたpacketをUser空間へ渡す」ということを行っています。

## BPFの活用
さて、逆にBPFはどのようなことに用いることが出来るでしょうか？実はPacketをBPFで処理する際にはただUser空間に渡すだけでなく、そのPacketをどのように処理していくかを決めることが出来ます。

- 特定条件を満たすPacketをDROPし、User空間で待つアプリケーションへ渡さない
- Packetに細工をした上でREDIRECTし、別端末へ渡す

といったことも可能です。さらに、このようなフィルタリング技術は別にPacketにのみである必要はなく、例えば「syscallを監視し、ヤバいsyscallは拒否する」といったことも可能です（例えばdockerはコンテナ内での一部syscallをseccompで弾いていますが、seccompはbpfを利用しています）。他にもロードバランスにおいて高速・動的にiptablesを変更する手段として[Cilium](https://github.com/cilium/cilium)があったりもします。このように拡張されたeBPFが現在のLinux Kernelに実装されています。[このリンク](https://qiita.com/sg-matsumoto/items/8194320db32d4d8f7a16)は結構取っ掛かりやすいです。

![](https://ebpf.io/static/go-1a1bb6f1e64b1ad5597f57dc17cf1350.png)

さて、先ほどtcpdumpで示したようなコードを書いてオリジナルBPFを実装しよう！というのは流石に苦です。実際にはライブラリを利用するケースが多くなると思います。eBPFをサポートするライブラリには以下のようなものがあります。

- [bcc](https://github.com/iovisor/bcc)
- [bpftrace](https://github.com/iovisor/bpftrace)
- [ply](https://github.com/iovisor/ply)
- [libebpf](https://github.com/tuxology/libebpf)
- [xdp](https://github.com/xdp-project/xdp-project)

この中で、今回はXDPについて少し詳しく触れていきます。

### XDP
XDPの特徴は「Kernel空間での処理前に呼び出される」という点です[^1]。個人的には[このスライド](https://www.janog.gr.jp/meeting/janog45/application/files/1615/7984/1029/008_pktfwd-xdp_kusakabe_00.pdf)は参考になりました。

さて、XDPなんですが、実は[公式チュートリアル](https://github.com/xdp-project/xdp-tutorial)が存在し、これをやるとある程度扱えるようになります。が、折角なので今回はXDPを利用して簡易的なtcpdumpのようなものを作成しようと思います。

#### XDPを使用する環境のインストール
[XDP tutorial](https://github.com/xdp-project/xdp-tutorial/blob/master/setup_dependencies.org)をベースに進めます。Ubuntuの場合、以下コマンドを実行してください。

```bash
$ sudo apt install clang llvm libelf-dev libpcap-dev gcc-multilib build-essential
$ sudo apt install linux-tools-$(uname -r)
$ git clone --recurse-submodules https://github.com/xdp-project/xdp-tutorial
```

実装量を少し減らしたいので、`xdp-tutorial`内部にある`common`と`libbpf`ディレクトリ内部を参照します。それが出来るように必要に応じて後述のコードを変えてください。

#### 実装
XDP問わずBPFには以下の2つのプログラムが必要です。

- 実際にKernel空間内部で実行するBPFプログラム
- BPFプログラムをattach/detachしたり、BPFプログラムとデータのやり取りを行うプログラム

Pythonで実装するbccなどでは後者のコード内部に前者のコードを文字列として入れてしまうサンプルが多いです。今回、tcpdumpで受け取るUser空間側のプログラムは[xdp-tutorialにあるもの](https://github.com/fiord/xdp-tutorial/blob/master/tracing04-xdp-tcpdump/xdp_sample_pkts_user.c)を使います（あまりにも長いので、キーポイントだけ抜粋して解説します）

```c:xdp_kernel.c
// SPDX-License-Identifier: GPL-2.0
#include <linux/bpf.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#include "../common/parsing_helpers.h"

#define SAMPLE_SIZE 1024ul
#define MAX_CPUS 128

#ifndef __packed
#define __packed __attribute__((packed))
#endif

#define min(x, y) ((x) > (y) ? (y) : (x))

// packetのmetadata(user側で出力に使用)
struct S{
  // user側で必要なcookie
  __u16 cookie;
  __u16 pkt_len;
} __packed;

// User空間と共有するmapの宣言
struct bpf_map_def SEC("maps") my_map = {
  .type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
  .key_size = sizeof(int),
  .value_size = sizeof(__u32),
  .max_entries = MAX_CPUS,
};

SEC("xdp_sample")
int xdp_sample_pro(struct xdp_md *ctx) {
  // packet dataはdata~data_endの間の領域にあります
  void *data = (void*)(long)ctx->data;
  void *data_end = (void*)(long)ctx->data_end;
  struct hdr_cursor nh;
  struct ethhdr *eth;
  int eth_type;
  int ip_type;
  struct iphdr *iphdr;
  struct ipv6hdr *ipv6hdr;

  // parse
  nh.pos = data;

  eth_type = parse_ethhdr(&nh, data_end, &eth);
  if (eth_type == bpf_htons(ETH_P_IP)) {
    ip_type = parse_iphdr(&nh, data_end, &iphdr);
    if (ip_type != IPPROTO_ICMP) {
      goto out;
    }
  }
  else if (eth_type == bpf_htons(ETH_P_IPV6)) {
    ip_type = parse_ip6hdr(&nh, data_end, &ipv6hdr);
    if (ip_type != IPPROTO_ICMP6) {
      goto out;
    }
  }

  // data送信
  __u64 flags = BPF_F_CURRENT_CPU;
  __u16 sample_size;
  int ret;
  struct S metadata;

  metadata.cookie = 0xdead;
  metadata.pkt_len = (__u16)(data_end - data);
  sample_size = min(metadata.pkt_len, SAMPLE_SIZE);

  flags |= (__u64)sample_size << 32;

  // ここでmapにmetadataを送信しています
  ret = bpf_perf_event_output(ctx, &my_map, flags, &metadata, sizeof(metadata));

  return XDP_PASS;

out:
  return XDP_DROP;
}

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

この実装では、以下のような挙動をします。ICMPを選んだのは検証が楽なためです（pingだけが返ってくればOK）

- ICMP Packetを受け取った際には`bpf_perf_event_output`を用いてUser空間へデータを送る
- ICMP以外のPacketはDROPするため、ping(ICMPです)しか応答しないしログにも残らない

では、User空間ではどのようにしてKernel空間でのデータを貰うのかというと、`main`関数内、BPFプログラム読み込んだ直後にある

```c
map = bpf_map__next(NULL, obj);
  if (!map) {
    printf("ERROR: finding a map in obj file failed\n");
    return 1;
  }
  map_fd = bpf_map__fd(map);
```

で、BPFプログラムを読み込んだ`obj`にある`map`を取り出し、以降これをCPU毎に分配、`perf_event_mmap_header`で`perf_event_mmap_page`に変換してから

```c
int perf_event_poller_multi(int *fds, struct perf_event_mmap_page **headers, int num_fds, perf_event_print_fn output_fn, int *done) {
  enum bpf_perf_event_ret ret;
  struct pollfd *pfds;
  void *buf = NULL;
  size_t len = 0;
  int i;

  pfds = calloc(num_fds, sizeof(*pfds));
  if (!pfds) {
    return LIBBPF_PERF_EVENT_ERROR;
  }

  for (i = 0; i < num_fds; i++) {
    pfds[i].fd = fds[i];
    pfds[i].events = POLLIN;
  }

  while(!*done) {
    poll(pfds, num_fds, 1000);
    for (i = 0; i < num_fds; i++) {
      if (!pfds[i].revents) {
        continue;
      }

      ret = bpf_perf_event_read_simple(headers[i],
          page_cnt * page_size,
          page_size,
          &buf,
          &len,
          bpf_perf_event_print,
          output_fn);

      if (ret != LIBBPF_PERF_EVENT_CONT) {
        break;
      }
    }
  }

  free(buf);
  free(pfds);
  return ret;
}
```

のwhileループ内の`bpf_perf_event_read_simple`で`buf`、`len`にデータを移し替えつつ出力関数を呼んでいます。こうすることでUser空間側で受け取ったデータを記録することが出来ます。

#### 動作検証
上のプログラムを動作させた端末（端末A）とその端末にPacketを投げ込む端末（端末B）を用意します。

- 端末Aで`nc -l 3000`している状態で端末Bで`nc <端末AのIPアドレス>`でTCP通信が出来ないこと
- 端末Bで`ping <端末AのIPアドレス>`でICMPでの疎通確認が取れること

が確認出来ればOKです。

```bash:端末A
$ sudo ./xdp_user -d <端末Aのnetwork interface名> -F # 受け取ったパケットが表示される
# 以下別プロセスにて
$ nc -l 3000
```

```bash:端末B
$ nc <端末AのIPアドレス> 3000
# 適当にタイプしてEnterキーで端末Aに反映されない=TCP connectionが出来ていない
$ ping <端末AのIPアドレス> # 同時に上のxdp_userプロセスでicmpパケットが見える
64 bytes from <端末AのIPアドレス> icmp_seq=1 ttl=64 time=X.XXX ms
64 bytes from <端末AのIPアドレス> icmp_seq=2 ttl=64 time=X.XXX ms
64 bytes from <端末AのIPアドレス> icmp_seq=3 ttl=64 time=X.XXX ms
```

## 宣伝
この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ LGTM、Twitter や Facebook、はてなブックマークにてコメントをお願いします！

また [DeNA 公式 Twitter アカウント @DeNAxTech](https://twitter.com/DeNAxTech) では、 Blog 記事だけでなく色々な勉強会での登壇資料も発信しています。ぜひフォローして下さい！
<a href="https://twitter.com/DeNAxTech?ref_src=twsrc%5Etfw" class="twitter-follow-button" data-size="large" data-lang="ja" data-show-count="false">Follow @DeNAxTech</a><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

[^1]: [この記事](http://yunazuno.hatenablog.com/entry/2017/06/12/094101)にもあるように、厳密にはXDPに対応するNICを利用する必要があり、多くの環境でこれを満たすのは難しいと考えています。その点も踏まえ、この記事ではGeneric XDPを使用しています。また、全ライブラリの特徴を完全に把握している訳ではないので、他にも同じタイミングで処理できるライブラリもあるとは思いますが...