RustでBPFでパケットキャプチャ

ネットワークとRustの勉強のため,データリンク層Ethernet)のパケットを扱ってみることにした.それを簡単にできるlibpnetクレートが既にあるが,便利すぎて,これを使っては全く勉強にならなそうなので使わずに実装することにした.

といっても80%以上libpnetクレートの写経なので,それの解説と思って読んでいただく方がいいかも.

が,なんと実装する過程でこのライブラリのバグを発見して,issueを立てて実際に修正されたコードがmasterブランチにマージされたので,そのことにも少し触れながら書いていく.

成果物はこちら↓

実行環境

macOS Catalina (Ver. 10.15.7)

$ rustup -V
rustup 1.22.1 (b01adbbc3 2020-07-08)
$ rustc -V
rustc 1.44.1 (c7087fe00 2020-06-17)

Ethernetのパケットを扱う方法

トランスポート層以上のパケットを扱う場合,ほとんどのOSでsocketインターフェースが用意されているので,それを使うことで簡単に送受信を行うことができる.しかし,ネットワーク層以下のパケットを扱う方法はOSによって異なり,標準化されていない.

Ethernetなどのパケットを扱う際,Linuxの場合は,TCPUDPの時と同じように,socket関数を使うことができるが,macOSなどのUnix系のOSの場合はsocket関数を使ってEthernetなどのパケットを取得することはできない.代わりにBPFという,カーネルが提供する機能を使う.

BPF(Berkeley Packet Filter)

BPFは,ユーザが作成した,特殊な命令セットで書かれたBPFプログラムをシステムコールによってカーネル内に登録しておくと,対応するイベントが発生した時に,関数としてそのプログラムが実行されるというもの.何にアタッチするかによって,ネットワークにおけるパケットフィルタとして動作するものや,システムコールのフィルタ,プログラムのトレーシングなどに使うことができる.

今回は特にフィルタリングはしないので,BPFプログラムの作成/登録はせずに,ただデータリンク層のパケットを取得するためにこの機能使う.BPFデバイスを用いることにより,ユーザプロセスは,BPFファイルへの読み書きでパケットの送受信を行うことができる.

プログラム

使用する言語はRust.本筋からずれてしまうので,Rustについて詳しいことはここでは述べません.

処理の流れ

  1. 使用可能なBPFデバイスを探し,openする.

  2. BPFファイルread時のバッファ長を設定.(3. の前に行う必要がある)

  3. 使用するインターフェースを設定.(今回はネットワークインターフェース)

  4. 直接モードを有効に設定.これにより,readの際に,パケットを受信したらただちに返るようになる.無効の場合は,readの際に,カーネルバッファが満杯になるかタイムアウトが起こるまでブロックされる.今回は受信したらなるべくリアルタイムに表示させたいので有効にする方が適している.

  5. インターフェースをプロミスキャスモードに設定.これにより,自分宛以外のパケットも取得できるようになる.

  6. BPFファイルを監視.read可能になったらreadする.

  7. パケット解析.(BPFパケット)

  8. パケット解析.(Ethernetパケット)

コード(Rust)

大体の処理の流れがつかめたと思うので,処理の流れと照らし合わせながら実際のコードを説明していく.

1.

まずは

"1. 使用可能なBPFデバイスを探し,openする."

の部分のコード.

// 1. 使用可能なBPFデバイスを探し,openする.
fn get_fd(attempts: usize) -> libc::c_int {
    for i in 0..attempts {
        let fd = unsafe {
            let file_name = format!("/dev/bpf{}", i);
            libc::open(
                CString::new(file_name.as_bytes()).unwrap().as_ptr(),
                libc::O_RDWR,
                0,
            )
        };
        if fd != -1 {
            return fd;
        }
    }
    -1
}

OpenBSDmacOSでは,/dev/bpf*がBPFファイルである.(私のmacでは,bpf0bpf255の256個あった.)これらを順番にopenしていき,使用可能なBPFデバイスを探す.

2. 〜5.

続いて2. 〜5. までのコード.

