この記事はDeNA 21 新卒 Advent Calendar 2020の9日目の記事です。記事の内容は個人の見解であり、所属する組織を代表するものではありません。
Packet Captureとは
WikipediaではLANアナライザと紹介されていたり、その呼び方は様々です。この記事では「特定のPCに関わるネットワーク通信を監視・記録を行うソフト」と定義します。
コンピュータが他のコンピュータと通信を行う際、所謂「パケット」という単位でデータの送出/受信を行います。tcpdump
等のpacket capture toolを利用することで、気軽にコンピュータがどのような通信を行っているか閲覧することが出来ます。
では、そもそもこのpacket captureはどのように動作しているのでしょうか?通常アプリケーションでは特定のポートのみで待つ、といった挙動になるため、コンピュータに届いた全てのパケットを閲覧することは到底不可能です。
Packet Captureの原理
「どのようにしてPacket Captureが動作しているか」を知るためには「パケットがどのような処理を受けてアプリケーションにデータが渡されるか」を知る必要があります。以下のスライドが分かりやすいかと思います。
ざっくり理解するのであれば、以下の内容を理解していれば問題ないです。
- パケットを受け取ると、まずKernelの処理が入る
- Kernel内部で(TCP/IPに則るなら)Ethernet/IP/(TCP・UDP)のパースを行う
- 出来たデータをportで待っているUser空間のプロセスに渡す
tcpdumpは、この2の中で一瞬割り込み処理を入れることで成り立っています。このような、Kernel空間での処理に割り込みを入れ、Packet Filteringを行う技術にBPFというものがあります。
BPF(Berkeley Packet Filter)
BPFはUser空間で定義したプログラムをKernel空間の割り込み処理の決まった箇所で呼び出せるという技術です。Kernel空間の中でVMを作成して実行するため、使用できる命令セットに制約があったり、プログラム作成時に全ての命令に到達可能であることを保証している必要があったりします。
Packet Captureに用いられるtcpdumpもこれを利用しています。実際に、tcpdumpに-d
や-dd
、-ddd
といったオプションを付けることでBPFで用いる「取り出すかどうか判別するコード」を見ることが出来ます。dの数が増えるほど人間向きで無くなります。以下のコードはアセンブリで「TCPのプロトコル番号が6であることを利用し、jeqをしている」くらいは読み取れます。
$ 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があったりもします。このように拡張されたeBPFが現在のLinux Kernelに実装されています。このリンクは結構取っ掛かりやすいです。
さて、先ほどtcpdumpで示したようなコードを書いてオリジナルBPFを実装しよう!というのは流石に苦です。実際にはライブラリを利用するケースが多くなると思います。eBPFをサポートするライブラリには以下のようなものがあります。
この中で、今回はXDPについて少し詳しく触れていきます。
XDP
XDPの特徴は「Kernel空間での処理前に呼び出される」という点です1。個人的にはこのスライドは参考になりました。
さて、XDPなんですが、実は公式チュートリアルが存在し、これをやるとある程度扱えるようになります。が、折角なので今回はXDPを利用して簡易的なtcpdumpのようなものを作成しようと思います。
XDPを使用する環境のインストール
XDP tutorialをベースに進めます。Ubuntuの場合、以下コマンドを実行してください。
$ 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にあるものを使います(あまりにも長いので、キーポイントだけ抜粋して解説します)
// 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, ð);
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プログラム読み込んだ直後にある
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
に変換してから
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です。
$ sudo ./xdp_user -d <端末Aのnetwork interface名> -F # 受け取ったパケットが表示される
# 以下別プロセスにて
$ nc -l 3000
$ 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 では、 Blog 記事だけでなく色々な勉強会での登壇資料も発信しています。ぜひフォローして下さい!
Follow @DeNAxTech