パケットキャプチャの実装方法

ネットワークアプリケーションの解析やデバッグなどにパケットキャプチャ(もしくはスニファ)は必須の機能で、UNIXであればtcpdump、snoop、wireshark*1Plan 9であればsnoopyなどが存在する。パケットを横から盗み見するために、OSごとにいろんなアプローチを取っている。ある機能をどのように実装するかで、そのOSの設計哲学が透けて見えてくるかもしれない。ということで、今日はパケットキャプチャの実装方法について調べてみたい。

libpcap

tcpdumpのパケットキャプチャ処理はlibpcapとして独立したライブラリになっていて、OS依存部分を隠蔽している。例えば、BSDUNIXはBPF(Berkeley Packet Filter)、LinuxはPF_PACKETを利用してパケットキャプチャを実現している。より正確にはBPFはキャプチャとフィルタリングするのに対して、PF_PACKETはキャプチャしかしない。カーネル内でパケットのフィルタリングを実行するために、Linuxはソケットフィルタという仕組みを実装している*2

BSDの場合(BPF)

ネットワークインタフェースにアタッチして、キャプチャしたデータをbpfデバイス(/dev/bpf0など)から読み出す。パケットをタップするコードがネットワークインタフェースのデバドラに埋込まれている。例えばNetBSDのドライバを見るとこんな感じのコードが送受信処理時に実行されている。

    if (ifp->if_bpf)
        bpf_mtap(ifp->if_bpf, m);

タップしたパケットはbpf_filterでフィルタリングされ、BPFパケットヘッダが作られてbpfデバイスにキューイングされる。ドライバごとにbpf_mtapの呼出しを書く必要があるのはいまいちな気がするなぁ。

BPFの歴史は意外と古い。LBLのSteven McCanneとVan Jacobson(TCP/IPの大家)による論文は1993年のUSENIXで発表されている(「The BSD Packet Filter: A New Architecture for User-level Packet Capture」)。さらにさかのぼると、1980 年にCMUのMike Accetta、Richard Rashidらによって開発されたEnetパケットフィルタ(EtherNet packet filter)が祖先になる。1983年にはStanford大のJeffrey MogulがEnetをBSDに移植し、SunOS 4.1のSTREAMS NIT、そしてBPFへと進化していった。

net/bpf.[ch]、dev/pci/if_wm.cあたりのコードが関係する。

Linuxの場合(PF_PACKET)

まずは使い方から。そもそもパケットキャプチャをネタにしようと思いついたのは、tcpdumpを使っていてEthernetのヘッダのヘキサダンプを見たかったので*3、次のようなスクリプトを書いてみたというのがきっかけだった。PF_PACKETを使うことでパケットがキャプチャできるのがわかると思う。

#!/usr/bin/python
import sys
from socket import *
 
ETH_P_IP = 0x800
s = socket(PF_PACKET, SOCK_RAW, ETH_P_IP)
s.bind(("eth1", ETH_P_IP))
 
while 1:
    p = s.recv(2048)
    plen = len(p)
 
    # dump header
    src = ":".join(["%02x" % ord(x) for x in p[0:6]])
    dst = ":".join(["%02x" % ord(x) for x in p[6:12]])
    type = ntohs(ord(p[12:13]))
    print("%s > %s, ethertype %04x, length %d" % (src, dst, type, plen))
 
    # dump body
    print("\t"),
    for i in xrange(0, plen, 2):
        print("%02x%02x" % (ord(p[i]), ord(p[i+1]))),
        if i % 16 == 14:
            print("\n\t"),
    print("")

では、実装がどうなっているのか見て行こう。

BPFではドライバにフックポイントを埋込んでいた訳だが、PF_PACKETは一つのプロトコルファミリなので、PF_INET(TCP/IP)スタックと同じレイアになる。プロトコルスイッチというか、受信時の多重分離(demultiplexing)処理を考えてみよう。INETの場合、dev_add_pack関数で受信ハンドラをip_rcv関数に登録するのに対して、PF_PACKETはパケットを受信するとpacket_rcv関数が呼ばれる。もう少し具体的にはbindによってPF_PACKETソケットとインタフェースがbindされるときに、dev_add_packが呼ばれる。これによってタップしたインタフェースがパケットを受信した場合、packet_rcvが呼ばれ、受信キューにパケットがキューイングされる。

