🐝

[Rust] eBPF で TCP/UDP 通信を監視するプログラムを作成した。

に公開

はじめに

以前、ICMP パケットをキャプチャについての記事を書いたところから、ネットワークへの興味が強まっています。
本記事では、eBPF を使用して TCP/UDP の通信を監視するプログラムを、Rust で開発していきます。

eBPFとは

eBPF(Extended Berkeley Packet Filter)は、Linuxカーネル内で安全に実行されるサンドボックス化されたプログラム環境です。ユーザースペースからカーネルに直接プログラムをロードし、JITコンパイルでネイティブコードに変換して高速に動作します。検証器によってプログラムの安全性がチェックされ、不正な動作やシステム障害のリスクを低減しています。
eBPFは、ネットワークのフィルタリングやシステムコールのトレースなど、幅広い用途に利用できます。

eBPF には、eBPFプログラムとユーザレベルのプログラムがあります。
eBPFプログラムは、バイトコードにコンパイルされます。
ユーザレベルのプログラムは、コンパイルされたバイトコードをカーネルにロードします。

Aya とは

Rust で eBPF のプログラムを開発するために、今回は Aya というライブラリを使用します。
他にも libbpf-rs などのライブラリがありますが、Aya を使用することで eBPF プログラムとユーザレベルのプログラムを、両方 Rust で書くことができます。

ここでは、eBPFの開発環境を準備するところから、プログラムを作成・実行するまでの説明を記載します。

eBPFの開発環境を準備する

OSなどのバージョンは以下の通りです。

  • Ubuntu 20.04.6 LTS
  • カーネル 5.15.167.4-microsoft-standard-WSL2
  • rustc 1.85.0 (4d91de4e4 2025-02-17)
  • cargo 1.85.0 (d73d2caf9 2024-12-31)

公式ドキュメントを参考に、開発環境を整えていきます。

rustup install stable
rustup toolchain install nightly --component rust-src
cargo install bpf-linker
cargo install cargo-generate

以上を実行して、必要なツールをインストールします。

プロジェクトの作成

以下を実行して、プロジェクトを作成することができます。

cargo generate https://github.com/aya-rs/aya-template

実行するとプロジェクト名を聞かれるので pcap と入力します。
その後、Which type of eBPF program? 表示されます。ここでは xdp を選びます。

その後、以下のような出力がでて、プロジェクトが作成されます。

$ cargo generate https://github.com/aya-rs/aya-template
⚠️   Favorite `https://github.com/aya-rs/aya-template` not found in config, using it as a git repository: https://github.com/aya-rs/aya-template
🤷   Project Name: pcap
🔧   Destination: /path/to/pcap ...
🔧   project-name: pcap ...
🔧   Generating template ...
✔ 🤷   Which type of eBPF program? · xdp
[ 1/20]   Done: .gitignore
[ 2/20]   Done: Cargo.toml
[ 3/20]   Done: README.md
[ 4/20]   Ignored: pre-script.rhai
[ 5/20]   Done: rustfmt.toml
[ 6/20]   Done: pcap/Cargo.toml
[ 7/20]   Done: pcap/build.rs
[ 8/20]   Done: pcap/src/main.rs
[ 9/20]   Done: pcap/src
[10/20]   Done: pcap
[11/20]   Done: pcap-common/Cargo.toml
[12/20]   Done: pcap-common/src/lib.rs
[13/20]   Done: pcap-common/src
[14/20]   Done: pcap-common
[15/20]   Done: pcap-ebpf/Cargo.toml
[16/20]   Done: pcap-ebpf/build.rs
[17/20]   Done: pcap-ebpf/src/lib.rs
[18/20]   Done: pcap-ebpf/src/main.rs
[19/20]   Done: pcap-ebpf/src
[20/20]   Done: pcap-ebpf
🔧   Moving generated files into: `/path/to/pcap`...
🔧   Initializing a fresh Git repository
✨   Done! New project created /path/to/pcap

以下のようにプロジェクト名のディレクトリができていると思います。

$ ls
pcap

作成されたディレクトリの中には、以下の3種の Rust プロジェクトが作成されています。

  • ユーザレベルのプログラムを書くプロジェクト
  • eBPFプログラムを書くプロジェクト
  • 上の2プロジェクトで共有するデータを定義するプロジェクト

作成されたディレクトリの中を見ると、以下のようになっています。

