傳統的封包過濾方法,需要先將 kernel 的封包複製進 userspace,再進一步於 userspace 分析封包(使用 tap)。然而這個複製的過程 cost 是很高的。此外,傳統的封包過濾演算法效能也欠佳。
直到 1992 年 Steven McCanne 和 Van Jacobson 發表的論文 The BSD Packet Filter: A New Architecture for User-level Packet Capture 中,提出了 Berkeley Packet Filter (BPF),避免了把無用的封包複製到 userspace 的過程。形式上說來,BPF 就是一個跑在 kernel 中的虛擬機。因此,我們得以把 userspace 的程式轉換成 byte code(一個虛擬的指令集) 注入到 kernel space 中執行。
一些無聊的事:
這裡也可以看到 BPF 的原本應該是叫 BSD Packet Filter,也就是說機制原本是實現在 BSD 系統上的。不過似乎隨著 BPF 漸漸被不同作業系統納入,名稱就輾轉變成了 Berkeley Packet Filter 了 XD
後來 Linux 也引入了 BPF,在某些應用上被使用(例如 tcpdump)。並且,BPF 也有了加速用的 Just-In-Time (jit) 編譯器。
之後,BPF 被擴展至網路以外的領域,而可以被應用在 kernel 效能分析,那便是 eBPF(extended BPF),而原本我們所說的 BPF 則被可以被改稱為 cBPF(classic BPF)
BPF 的設計如圖。經過 data link layer 層的封包會額外傳遞一份給 BPF,在 kernel 先行過濾後再複製所要求的封包到 userspace。
除了在 kernel space 就先行過濾來提升效能以外,BPF 的 filter 架構也有學問。一般而言,filter 架構可以分成 boolean expression tree 或者 directed acyclic control flow graph(或稱 CFG) 兩種。在樹狀結構中,每個節點都表示一個 boolean operaiton(and、or 等,如下圖所示)
在 CFG 中,節點則是表示對一個封包欄位的判別(若為 true 就往右分支走,反之向左),並用兩個終點的 leaves 表示通過 filter 或否。(等價於前面 boolean expression tree 的 CFG 如下圖)
可以從圖中看到 CFG 會需要較少的 parsing 次數,因此 BPF 透過 CFG 的 filter 設計來提升效能。
BPF 的詳細請參照 reference,暫時只先研究部份內容。
另外,我感覺自己有些一知半解,因此如果有發現用詞不精準的地方都歡迎指教!
eBPF 基於 BPF 可將使用者定義的程式注入 kernel 中的架構做出了更多改進,其設計得以利用現代硬體的優勢。
其整體架構大致可以如下表示:
eBPF 理所當然的可以如 cBPF 做到過濾封包。然而實際上 eBPF 可以做得還遠遠不只這些! eBPF 可以把你的程式注入到 kernel 中,將其嵌入到任何的目標程式路徑上,一旦這些路徑被走過,便會執行注入的程式。因此 eBPF 可以做到:
等等更多
要在 linux 中實現 bpf,可以通過 bpf() 這個 system call
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
union bpf_attr {
struct { /* Used by BPF_MAP_CREATE */
__u32 map_type;
__u32 key_size; /* size of key in bytes */
__u32 value_size; /* size of value in bytes */
__u32 max_entries; /* maximum number of entries
in a map */
};
struct { /* Used by BPF_MAP_*_ELEM and BPF_MAP_GET_NEXT_KEY
commands */
__u32 map_fd;
__aligned_u64 key;
union {
__aligned_u64 value;
__aligned_u64 next_key;
};
__u64 flags;
};
struct { /* Used by BPF_PROG_LOAD */
__u32 prog_type;
__u32 insn_cnt;
__aligned_u64 insns; /* 'const struct bpf_insn *' */
__aligned_u64 license; /* 'const char *' */
__u32 log_level; /* verbosity level of verifier */
__u32 log_size; /* size of user buffer */
__aligned_u64 log_buf; /* user supplied 'char *'
buffer */
__u32 kern_version;
/* checked when prog_type=kprobe
(since Linux 4.1) */
};
} __attribute__((aligned(8)));
在 linux/bpf.h 中有定義 cmd 的 macro
等等
由於 eBPF 需要追蹤並統計 kernel 中的統計資訊,因此需要一個 eBPF Maps 資料結構來記錄。eBPF Maps 使用 key-value 的方式儲存數據, 可以透過 cmd = BPF_MAP_CREATE 建立,並得到一個 file descriptor 的回傳。
map 會有以下 member:
透過 map 機制,無論是從使用者層級抑或核心內部都可存取,進而提升了效率。
直接撰寫 eBPF 的虛擬指令(bytecode)並非易事,所幸 LLVM Clang compiler 支援將 C 編譯成 byte code,再透過 bfs system call(cmd = BPF_PROG_LOAD) 將 byte code 載入。你可以透過 Clang 的 -target = bpf
參數將 C 編成 elf 格式,再透過 libbpf 函式庫解析並注入 kernel。
在 /samples/bpf 中有許多範例程式。
BPF Compiler Collection (BCC) 作為 IOVisor 子計畫被提出,讓進行 BPF 的開發只需要專注於 C 語言注入於核心的邏輯和流程,剩下的工作,包括編譯、解析 ELF、載入 BPF 代碼塊以及建立 map 等等基本可以由 BCC 一力承擔,無需多勞開發者費心。
如果是 Ubuntu 可以使用下面指令,其他系統或者怕有問題最好直接參考 Installing BCC。此外,建議從 source 安裝,比較不會有版本太舊的問題。
git clone https://github.com/iovisor/bcc.git
mkdir bcc/build; cd bcc/build
cmake ..
make
sudo make install
cmake -DPYTHON_CMD=python3 .. # build python3 binding
pushd src/python/
make
sudo make install
popd
也可以參考這位同學的筆記
紀錄一些途中有遇到到問題,不過基本上就是有缺套件,安裝一下就沒事了:
Unable to find clang libraries ->
sudo apt-get install libclang-dev
Could NOT find LibElf ->
sudo apt-get install libelf-dev
此外,執行 python 檔/相關指令時要記得要用 sudo! 如果忘記可能不會顯示權限不足而是其他問題。
Kprobes 範例
執行以下程式:
from bcc import BPF
prog = """
int hello(void *ctx) {
bpf_trace_printk("Hello, World!\\n");
return 0;
}
"""
b = BPF(text=prog)
b.attach_kprobe(event="__x64_sys_clone", fn_name="hello")
print ("PID MESSAGE")
b.trace_print(fmt="{1} {5}")
如果跟原本的程式相比,你會注意到 attach_kprobe
的 event
有做一些調整,這是因為原本的 sys_clone
在新的 Linux 版本中有更換命名,如果照原本的寫法會找不到 symbol。
證據是,如果輸入
> sudo cat /boot/System.map-`uname -r` | grep " sys_clone"
會看到沒有東西印出來,而
> sudo cat /boot/System.map-`uname -r` | grep " __x64_sys_clone"
則會有相同的 symbol。(注意 sys_clone 有個空白XD)
或者透過以下指令看看是否存在 sys_clone
這個 kernel symbol
$ cat /proc/kallsyms | grep sys_clone
程式的內容很淺顯易懂,就是每次 __x64_sys_clone
被執行的時候,印出 PID 跟 "Hello World" 訊息。
見文 BPF 的可移植性: Good Bye BCC! Hi CO-RE!。
BCC 雖然一定程度簡化了 BPF 的開發,但使用上仍有一些缺點。例如可移植性的限制、高度依賴 Clang/LLVM 帶來的負擔等,因此社群會更建議透過使用 CO-RE 技術的 libbpf 來進行 BPF 程式的開發。
一般而言,使用 CO-RE 進行的 BPF program 可區分為兩部份。一部份會編譯成 BPF bytecode,之後被載入至 kernel space 運行;另一部份則是執行於 userspace 的 BPF loader。其依賴於 libbpf,被用來將 BPF bytecode 載入到 kernel,並監視其狀態以在 userspace 進行對應行為。
其中要載入至核心的部份只能用 C 撰寫( * ),更嚴謹的說是受限制的 C 語法。其透過可以支援編譯出 BPF bytecode 的編譯器編譯後,產生 ELF 格式的二進位檔。而執行在 user space 的程式則有較多選擇,可以用 C/Python/Rust 等等語言撰寫。
可以參考以下文章
在開始前,需要安裝一些 dependency:
$ apt install clang llvm libelf1 libelf-dev zlib1g-dev
此外也會需要 bpftool:
$ git clone https://github.com/libbpf/bpftool.git
$ cd bpftool
$ git submodule update --init
$ cd src
$ make
$ sudo make install
接著,假設我們要建立一個簡單專案 hello
,可以遵循以下的步驟:
$ cargo new hello
hello
底下的 Cargo.toml
中添加 libbpf-cargo 和 libbpf-rs 的 dependency[dependencies]
libbpf-rs = "0.19"
[build-dependencies]
libbpf-cargo = "0.13"
build.rs
(檔案名稱必須是 build.rs
,參見 The Cargo Book - Build Scripts)use std::fs::create_dir_all;
use std::path::Path;
use libbpf_cargo::SkeletonBuilder;
const SRC: &str = "./src/bpf/hello.bpf.c";
/* Reference:
* - https://github.com/libbpf/libbpf-bootstrap/blob/master/examples/rust/tracecon/build.rs
*/
fn main() {
// FIXME: Is it possible to output to env!("OUT_DIR")?
std::env::set_var("BPF_OUT_DIR", "src/bpf/.output");
create_dir_all("src/bpf/.output").unwrap();
let skel = Path::new("src/bpf/.output/hello.skel.rs");
SkeletonBuilder::new()
.source(SRC)
.build_and_generate(&skel)
.expect("bpf compilation failed");
println!("cargo:rerun-if-changed={}", SRC);
}
vmlinux.h
,我們可以透過 bpftool
來產生,或者仿效 libbpf-rs 的範例 直接複製一份,但這作法可能須注意相容性的問題(?)bpftool btf dump file /sys/kernel/btf/vmlinux format c > src/bpf/vmlinux.h
src/bpf/hello.bpf.c
:SEC
用來將每個編譯出的 object 放到給定的 ELF section 中,這會影響 bytecode 被載入 kernel 的行為,例如 SEC("tracepoint/syscalls/sys_enter_execve")
,tracepoint
表示 BPF program 會追蹤 kernel 的特定事件,而具體的事件是 syscalls/sys_enter_execve
,我們可以透過 cat /sys/kernel/debug/tracing/available_events
知道有哪些事件是可以追蹤的$ sudo cat /sys/kernel/debug/tracing/available_events | grep sys_enter_execve
syscalls:sys_enter_execveat
syscalls:sys_enter_execve
tools/lib/bpf
這個路徑底下,例如 bpf_printk
可以用來印出 debug message// We must include this
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx) {
char msg[] = "Hello, World!";
bpf_printk("invoke bpf_prog: %s\n", msg);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
cargo build
,之後我們應該可以在 src/bpf/.output
路徑中找到 skel.rs
src
路徑下建立一個 main.rs
,透過 cargo build
再編譯一次use anyhow::Result;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::{thread, time};
#[path = "bpf/.output/hello.skel.rs"]
mod hello;
use hello::*;
/* Reference: https://www.sobyte.net/post/2022-07/c-ebpf/ */
fn main() -> Result<()> {
let hello_builder = HelloSkelBuilder::default();
/* Open BPF application */
let open_skel = hello_builder.open()?;
/* Load & verify BPF programs */
let mut skel = open_skel.load()?;
/* Attach tracepoint handler */
let _tracepoint = skel
.progs_mut()
.bpf_prog()
.attach_tracepoint("syscalls", "sys_enter_execve")?;
/* Run `sudo cat /sys/kernel/debug/tracing/trace_pipe` to
* see output of the BPF programs */
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
})?;
let ten_millis = time::Duration::from_millis(10);
while running.load(Ordering::SeqCst) {
/* trigger our BPF program */
eprint!(".");
thread::sleep(ten_millis);
}
Ok(())
}
sudo ./target/debug/hello-libbpf-rs
(需要 root 權限因為其會需要 setrlimit
)後,在另一個 shell 執行 sudo cat /sys/kernel/debug/tracing/trace_pipe
就可以找到我們注入的信息!$ sudo cat /sys/kernel/debug/tracing/trace_pipe
[sudo] password for rin:
sudo-11688 [004] d..31 1404.417057: bpf_trace_printk: invoke bpf_prog: Hello, World!
<...>-11690 [005] d..31 1408.100552: bpf_trace_printk: invoke bpf_prog: Hello, World!
^C
與 C 相比,使用 Rust 撰寫 eBPF code 的好處是得易於其完善的 build system,在專案管理上相對容易。例如 ebpf-strace
這個專案只需藉由 cargo 就可以取得所有依賴的套件、完整編譯並運行。