pub fn channel(network_interface_name: &String, config: Config) -> io::Result<Channel> {
    // 1. 使用可能なBPFデバイスを探し,openする.
    let fd = get_fd(config.bpf_fd_attempts);
    if fd == -1 {
        return Err(io::Error::last_os_error());
    }
    let mut iface: bpf::ifreq = unsafe { mem::zeroed() };
    for (i, c) in network_interface_name.bytes().enumerate() {
        iface.ifr_name[i] = c as i8;
    }

    // 2. BPFファイルread時のバッファ長を設定.
    if unsafe { bpf::ioctl(fd, bpf::BIOCSBLEN, &(config.read_buffer_size as libc::c_uint)) } == -1 {
        let err = io::Error::last_os_error();
        unsafe {
            libc::close(fd);
        }
        return Err(err);
    }

    // 3. 使用するインターフェースを設定.
    if unsafe { bpf::ioctl(fd, bpf::BIOCSETIF, &iface) } == -1 {
        let err = io::Error::last_os_error();
        unsafe {
            libc::close(fd);
        }
        return Err(err);
    }

    // 4. 直接モードを有効に設定.
    if unsafe { bpf::ioctl(fd, bpf::BIOCIMMEDIATE, &1) } == -1 {
        let err = io::Error::last_os_error();
        unsafe {
            libc::close(fd);
        }
        return Err(err);
    }

    // 5. インターフェースをプロミスキャスモードに設定.
    if config.promiscuous {
        if unsafe { bpf::ioctl(fd, bpf::BIOCPROMISC, ptr::null::<&libc::c_ulong>()) } == -1 {
            let err = io::Error::last_os_error();
            unsafe {
                libc::close(fd);
            }
            return Err(err);
        }
    }

    let sender = DataLinkSender {};

    let mut receiver = DataLinkReceiver {
        fd: fd,
        fd_set: unsafe { mem::zeroed() },
        read_buffer: vec![0; config.read_buffer_size],
        // Ethernetの最小パケットサイズは 64 Byte.
        // ここからbuffer内に存在する最大パケット数がわかる.
        packets: VecDeque::with_capacity(config.read_buffer_size / 64),
    };
    unsafe {
        libc::FD_ZERO(&mut receiver.fd_set as *mut libc::fd_set);
        libc::FD_SET(fd, &mut receiver.fd_set as *mut libc::fd_set);
    }

    Ok(Channel::Ethernet(sender, receiver))
}

ioctlという関数を使ってBPFデバイスの設定を行うことができる. channel関数は,返り値として,パケットを送受信するための構造体DataLinkSender/DataLinkReceiverのセットを返す. 今回は受信しか行わないのでDataLinkSenderの中身は実装していない.

channel関数の最後に登場したFD_ZEROFD_SETについて説明する.

fd_setpselect

このタイミングでこの説明をするのは若干順番が前後しているのだが,ご容赦ください.

FD_ZERO関数,FD_SET関数で設定する変数は,

"6. BPFファイルを監視.read可能になったらreadする."

で使うものである.

監視する際に,pselectという関数を使う.この引数として,fd_setというものがある.pselect関数は,複数のファイルディスクリプタを監視することができる関数である.要するに,fd_setは,監視するファイルディスクリプタの一覧のようなものを示すものである.

ファイルディスクリプタは,データ上はlibc::c_intで,基本的にはファイルをopenする度に+1され,ファイルとかをOSが識別するために用いる識別子である.要するに,ただの数値ということ.fd_setのデータ構造はただのビット列で,監視したいファイルディスクリプタの数値番目のビットを立ててpselect関数に渡せば良い.

ここら辺のことは,libc::FD_SETlibc::fd_setあたりのソースコードを読むとなんとなく理解することができる.

pub const FD_SETSIZE: usize = 1024;
    pub struct fd_set {
        #[cfg(all(target_pointer_width = "64", any(target_os = "freebsd", target_os = "dragonfly")))]
        fds_bits: [i64; FD_SETSIZE / 64],
        #[cfg(not(all(target_pointer_width = "64", any(target_os = "freebsd", target_os = "dragonfly"))))]
        fds_bits: [i32; FD_SETSIZE / 32],
    }

...

    pub fn FD_SET(fd: ::c_int, set: *mut fd_set) -> () {
        let bits = ::mem::size_of_val(&(*set).fds_bits[0]) * 8;
        let fd = fd as usize;
        (*set).fds_bits[fd / bits] |= 1 << (fd % bits);
        return
    }

i64もしくはi32の配列として表現し,それを単にビット列として扱うことで,ファイルディスクリプタの数値番目のビットをそのファイルディスクリプタのフラグとして使うというようになっている.

なのだが,実はこれには少し問題があって,この実装を見ればわかるように,ファイルディスクリプタの値が1024以上だったときはエラーとなる.じゃあmacOSでファイルディスクリプタの値が1024以上になることがあるのかという話なのだが,厳密にはある. macOSは,デフォルトの状態だとファイルディスクリプタの値の上限は256なのだが,この値は変更可能である.軽く調べた感じだと,maxで524288まで設定できるっぽい.なので,pselectよりもppollが推奨されることが多いらしい.