$ tree pcap
pcap
├── Cargo.toml
├── README.md
├── pcap              <---- ユーザレベルのプログラム
│   ├── Cargo.toml
│   ├── build.rs
│   └── src
│       └── main.rs
├── pcap-common       <---- eBPFとユーザレベルで共有のデータを定義
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── pcap-ebpf         <---- eBPFプログラム
│   ├── Cargo.toml
│   ├── build.rs
│   └── src
│       ├── lib.rs
│       └── main.rs
└── rustfmt.toml

ここから各プロジェクトのソースコードを記載していきます。

pcap-common

ここでは、eBPFプログラムとユーザレベルのプログラムが共有するデータを定義します。
今回は、以下の2つを定義しています。

  • PacketInfo : IPアドレス、ポートとプロトコルからなる構造体
  • Protocol : プロトコルを表す enum (今回は TCP/UDP のみ)

pcap-common/lib.rsのソースコードは以下の通りです。

#![no_std]

use core::{fmt, net::Ipv4Addr};

#[derive(Copy, Clone)]
pub enum Protocol {
    TCP,
    UDP,
}

#[repr(C)]
#[derive(Copy, Clone)]
pub struct PacketInfo {
    src_addr: Ipv4Addr,
    dst_addr: Ipv4Addr,
    src_port: u16,
    dst_port: u16,
    protocol: Protocol,
}

impl PacketInfo {
    pub fn new(
        src_addr: u32,
        dst_addr: u32,
        src_port: u16,
        dst_port: u16,
        protocol: Protocol,
    ) -> Self {
        PacketInfo {
            src_addr: Ipv4Addr::from(src_addr),
            dst_addr: Ipv4Addr::from(dst_addr),
            src_port,
            dst_port,
            protocol,
        }
    }
}

impl fmt::Display for PacketInfo {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "{}\t{}\t{}\t{}\t{}",
            self.src_addr,
            self.src_port,
            self.dst_addr,
            self.dst_port,
            match self.protocol {
                Protocol::TCP => "TCP",
                Protocol::UDP => "UDP",
            }
        )
    }
}

#[cfg(feature = "user")]
unsafe impl aya::Pod for PacketInfo {}

pcap-ebpf

ここでは、カーネルで動く eBPFプログラムを作成します。
以下を実行して、使用するライブラリを追加します。

cargo add network_types

Aya の公式サイトに xdp のプログラムの例があります。

https://aya-rs.dev/book/programs/xdp/#creating-the-ebpf-component

例のプログラムは特定のIPアドレスのパケットのみ Drop するものです。
例を参考にし、try_xdp_firewallにあたる関数を変更します。

変更した点としては、おもに以下の3点です。

  • 送信元、宛先の両方のIPアドレスを取得する
  • プロトコルがUDP, TCPの場合、ポート番号を取得する
  • ユーザレベルのプログラムとデータを共有するための EVENT を作成する。

pcap-ebpf/src/main.rsのソースコードは以下です。

#![no_std]
#![no_main]

use aya_ebpf::{
    bindings::xdp_action,
    macros::{map, xdp},
    maps::PerfEventArray,
    programs::XdpContext,
};
use core::mem;
use network_types::{
    eth::{EthHdr, EtherType},
    ip::{IpProto, Ipv4Hdr},
    tcp::TcpHdr,
    udp::UdpHdr,
};
use pcap_common::{PacketInfo, Protocol};

#[map]
static EVENTS: PerfEventArray<PacketInfo> = PerfEventArray::<PacketInfo>::new(0);

#[inline(always)]
fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
    let start: usize = ctx.data();
    let end: usize = ctx.data_end();
    let len: usize = mem::size_of::<T>();

    if start + offset + len > end {
        return Err(());
    }
    Ok((start + offset) as *const T)
}

#[xdp]
pub fn pcap(ctx: XdpContext) -> u32 {
    match try_pcap(ctx) {
        Ok(ret) => ret,
        Err(_) => xdp_action::XDP_ABORTED,
    }
}

