Zajęcia 6: BPF¶
Data: 01.04.2025 Małe zadanie #5
Tip
Useful links:
eBPF on Linux -- a bit nicer than the above
BPF and XDP Reference Guide with technical details
Hands-on
For the labs today, you will need to download a prebuild kernel image (unless you have an image for the BPF large task already working):
https://students.mimuw.edu.pl/ZSO/PUBLIC-SO/vmlinuz-6.12.6zsobpf
https://students.mimuw.edu.pl/ZSO/PUBLIC-SO/initrd.img-6.12.6zsobpf
Boot the QEMU image by using these options apart from your usual ones:
-kernel vmlinuz-6.12.6zsobpf -initrd initrd.img-6.12.6zsobpf -append "root=/dev/sda3"
Then, install these dependencies on QEMU:
apt install clang clang-14 llvm pahole bpftool bpftrace bpfcc-tools libbpfcc libbpfcc-dev libbpf-dev
Use the superuser account for all the commands today.
Wprowadzenie¶
BPF (Berkeley Packet Filter) to technologia pozwalająca na dostarczanie programu filtrującego przez proces użytkownika. W skrócie, BPF umożliwia napisanie krótkich programów (nie będących modułami), które są wykonane w trybie jądra. Najprostszym przykładem programu BPF (dostępnym w "man 2 bpf") jest filtr zliczający pakiety TCP i UDP otrzymanych przez system operacyjny.
BPF ma wiele praktycznych zastosowań, związanych między innymi z bezpieczeństwem, śledzeniem i profilowaniem procesów, obsługą interfejsów sieciowych oraz monitorowaniem systemu [1]. Technologia ta zyskuje na popularności np. w 2019 roku Netflix używał domyślnie 15, a Facebook 40 programów BPF w produkcyjnym środowisku [3], co z kolei przekłada się na intensywny rozwój technologii w ostatnich latach - zawartość katalogu linux/kernel/bpf modyfikowało w 2021 prawie 400 commitów.
Jedną z niewątpliwych zalet BPF jest jego wysoka wydajność pozwalająca na to, by wykonywać niezbyt skomplikowany program dla każdego pakietu przy 10Gb/s bez wyraźnych opóźnień [5]. Należy jednak pamiętać, że programy BPF nie będą specjalnie szybsze od ich odpowiedników zaimplementowanych w kodzie jądra [6], a najważniejszą cechą programów BPF jest to, że umożliwiają uruchomienie kodu w trybie jądra z procesu użytkownika. Jak łatwo się domyślić operacja taka wymaga szczególnych środków ostrożności, dlatego programy BPF są uruchamiane w piaskownicy (ang. sandbox) po wcześniejszej weryfikacji, o czym więcej za chwilę.
Jeśli chodzi o nazewnictwo to skrót BPF pochodzi z publikacji "The BSD Packet Filter" [7] napisanej w 1992. W Linuxie 3.18 dodano extended BPF (eBPF) wspierającego m.in. 64-bitowe rejestry, a wcześniejszą wersję zaczęto nazywać cBPF (classic BPF). W tym momencie najczęściej technologię po prostu nazywa się BPF, chociaż w niektórych miejscach ciągle można spotkać się z użyciem nazwy eBPF [2].
Typy programów BPF¶
Programy BPF mogą być różnych typów, które szczegółowo specyfikuje "enum bpf_prog_type" w pliku include/uapi/linux/bpf.h
.
Jądro w wersji 6.12 zawiera ponad 30 typów z których niektóre ważniejsze to:
BPF_PROG_TYPE_SOCKET_FILTER
pozwalający na porzucanie lub skracanie pakietówBPF_PROG_TYPE_KPROBE
pozwalający na instrumentację funkcjiBPF_PROG_TYPE_XDP
pozwala zadecydować o losie pakietu na wczesnym etapie jego obsługi (zanim kosztowne operacje zostaną wykonane), co jest przydatne do ochrony przed atakami DDoS.BPF_PROG_TYPE_CGROUP_*
pozwalające dodatkowo zarządzać uprawnieniami cgroup
Tworzenie programów BPF¶
Programy BPF przypominają ASM, ale mają własny zbiór rejestrów i instrukcji. Do dyspozycji programisty jest 11 rejestrów: R0-R9 umożliwiających odczyt i zapis oraz R10 do odczytu adresu ramki (podobnie jak RBP w x86_64). Rejestry są modyfikowane przez liczne instrukcje [8], które umożliwiają między innymi:
operacje arytmetyczne (np.
BPF_ADD
,BPF_MUL
),skoki i wywołania funkcji (np.
BPF_JEQ
,BPF_JLE
,BPF_CALL
),wczytywanie i zapisywanie wartości (np.
BPF_LD
,BPF_ST
).
Jedną z możliwości na napisanie program BPF jest ręczne wykorzystanie struktury "struct bpf_insn" (tak jak w samples/bpf/bpf_insn.h
).
Ma to jednak oczywiste wady (tak samo jak pisanie programów w ASMie),
dlatego istnieją narzędzia umożliwiające pisanie programów BPF w językach programowania takich jak C, C++, Python czy Go, między innymi bcc oraz libbpf.
Jako, że programy BPF działają w piaskownicy, nie mogą (typowo) wywoływać dowolnych funkcji jądra i mają ograniczone możliwości interakcji ze światem -- dostępny jest ograniczony zbiór funkcji pomocniczych, które umożliwiają między innymi:
proste wypisywanie (
bpf_trace_printk
),pobieranie informacji o kontekście (np. bpf_get_current_uid_gid),
komunikację z przestrzenią użytkownika za pomocą różnego rodzaju tablic asocjacyjnych (
bpf_map_*
),wykonanie operacji specyficznych dla typu programu (np. odrzucenie pakietu),
wywoływanie innych programów BPF (
bpf_tail_call
).
Przygotowany program BPF jest po stronie jądra weryfikowany, a następnie kompilowany metodą JIT (just in time compilation) do kodu maszynowego.
Kod odpowiedzialny za kompilację dla architektury x86 znajduje się w pliku arch/x86/net/bpf_jit_comp.c
.
Po skompilowaniu program BPF może zostać uruchomiony.
Zazwyczaj, programowi BPF będzie towarzyszył program w przestrzeni użytkownika pośredniczący w komunikacji z nim.
Kernel Tracing¶
Linux has multiple facilities for tracing and observability what is happening in it. The most important parts pieces include tracepoints, ftrace, Kprobes. In short, they allow hooking (placing probes) at various places. Of special interest are dynamic traces, which allow hooking at runtime with virtually no overhead otherwise.
The idea of tracepoints is straightforward:
we explicitly place code checking if a probe is connected, and if so, call it with some arguments.
Function tracing with ftrace is a bit trickier, as we need help from the compiler to put a stub call at each function entry.
With dynamic ftrace on x86, you can notice a call to __fentry__
at almost every function.
(Check it yourself with objdump --disassemble=vfs_write vmlinux | less
)
The function entry hook is also used to place a function exit hook: we just need to replace the return pointer on the stack
with a pointer to a specially crafted trampoline.
As an extra optimization, the kernel will self-modify and replace these calls with NOPs until they are needed.
Kprobes are more powerful, as they allow hooking at individual instructions. In principle, it works by replacing the instruction at question with a breakpoint instruction to redirect the execution flow, then execute the instruction there along with registered probes, and return to the main flow.
Hands-on¶
Hands-on
First, check if you have enabled necessary kernel features with bpftool:
bpftool feature probe
bpftool
is developed alongside libbpf
in the main kernel tree.
bpftrace¶
bpftrace
is a tool enabling quick hacking a prototyping around BPF probe facilities.
Hands-on
You may check a list of all available probes with:
bpftrace -l
Go ahead and run your first BPF program with something like:
bpftrace -e 'kprobe:do_nanosleep { printf("PID %d sleeping...\n", pid); }'
Then execute sleep
in another terminal.
Keep the trace running, as we will examine it with bpftool:
bpftool prog list
will list currently installed BPF programs. You can see the BPF instructions (after initial translation by the kernel) with:
bpftool prog dump xlated id <id>
# or, in this specific case, just:
bpftool prog dump xlated name do_nanosleep
You cen see what maps are being used with bpftool map:
bpftool map
In this case, bpftrace uses perf_event_array
to implement its printf
.
You may read these events with:
bpftool map event_pipe id <id>
libbpf¶
Important
When building libbpf out-of-tree, you will need to provide it with information about non-stable functions/structures (such as when you modify the BPF facilities). You may extract these with:
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
Hands-on
Let's rewrite our probe with C:
#define BPF_NO_GLOBAL_DATA
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
SEC("kprobe/do_nanosleep")
int handle(void *ctx)
{
int pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("PID %d is sleeping", pid);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
And compile it with:
clang --target=bpf -g -Og -c example.bpf.c -o example.bpf.o
You may use llvm-readelf
and llvm-objdump
to inspect that file.
If you have a modern version of bpftool (e.g., compiled in linux-6.12.6/tools/bpf/bpftool
with make
),
you can just run:
bpftool prog load example.bpf.o /sys/fs/bpf/example autoattach
If your version does not support the 'autoattach' option yet, you will have to use libbpf for loading the program. The simplest way is to generate a skeleton file like:
bpftool gen skeleton example.bpf.o name example > example.skel.h
And write a loader file like:
#include <unistd.h>
#include "example.skel.h"
int main()
{
struct example *skel;
int err = 0;
skel = example__open();
if (!skel)
goto cleanup;
err = example__load(skel);
if (err)
goto cleanup;
err = example__attach(skel);
if (err)
goto cleanup;
pause();
cleanup:
example__destroy(skel);
return err;
}
Which may be compiled and executed with:
gcc example.user.c -o example.user -lbpf
./example.user
In either way, open the trace printk log with:
bpftool prog tracelog
And execute a sleep program in another terminal.
Programy BPF od strony jądra¶
Podstawowa ścieżka uruchomienia programu BPF rozpoczyna się od użycia funkcji bpf_prog_load
,
która otrzymuje typ programu BPF wraz z listą instrukcji.
Funkcja ta powoduje weryfikację oraz załadowanie programu, a następnie zwraca numer deskryptora pliku powiązanego z programem.
Deskryptor ten można następnie wykorzystać np. przekazując go jako argument funkcji setsocketopt
lub ioctl z requestem PERF_EVENT_IOC_SET_BPF
.
Weryfikacja programów BPF¶
Ponieważ program BPF jest dostarczany z programu użytkownika, a wykonany w trybie jądra, potrzebna jest dodatkowa weryfikacja poprawności programu, aby zapobiec zarówno nieuprawnionym dostępom do pamięci jak i przypadkowym błędom mogącym zawiesić cały system. Weryfikacja przebiega w dwóch etapach.
Pierwszy etap sprawdza między innymi:
Uprawnienia użytkownika (domyślnie tylko użytkownicy z
CAP_BPF
mogą ładować programy).Rozmiar programu (maksymalnie dopuszcza się
BPF_MAXINSNS
instrukcji, w naszej wersji to 4096).Obecność pętli. Od jądra 5.3 dopuszczone są ograniczone pętle (ang. bounded loops) dla których łatwo dowieść własność stopu.
Wywołania funkcji. Generalnie nie można wołać funkcji które nie należą do grupy BPF helpers.
Osiągalność wszystkich instrukcji.
Drugi etap jest bardziej skomplikowany.
Weryfikator zaczyna od pierwszej instrukcji programu i stara się zbadać wszystkie możliwe przebiegi programu,
jednocześnie weryfikując jego stan, zawartość rejestrów oraz operacje które są na nich wykonywane.
Do weryfikacji stanu jest użyta struktura bpf_reg_state
dostępna w include/linux/bpf_verifier.h
przechowująca między innymi typ wartości (bpf_reg_type
w include/linux/bpf.h
).
Wartość może mieć typ NOT_INIT
, SCALAR_VALUE
lub jeden z typów wskaźnika (np. PTR_TO_CTX
, PTR_TO_STACK
, PTR_TO_PACKET
).
Operacje na wskaźnikach mogą zmienić ich typ, np. dodając dwa PTR_TO_CTX
otrzymujemy SCALAR_VALUE
i od tego momentu nie możemy już pamięci spod tej wartości (mogłoby to umożliwić nieuprawniony dostęp do pamięci).
Przykłady¶
Implementację funkcji pomocniczych można znaleźć w bpf/helpers.c
.
Przy implementacji nowego typu programów warto wzorować się na innych, relatywnie prostych jak np. bpf/cgroup.c
.
Małe zadanie #5¶
Zaimplementuj program show_bt wyświetlający backtrace dla wywołań funkcji w kodzie jądra wykonanych przez ostatnie 5 sekund.
Przykładowo wywołanie ./show_bt vfs_write
podczas którego zostaną zapisane dane do pliku (przez inny proces) powinno wyświetlić na stdout backtrace (kodu wywołanego w trybie jądra) dla tego wykonania.
Jeśli podczas wykonania programu show_bt funkcja w kodzie jądra zostanie wykonana wiele razy i te wywołania generują różny backtrace, należy wypisać każdy z nich.
Do rozwiązania załącz informację dla jakich funkcji program nie działa. Dlaczego?
Wskazówka: można wykorzystać bcc, w szczególności pomocna może być funkcja attach_kprobe
.
Preparing for the Large Assignment¶
Build your own kernel image that will be able to run examples provided today.
You may start from the config provided for the Assignment 2: BPF compressibility analyzer or the one used to build this image config-6.12.6zsobpf
.
If you want to start from your, config, you need to enable several flags in various places.
In menuconfig
visit / and enable at least:
'General setup' -> 'BPF subsystem':
CONFIG_BPF=y
,CONFIG_BPF_SYSCALL=y
,CONFIG_BPF_JIT=y
, andCONFIG_BPF_EVENTS=y
'Kernel hacking' -> 'Tracers':
CONFIG_DYNAMIC_EVENTS=y
,CONFIG_KPROBES
,CONFIG_FUNCTION_TRACER
,CONFIG_DYNAMIC_FTRACE
,CONFIG_FPROBE
,CONFIG_FTRACE_SYSCALLS
,CONFIG_FPROBE_EVENTS
,CONFIG_KPROBE_EVENTS
for examples on this lab (kprobes)'General setup':
CONFIG_IKHEADERS=y
forbcc
Under 'Kernel hacking':
CONFIG_DEBUG_KERNEL
+CONFIG_DEBUG_INFO_BTF
-- enabling these will likely more than 1GB RAM during build
You may attempt to build the examples located at samples/bpf, however this will most likely fail unless you use their reference config. You may find the cilium guide useful here.
Readings and Extra Learning¶
There is a nice free book by Liz Rice available here: https://isovalent.com/books/learning-ebpf/
There is also a modern tutorial available here: https://github.com/eunomia-bpf/bpf-developer-tutorial
Referencje¶
[1] https://ebpf.io/
[2] https://www.brendangregg.com/blog/2021-06-15/bpf-internals.html
[3] https://www.brendangregg.com/blog/2019-12-02/bpf-a-new-type-of-software.html
[4] https://www.brendangregg.com/bpf-performance-tools-book.html
[5] https://kinvolk.io/blog/2020/09/performance-benchmark-analysis-of-egress-filtering-on-linux/
[6] https://pchaigno.github.io/ebpf/2020/09/29/bpf-isnt-just-about-speed.html
[7] https://www.usenix.org/legacy/publications/library/proceedings/sd93/mccanne.pdf
[8] https://www.kernel.org/doc/html/latest/bpf/instruction-set.html