Zajęcia 5: BPF

Data: 29.03.2021

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. Najprostrzym 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, ślecedzeniem 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 5.16.5 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 intrumentację 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

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:

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

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 programow 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.

Przygotowany program BPF jest po stronie kernela 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.

Uruchamianie programów BPF

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.