Zajęcia 6: BPF

Data: 01.04.2025 Małe zadanie #5

Tip

Useful links:

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ów

  • BPF_PROG_TYPE_KPROBE pozwalający na instrumentację funkcji

  • BPF_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.

Dokumentacja funkcji pomocniczych: eBPF Docs, manpage

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, and CONFIG_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 for bcc

  • 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