BPFパケットヘッダにはタイムスタンプフィールドがある(のでタイムスタンプを記録するのはカーネルの仕事)けど、PF_PACKETだとユーザランドでタイムスタンプを記録しているのかな。

PF_PACKETはLinux kernel 2.2で導入された。最近のOpenSolaris?でもLinuxとの互換性のためにPF_PACKETサポートが追加されたらしい。

net/packet/af_packet.c、net/core/dev.cあたりのコードが関係する。

Plan 9の場合

Plan 9であれば、これまたechoとcatだけでパケットキャプチャが実装できてしまう。もちろん、これはproof of conceptであり実用には耐えないが、基本はsnoopyも同じである。

#!/bin/rc
clonefile=/net/ether0/clone
<[4] $clonefile {
	netdir=`{basename -d $clonefile} ^ / ^  `{cat /fd/4}
	echo connect -1 >$netdir/ctl || exit 'cannot connect'
	cat $netdir/data | xd -u -x2
}

このスクリプトを実行して、リモートからpingを打つと、次のような結果が得られる。

% pdump
0000000 000c 2996 c3ee 0050 56c0 0008 0800 4500
0000010 0054 9f39 0000 4001 ed9a c0a8 b601 c0a8
0000020 b682 0800 192f 6638 0000 4b45 a78c 0004
0000030 9abf 0809 0a0b 0c0d 0e0f 1011 1213 1415
0000040 1617 1819 1a1b 1c1d 1e1f 2021 2223 2425
0000050 2627

ここではpingの1パケット目だけを切り出しているが、実際にはパケットの切れ目なくダンプされるので、見るに耐えられるものではない(笑)

上のスクリプトが何をしているかというと、/net/ether0!-1にdialして、そこからreadしているだけである。

補足すると、/net/ethernはイーサネットインタフェースを抽象化していて、こんなディレクトリ構造になっている。

/net/ethern/clone
/net/ethern/addr
/net/ethern/ifstats
/net/ethern/stats
/net/ethern/[0-7]
/net/ethern/[0-7]/data
/net/ethern/[0-7]/ctl
/net/ethern/[0-7]/ifstats
/net/ethern/[0-7]/stats
/net/ethern/[0-7]/type

デフォルトでは3階層目は次のようになっているはずだ。

% lc /net/ether0
0 1 2 addr clone ifstats stats

0から2のtypeはそれぞれ0x0806、0x0800、0x86DD。つまり、ARPIPv4IPv6を意味している。この状態でdial("/net/ether0!-1")すると、3というディレクトリが新たに作られ、typeが-1に設定される。これらのサブディレクトリがプロトコルファミリを現しており、typeが-1とはPF_PACKETに相当するわけである。

パケットのフィルタリングに関してはPlan 9カーネルは何も提供していないので、すべてのパケットをカーネルからユーザランドにコピーしてくる必要があり、性能的には問題になりそうだ。

関係するコードは9/port/netif.[ch]あたり。

まとめ

BPF、PF_PACKET、Plan 9の三つのパケットキャプチャ実装方法について調べた。BPFはネットワークインタフェースのデバイスドライバにフックポイントを埋込む方法でパケットを盗み見している。一方、後者の二つは新たなプロトコルファミリを定義しており*4TCP/IPプロトコルスタックを介さずに直接ユーザがパケットにアクセスできる手段を提供している。そして、PF_PACKETとPlan 9の違いは、ソケットインタフェースを使うか、ファイルインタフェースを使うかという実装上の違いに帰着する。

*1:パッケージにはCLIで動くtsharkが含まれている。

*2:NetFilterとは別物。

*3:-eとか-xとか試してみたがヘキサダンプされるのはEthernetペイロードからだった。きっと方法はある気がするので、知っていたら教えてください。

*4:Plan 9の場合はそこまで明示的になってはいないが。