なのだが,libpnetpselectを使ってるので,今回はあえてpselectを使うことにした.

だいぶ話がずれてしまったので,そろそろ本筋に戻る.

6. 〜7.

続いて,6. 〜7. のコード.

pub struct DataLinkReceiver {
    fd: libc::c_int,
    fd_set: libc::fd_set,
    read_buffer: Vec<u8>,
    packets: VecDeque<(usize, usize)>,
}

impl DataLinkReceiver {
    pub fn next(&mut self) -> io::Result<&[u8]> {
        if self.packets.is_empty() {
            // 6. BPFファイルを監視.read可能になったらreadする.
            let buffer = &mut self.read_buffer;
            let ret = unsafe {
                libc::FD_SET(self.fd, &mut self.fd_set as *mut libc::fd_set);
                libc::pselect(self.fd + 1, &mut self.fd_set as *mut libc::fd_set, ptr::null_mut(), ptr::null_mut(), ptr::null(), ptr::null(),)
            };
            if ret <= 0 {
                return Err(io::Error::last_os_error());
            }
            let buflen = match unsafe {
                libc::read(
                    self.fd,
                    buffer.as_ptr() as *mut libc::c_void,
                    buffer.len() as libc::size_t,
                )
            } {
                len if len > 0 => len,
                _ => return Err(io::Error::last_os_error()),
            };
            // 7. パケット解析.(BPFパケット)
            let mut ptr: *mut u8 = buffer.as_mut_ptr();
            let end = unsafe { buffer.as_ptr().offset(buflen as isize) };
            while (ptr as *const u8) < end {
                unsafe {
                    let bpf_packet: *const bpf::bpf_hdr = mem::transmute(ptr);
                    let start = ptr as isize + (*bpf_packet).bh_hdrlen as isize - buffer.as_ptr() as isize;
                    self.packets.push_back((start as usize, (*bpf_packet).bh_caplen as usize));
                    ptr = ptr.offset(bpf::BPF_WORDALIGN((*bpf_packet).bh_hdrlen as isize + (*bpf_packet).bh_caplen as isize));
                }
            }
        }
        let (start, len) = self.packets.pop_front().unwrap();

        Ok(&self.read_buffer[start..start + len])
    }
}

DataLinkReceive::next関数は,受信したEthernetパケットを1つ返す関数である. 先ほど説明したpselect関数により,read可能になるまで待ってreadする.

"7. パケット解析.(BPFパケット)"

の処理の部分を説明する前に気をつけたいことがあるのでそれについてまず触れる.

BPFファイルreadで取得できるデータの中身

BPFファイルreadで取得したデータの中身はBPFパケットというものである.注意すべきなのは,連続する複数のBPFパケットである可能性があるということである.

BPFパケット

BPFパケットは,| BPFヘッダ + ペイロード |という形式になっている. BPFヘッダはUnixカーネルのソースで以下のようになっている.

struct bpf_hdr {
    struct timeval bh_tstamp;  /* time stamp */
    u_long      bh_caplen;  /* length of captured portion */
    u_long      bh_datalen; /* original length of packet */
    u_short     bh_hdrlen;  /* length of bpf header (this struct
                      plus alignment padding) */
};

https://unix.superglobalmegacorp.com/Net2/newsrc/net/bpf.h.html

bh_hdrlenメンバがヘッダの長さ,bh_caplenメンバがペイロードの長さなので,ここの値を使って連続するBPFパケットの分かれ目を判断することができる.

7.

先ほどのコードの説明に戻る. read_bufferメンバには,BPFファイルreadで取得したデータの中身が入るので,この中には複数のBPFパケットがある(可能性がある).しかし,DataLinkReceive::next関数自体は受信したEthernetパケットを1つ返す関数なので,パケット1つだけを返して,続きはまた次回呼ばれたときに返すということになる.なので,read_bufferの中に含まれる全パケットを返し終わったら,またreadするという風になっている.

大体理解できたところで,次に,BPFパケットを実際に解析する部分の説明をする. パケット解析の処理は,先ほどのコードの,この1行(3行)である.

unsafe {
    let bpf_packet: *const bpf::bpf_hdr = mem::transmute(ptr);
}

え,たった1行でできるの?って思う方もいるかもしれないので,この一応コードの説明をする. bpf::bpf_hdrというのは,以下に示す構造体である.