fn try_pcap(ctx: XdpContext) -> Result<u32, ()> {
    let mut cursor: usize = 0;

    // eth -> ip
    let ethhdr: *const EthHdr = ptr_at(&ctx, cursor)?;
    cursor += EthHdr::LEN;
    if unsafe { (*ethhdr).ether_type } != EtherType::Ipv4 {
        return Ok(xdp_action::XDP_PASS);
    }

    // get IP address.
    let iphdr: *const Ipv4Hdr = ptr_at(&ctx, cursor)?;
    cursor += Ipv4Hdr::LEN;
    let src_addr: u32 = u32::from_be(unsafe { (*iphdr).src_addr });
    let dst_addr: u32 = u32::from_be(unsafe { (*iphdr).dst_addr });

    // ip -> tcp/udp
    let (src_port, dst_port, protocol) = match unsafe { (*iphdr).proto } {
        IpProto::Tcp => {
            let tcphdr: *const TcpHdr = ptr_at(&ctx, cursor)?;
            let source = u16::from_be(unsafe { (*tcphdr).source });
            let dest = u16::from_be(unsafe { (*tcphdr).dest });
            (source, dest, Protocol::TCP)
        }
        IpProto::Udp => {
            let udphdr: *const UdpHdr = ptr_at(&ctx, cursor)?;
            let source = u16::from_be(unsafe { (*udphdr).source });
            let dest = u16::from_be(unsafe { (*udphdr).dest });
            (source, dest, Protocol::UDP)
        }
        _ => return Ok(xdp_action::XDP_PASS),
    };

    let packet_info = PacketInfo::new(src_addr, dst_addr, src_port, dst_port, protocol);
    EVENTS.output(&ctx, &packet_info, 0);

    Ok(xdp_action::XDP_PASS)
}

#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

他のファイル(lib.rsなど)には変更はありません。

pcap

ここでは、ユーザレベルのプログラムを作成していきます。
今回は、eBPFプログラムからIPアドレス、ポート、プロトコルの情報を受け取り、表示する処理を書きます。

プロジェクト作成時に作られる main.rs に処理を加えます。
書き加えたのは以下の2点です。

  1. useのところに以下を加えて、使用するライブラリをインポートします。

    use anyhow::Context as _;
    use aya::{
        maps::AsyncPerfEventArray,
        programs::{Xdp, XdpFlags},
        util::online_cpus,
    };
    use clap::Parser;
    #[rustfmt::skip]
    use log::{debug, warn};
    use bytes::BytesMut;
    use pcap_common::PacketInfo;
    use tokio::{signal, task};
    
  2. main関数にeBPFプログラムから情報を受け取り、表示する処理を書きます。
    最初からあるWaiting for Ctrl-CExiting...の間に処理を加えます。

        println!("Waiting for Ctrl-C...");
        println!("S-Address\tS-Port\tD-Address\tD-Port\tProtocol");
    
        let mut perf_array = AsyncPerfEventArray::try_from(ebpf.take_map("EVENTS").unwrap())?;
    
        for cpu_id in online_cpus().map_err(|(_, e)| e)? {
            let mut buf = perf_array.open(cpu_id, None)?;
    
            task::spawn(async move {
                let mut buffers = (0..10)
                    .map(|_| BytesMut::with_capacity(1024))
                    .collect::<Vec<_>>();
    
                loop {
                    let events = buf.read_events(&mut buffers).await.unwrap();
                    for buf in buffers.iter_mut().take(events.read) {
                        let ptr = buf.as_ptr() as *const PacketInfo;
                        let packet_info = unsafe { ptr.read_unaligned() };
                        println!("{packet_info}");
                    }
                }
            });
        }
        ctrl_c.await?;
        println!("Exiting...");
    }
    

他のファイル(build.rsなど)には変更はありません。

プログラムを実行する

以下のコマンドで実行します。
root 権限で実行するため、--config オプションをつけています。

$ cargo run --release --config 'target."cfg(all())".runner="sudo -E"' -- --iface eth0

実行すると以下の表示されます。

Waiting for Ctrl-C...
S-Address       S-Port  D-Address       D-Port  Protocol

別の端末から curl google.com を実行してみると、以下のように表示されました。

Waiting for Ctrl-C...
S-Address       S-Port  D-Address       D-Port  Protocol
172.217.26.238  80      192.168.45.10   45460   TCP
172.217.26.238  80      192.168.45.10   45460   TCP
172.217.26.238  80      192.168.45.10   45460   TCP
172.217.26.238  80      192.168.45.10   45460   TCP

おわりに

実際にプログラムを作ってみることで、eBPF に入門することができたと思います。
#[no_std]のプログラムを作成したのは初めてでした。
println!()が使えないことが衝撃でした。

Discussion