#[repr(C)]
pub struct bpf_hdr {
    pub bh_tstamp: i64; // timeval32,
    pub bh_caplen: u32,
    pub bh_datalen: u32,
    pub bh_hdrlen: libc::c_ushort,
}

見覚えがありますね.先ほどUnixカーネルのソースに出てきていたものと同じ構造になっている. つまり,先ほどのRustの1行のコードは,この構造体のインスタンスを生成しているということなんだが,代入している値が少し気になるかもしれない.

mem::transmute(ptr)

mem::transmute関数は,ある型の値のビットを別の型として再解釈するというもの. つまり,先ほどのコードでptrポインタが指すアドレスは,BPFパケットの先頭位置のアドレスなので,そこからbpf_hdrサイズ分のビットをbpf_hdrインスタンスとして解釈するということだ.そういう感じの処理なので当然unsafe

これでEthernetのパケットが取得できた!やった!!

と,言いたいところなのだが,なんと,libpnetのこの部分にバグがあった..

libpnetライブラリにバグを発見

ここで,bpf_hdr構造体の定義を再掲する.

#[repr(C)]
pub struct bpf_hdr {
    pub bh_tstamp: i64; // timeval32,
    pub bh_caplen: u32,
    pub bh_datalen: u32,
    pub bh_hdrlen: libc::c_ushort,
}

この#[repr(C)]アトリビュートは,C言語と同じようなメモリレイアウトにして!とコンパイラに伝えるためのものだ.どういうことかというと,上記のような構造体を定義した場合,Rustでは,定義したメンバの順番通りにメモリ上に配置されるとは限らないのだ.使用するメモリ領域をできるだけ削減するために,メモリ上での配置を,Rustコード上での順番から最適な順番にコンパイラが入れ替えることがある.これは,一見とても嬉しいことのように思える.

しかし,今回に限っては嬉しくない.先ほど説明したように,mem::transmute関数によって "bpf_hdrサイズ分のビットをbpf_hdrインスタンスとして解釈する" という風に使うからだ.メモリ上でのメンバの順番が勝手に入れ替わってしまうと,例えば,bh_hdrlenは本来は17Byte目以降の2Byteなのだが,その部分を指さなくなってしまう可能性がある.

つまり,今回に限っては最適化して欲しくないので,bpf_hdr構造体に#[repr(C)]アトリビュートをつけることで,順番の入れ替わりが起きないようにする必要がある.そして,libpnetのコードのbpf_hdr構造体の宣言部分には#[repr(C)]アトリビュートがなかった..

libpnetは,Rustのライブラリの中ではかなり重要(?)なライブラリに入ると思うので,少し興奮した(笑). issueを立てて1〜2時間ほどで実際に修正され,そのコードがmasterブランチにマージされた.めでたし. スクリーンショット 2020-10-10 18.17.32.png

8.

最後に

"8. パケット解析.(Ethernetパケット)"

だが,これは先ほどのDataLinkReceive::next関数から返ってきたパケットをEthernetの仕様を見ながら各フィールドの値を取得すればいいだけなので説明は省略する.

コードは冒頭のリンクから見れるので,気になる方はsrc/packet.rsを見てください.

参考文献

ルーター自作でわかるパケットの流れ ~ソースコードで体感するネットワークのしくみ https://www.ibm.com/support/knowledgecenter/ja/ssw_ibm_i_71/rzab6/poll.htm https://unix.superglobalmegacorp.com/Net2/newsrc/net/bpf.h.html https://unix.superglobalmegacorp.com/Net2/newsrc/net/if.h.html https://elixir.bootlin.com/linux/v4.20.17/source/include/uapi/asm-generic/ioctl.h http://www.yosbits.com/opensonar/rest/man/freebsd/man/ja/man4/bpf.4.html http://support.tenasys.com/intimehelp_6_jp/bpf.html https://www.infraexpert.com/study/ethernet4.html https://github.com/libpnet/libpnet https://github.com/rust-lang/libc https://nwpct1.hatenablog.com/entry/capture-packet-bpf-rawsocket http://hkou.hatenablog.com/entry/2016/08/27/181500 https://euniclus.com/article/rust-low-level-network/ https://www.atmarkit.co.jp/ait/articles/1811/21/news010.html https://www.atmarkit.co.jp/ait/articles/1910/07/news008.html https://qiita.com/sg-matsumoto/items/8194320db32d4d8f7a16 https://qiita.com/toshihirock/items/78286fccf07dbe6df38f https://qiita.com/fujinochan/items/2337ce48a998cf67966b