Filtry pakietów Berkeley (BPF) to technologia jądra Linux, która od kilku lat nie schodzi z czołowych stron anglojęzycznych publikacji technicznych. Konferencje są przepełnione wystąpieniami na temat zastosowania i rozwoju BPF. David Miller, utrzymujący sieciowy subsystem Linux, mówi o swoim wystąpieniu na Linux Plumbers 2018 (XDP to jeden ze sposobów wykorzystania BPF). Brendan Gregg przedstawia wykłady pod tytułem . Toke Høiland-Jørgensen , że jądro to teraz microkernel. Thomas Graf promuje ideę, że .
Na Habrze nadal brakuje systematycznego opisu BPF, dlatego w serii artykułów postaram się przedstawić historię technologii, opisać architekturę i narzędzia programistyczne, a także zdefiniować obszary zastosowania i praktyki wykorzystywania BPF. W tym, zerowym, artykule cyklu opowiadam o historii i architekturze klasycznego BPF, a także ujawniam tajemnice zasad działania tcpdump, seccomp, strace, i wiele więcej.
Rozwój BPF jest kontrolowany przez społeczność sieciową Linuxa, a główne istniejące zastosowania BPF są związane z sieciami i dlatego, za pozwoleniem , nazwałem serię „BPF dla najmniejszych”, na cześć wspaniałej serii .
Krótki kurs historii BPF (c)
Współczesna technologia BPF to ulepszona i rozszerzona wersja starej technologii o tej samej nazwie, obecnie nazywanej, aby uniknąć zamieszania, klasycznym BPF. Na podstawie klasycznego BPF powstały znane narzędzie tcpdump, mechanizm seccomp, a także mniej znany moduł xt_bpf do iptables i klasyfikator cls_bpf. W nowoczesnym Linuxie klasyczne programy BPF są automatycznie transliterowane do nowej formy, jednak z punktu widzenia użytkownika API pozostało na miejscu, a nowe zastosowania klasycznego BPF, jak zobaczymy w tym artykule, są wciąż obecne. Z tego powodu, a także dlatego, że śledzenie historii rozwoju klasycznego BPF w Linuxie wyjaśni, jak i dlaczego ewoluowało do nowoczesnej formy, postanowiłem zacząć od artykułu na temat klasycznego BPF.
Pod koniec lat osiemdziesiątych XX wieku inżynierowie z znanego Laboratorium Lawrence Berkeley zainteresowali się kwestią, jak prawidłowo filtrować pakiety sieciowe na nowoczesnym wówczas sprzęcie. Podstawowa idea filtracji, zrealizowana początkowo w technologii CSPF (CMU/Stanford Packet Filter), polegała na tym, aby filtrować zbędne pakiety tak wcześnie, jak to możliwe, czyli w przestrzeni jądra, ponieważ pozwala to uniknąć kopiowania zbędnych danych do przestrzeni użytkownika. Aby zapewnić bezpieczeństwo wykonywania kodu użytkownika w przestrzeni jądra, użyto maszyny wirtualnej — piaskownicy.
Jednakże maszyny wirtualne dla istniejących filtrów zostały zaprojektowane do pracy na maszynach ze stosową architekturą i na nowych maszynach RISC działały mniej efektywnie. W rezultacie dzięki wysiłkom inżynierów z Berkeley Labs opracowano nową technologię BPF (Berkeley Packet Filters), architektura maszyny wirtualnej której była zaprojektowana na podstawie procesora Motorola 6502 — roboczego konia takich znanych produktów jak lub . Nowa maszyna wirtualna zwiększała wydajność filtrów dziesięciokrotnie w porównaniu do istniejących rozwiązań.
Architektura maszyny BPF
Poznamy architekturę na podstawie przykładów. Jednak na początku powiemy, że maszyna miała dwa dostępne dla użytkownika 32-bitowe rejestry, akumulator A i rejestr indeksowy X, 64 bajty pamięci (16 słów) dostępnej do zapisu i odczytu oraz mały zestaw instrukcji do pracy z tymi obiektami. W programach dostępne były również instrukcje skoków do realizacji warunkowych wyrażeń, jednakże w celu zapewnienia terminowego zakończenia programu, można było skakać tylko do przodu, tj. między innymi zabroniono tworzenia pętli.
Ogólna schemat uruchamiania maszyny jest następująca. Użytkownik tworzy program dla architektury BPF i, za pomocą jakiegoś mechanizmu jądra (na przykład wywołania systemowego), ładuje i dołącza program do jakiegoś generatora zdarzeń w jądrze (na przykład zdarzenie — to przyjście kolejnego pakietu na kartę sieciową). Po wystąpieniu zdarzenia jądro uruchamia program (na przykład w interpreterze), przy czym pamięć maszyny odpowiada jakiegoś regionu pamięci rdzenia (na przykład, dane otrzymanego pakietu).
To, co powiedzieliśmy powyżej, wystarczy, aby zacząć omawiać przykłady: zapoznamy się z systemem i formatem poleceń w razie potrzeby. Jeśli jednak chcesz od razu poznać system poleceń wirtualnej maszyny i dowiedzieć się o wszystkich jego możliwościach, możesz przeczytać oryginalny artykuł i/lub pierwszą połowę pliku z dokumentacji jądra. Oprócz tego można zapoznać się z prezentacją , w której McCanne, jeden z autorów BPF, opowiada o historii powstania libpcap.
Przechodzimy teraz do omówienia wszystkich istotnych przykładów zastosowania klasycznego BPF w systemie Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.
tcpdump
Rozwój BPF przebiegał równolegle z rozwojem front-endu do filtrowania pakietów — znanego narzędzia tcpdump. I ponieważ jest to najstarszy i najbardziej znany przykład wykorzystania klasycznego BPF, dostępny w wielu systemach operacyjnych, od niego rozpoczniemy naukę tej technologii.
(Wszystkie przykłady w tym artykule uruchomiłem na Linuxie 5.6.0-rc6. Wyjścia niektórych poleceń zostały edytowane w celu poprawy czytelności.)
Przykład: obserwujemy pakiety IPv6
Załóżmy, że chcemy monitorować wszystkie pakiety IPv6 na interfejsie eth0. W tym celu możemy uruchomić program tcpdump z najprostszym filtrem ip6:
$ sudo tcpdump -i eth0 ip6W tym przypadku tcpdump skompiluje filtr ip6 do kodu bajtowego architektury BPF i prześle go do jądra (zobacz szczegóły w sekcji ). Załadowany filtr będzie uruchamiany dla każdego pakietu przechodzącego przez interfejs eth0. Jeśli filtr zwróci niezerową wartość n, to n bajty pakietu zostaną skopiowane do przestrzeni użytkownika i zobaczymy je w wyjściu. tcpdump.

Okazuje się, że możemy łatwo dowiedzieć się, jaki dokładnie kod bajtowy został wysłany do jądra tcpdump za pomocą samego tcpdump, jeśli uruchomimy go z opcją -d:
$ sudo tcpdump -i eth0 -d ip6
(000) ldh [12]
(001) jeq #0x86dd jt 2 jf 3
(002) ret #262144
(003) ret #0W zerowej linii uruchamiamy polecenie ldh [12], które interpretuje się jako „załaduj do rejestru A pół-słowa (16 bitów), znajdującego się pod adresem 12” i jedyne pytanie — do jakiej pamięci się odnosimy? Odpowiedź brzmi, że pod adresem x zaczyna się (x+1)-ty bajt analizowanego pakietu sieciowego. Odbieramy pakiety z interfejsu Ethernet eth0, co oznacza , że pakiet wygląda następująco (dla uproszczenia zakładamy, że w pakiecie nie ma tagów VLAN):
6 6 2
|ADRES MAC docelowy|ADRES MAC źródłowy|Typ Ether|...|Oznacza to, że po wykonaniu polecenia ldh [12] w rejestrze A znajdzie się pole Typ Ether — typ przesyłanego w danej ramce Ethernet pakietu. W wierszu 1 porównujemy zawartość rejestru A (typ pakietu) z 0x86dd, co oznacza interesującym nas typem IPv6. W wierszu 1, oprócz polecenia porównania, znajdują się także dwie kolumny — jt 2 i jf 3 — znaczniki, na które należy przejść w przypadku udanego porównania (A == 0x86dd) i nieudanego. Zatem w przypadku sukcesu (IPv6) przechodzimy do wiersza 2, a w przypadku porażki — do wiersza 3. Wiersz 3 kończy program z kodem 0 (nie kopiuj pakietu), wiersz 2 kończy program z kodem 262144 (skopiuj mi maksymalnie 256 kilobajtów pakietu).
Przykład bardziej skomplikowany: przyjrzymy się pakietom TCP na porcie docelowym
Zobaczmy, jak wygląda filtr, który kopiuje wszystkie pakiety TCP z portem docelowym 666. Rozważamy przypadek IPv4, ponieważ przypadek IPv6 jest prostszy. Po przeanalizowaniu tego przykładu, jako ćwiczenie, możesz samodzielnie oznaczyć filtr dla IPv6 (ip6 and tcp dst port 666) oraz filtr dla ogólnego przypadku (tcp dst port 666). Zatem interesujący nas filtr wygląda następująco:
$ sudo tcpdump -i eth0 -d ip and tcp dst port 666
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 10
(002) ldb [23]
(003) jeq #0x6 jt 4 jf 10
(004) ldh [20]
(005) jset #0x1fff jt 10 jf 6
(006) ldxb 4*([14]&0xf)
(007) ldh [x + 16]
(008) jeq #0x29a jt 9 jf 10
(009) ret #262144
(010) ret #0Co robią wiersze 0 i 1, już wiemy. W wierszu 2 sprawdziliśmy, że to jest pakiet IPv4 (Typ Ether = 0x800) i ładujemy do rejestru A 24. bajt pakietu. Nasz pakiet wygląda jak
14 8 1 1
|nagłówek ethernetowy|pola ip|ttl|protokół|...|a zatem ładujemy do rejestru A pole Protokół nagłówka IP, co jest logiczne, ponieważ chcemy kopiować tylko pakiety TCP. Porównujemy Protokół z 0x6 () w wierszu 3.
W wierszach 4 i 5 ładowaliśmy półsłowa znajdujące się pod adresem 20, a za pomocą polecenia jset sprawdzamy, czy którykolwiek z trzech — w masce wydanej jset wyczyszczono trzy najwyższe bity. Dwa bity z trzech informują nas, czy pakiet jest częścią fragmentowanego pakietu IP, a jeśli tak, to czy jest ostatnim fragmentem. Trzeci bit jest zarezerwowany i powinien wynosić zero. Nie chcemy sprawdzać ani niepełnych, ani uszkodzonych pakietów, dlatego sprawdzamy wszystkie trzy bity.
Linia 6 to najbardziej interesująca w tym liście. Wyrażenie ldxb 4*([14]&0xf) oznacza, że ładujemy do rejestru X cztery najmniejsze bity piętnastego bajtu pakietu, pomnożone przez 4. Cztery najmniejsze bity piętnastego bajtu to pole nagłówka IPv4, w którym przechowywana jest długość nagłówka w słowach, dlatego później trzeba pomnożyć przez 4. Interesujące jest to, że wyrażenie 4*([14]&0xf) jest oznaczeniem specjalnej schemy adresacji, którą można używać tylko w takiej formie i tylko dla rejestru X, to znaczy, że nie możemy powiedzieć ani ldb 4*([14]&0xf) ani ldxb 5*([14]&0xf) (możemy tylko wskazać inny offset, na przykład, ldxb 4*([16]&0xf)). Oczywiste jest, że ta schema adresacji została dodana do BPF właśnie po to, aby uzyskać w X (rejestr indeksowy) długość nagłówka IPv4.
W ten sposób, w linii 7 próbujemy załadować półsłowa, pod adresem (X+16). Przypominając sobie, że 14 bajtów zajmuje nagłówek Ethernet, a X zawiera długość nagłówka IPv4, rozumiemy, że w A ładowany jest port docelowy TCP:
14 X 2 2
|nagłówek ethernetowy|nagłówek ip|port źródłowy|port docelowy|Na końcu, w linii 8 porównujemy port docelowy z poszukiwanym wartością i w liniach 9 lub 10 zwracamy wynik — kopiować pakiet czy nie.
Tcpdump: ładowanie
W poprzednich przykładach specjalnie nie zatrzymywaliśmy się szczegółowo na tym, jak dokładnie ładujemy kod bajtowy BPF do jądra w celu filtrowania pakietów. Ogólnie mówiąc, tcpdump jest portowany na wiele systemów i do pracy z filtrami używa biblioteki . Krótko mówiąc, aby ustawić filtr na interfejsie za pomocą libpcap, należy wykonać następujące kroki:
- utworzyć deskryptor typu
pcap_tz nazwy interfejsu: , - aktywować interfejs: ,
- skompilować filtr: ,
- podłączyć filtr: .
Aby zobaczyć, jak funkcja pcap_setfilter jest zaimplementowana w Linuxie, używamy strace (niektóre linie zostały usunięte):
$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768) = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...W pierwszych dwóch liniach wyjścia tworzymy do odczytu wszystkich ram Ethernetowych i wiążemy go z interfejsem eth0. Z wiemy, że filtr ip będzie składał się z czterech instrukcji BPF, a w trzeciej linii widzimy, jak za pomocą opcji wywołania systemowego setsockopt Ładujemy i podłączamy filtr długości 4. To jest nasz filtr.
Warto zauważyć, że w klasycznym BPF ładowanie i podłączanie filtru zawsze zachodzi jako operacja atomowa, podczas gdy w nowej wersji BPF ładowanie programu i jego przypisanie do generatora zdarzeń są rozdzielone w czasie.
Ukryta prawda
Nieco bardziej szczegółowa wersja wyjścia wygląda następująco:
$ sudo strace -f -e trace=%network tcpdump -p -i eth0 ip
socket(AF_PACKET, SOCK_RAW, 768) = 3
bind(3, {sa_family=AF_PACKET, sll_protocol=htons(ETH_P_ALL), sll_ifindex=if_nametoindex("eth0"), sll_hatype=ARPHRD_NETROM, sll_pkttype=PACKET_HOST, sll_halen=0}, 20) = 0
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=1, filter=0xbeefbeefbeef}, 16) = 0
recvfrom(3, 0x7ffcad394257, 1, MSG_TRUNC, NULL, NULL) = -1 EAGAIN (Zasób tymczasowo niedostępny)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...Jak wspomniano wcześniej, ładujemy i podłączamy nasz filtr do gniazda w linii 5, ale co dzieje się w linijkach 3 i 4? Okazuje się, że to libpcap zatroszczy się o nas — aby w wyjściu naszego filtru nie znalazły się pakiety, które mu nie odpowiadają, biblioteka fikcyjny filtr ret #0 (odrzucić wszystkie pakiety), przekształca gniazdo w tryb nieblokujący i próbuje odczytać wszystkie pakiety, które mogły pozostać od poprzednich filtrów.
W związku z tym, aby filtrować pakiety w systemie Linux przy użyciu klasycznego BPF, należy mieć filtr w postaci struktury typu struct sock_fprog i otwarte gniazdo, a następnie filtr można podłączyć do gniazda za pomocą wywołania systemowego setsockopt.
Ciekawe, że filtr można podłączać do dowolnego gniazda, nie tylko do surowego. Oto program, który odrzuca wszystko, z wyjątkiem dwóch pierwszych bajtów wszystkich przychodzących datagramów UDP. (Komentarze dodałem w kodzie, aby nie zaśmiecać artykułu.)
Więcej informacji na temat użycia setsockopt do podłączania filtrów, zobacz w , a o pisaniu własnych filtrów typu struct sock_fprog bez pomocy tcpdump porozmawiamy w rozdziale .
Klasyczny BPF i XXI wiek
BPF został włączony do systemu Linux w 1997 roku i przez długi czas pozostawał roboczym koniem libpcap bez większych zmian (oczywiście zmiany specyficzne dla Linuxa, , ale nie zmieniały ogólnego obrazu). Pierwsze poważne oznaki tego, że BPF będzie ewoluować, pojawiły się w 2011 roku, kiedy Eric Dumazet zaproponował , dodając do jądra kompilator Just In Time — tłumacz do przetwarzania bajtkodu BPF na natywny x86_64 kod.
Kompilator JIT był pierwszym w łańcuchu zmian: w 2012 roku możliwość pisania filtrów dla , używając BPF, powstała w styczniu 2013 roku. moduł xt_bpf, umożliwiający pisanie reguł dla iptables przy użyciu BPF, a w październiku 2013 roku pojawił się jeszcze moduł cls_bpf, pozwalający na pisanie klasyfikatorów ruchu przy użyciu BPF.
Wkrótce dokładniej przyjrzymy się tym wszystkim przykładom, ale najpierw przyda nam się nauczyć się pisać i kompilować dowolne programy dla BPF, ponieważ możliwości zapewniane przez bibliotekę libpcap są ograniczone (prosty przykład: filtr wygenerowany libpcap może zwrócić tylko dwie wartości — 0 lub 0x40000) lub w ogóle, jak w przypadku seccomp, są nieodpowiednie.
Programowanie BPF przy użyciu własnych rąk
Poznajmy binarny format instrukcji BPF, który jest bardzo prosty:
16 8 8 32
| kod | jt | jf | k |Każda instrukcja zajmuje 64 bity, w których pierwsze 16 bitów to kod komendy, następnie dwa ośmiobitowe odskoki, jt i jf, i 32 bity dla argumentu K, którego przeznaczenie zmienia się w zależności od komendy. Na przykład komenda ret, kończąca działanie programu, ma kod 6, a wartość zwracana pochodzi z konstantsy K. W języku C jedna instrukcja BPF reprezentowana jest jako struktura
struct sock_filter {
__u16 kod;
__u8 jt;
__u8 jf;
__u32 k;
}a cały program — jako struktura
struct sock_fprog {
unsigned short len;
struct sock_filter *filter;
}W ten sposób możemy już pisać programy (kody instrukcji zakładamy, że znamy z ). Oto jak będzie wyglądał filtr ip6 z :
struct sock_filter code[] = {
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 1, 0x000086dd },
{ 0x06, 0, 0, 0x00040000 },
{ 0x06, 0, 0, 0x00000000 },
};
struct sock_fprog prog = {
.len = ARRAY_SIZE(code),
.filter = code,
};Program prog możemy legalnie używać w wywołaniu
setsockopt(sk, SOL_SOCKET, SO_ATTACH_FILTER, &prog, sizeof(prog))Pisanie programów w postaci kodów maszynowych nie jest zbyt wygodne, ale czasem jest to konieczne (na przykład do debugowania, tworzenia testów jednostkowych, pisania artykułów na Habrze itp.). Dla wygody w pliku <linux/filter.h> zdefiniowane są makra pomocnicze — ten sam przykład, co powyżej, można by przepisać jako
struct sock_filter code[] = {
BPF_STMT(BPF_LD|BPF_H|BPF_ABS, 12),
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, ETH_P_IPV6, 0, 1),
BPF_STMT(BPF_RET|BPF_K, 0x00040000),
BPF_STMT(BPF_RET|BPF_K, 0),
}Jednak ten wariant również nie jest zbyt wygodny. Tak pomyśleli programiści jądra Linux, dlatego w katalogu jądra można znaleźć assembler i debugger do pracy z klasycznym BPF.
Język asemblera bardzo przypomina wyjście debugera tcpdump, ale dodatkowo możemy wskazywać etykiety symboliczne. Na przykład, oto program, który odrzuca wszystkie pakiety oprócz TCP/IPv4:
$ cat /tmp/tcp-over-ipv4.bpf
ldh [12]
jne #0x800, drop
ldb [23]
jneq #6, drop
ret #-1
drop: ret #0Domyślnie assembler generuje kod w formacie , ,..., w naszym przykładzie z TCP otrzymamy
$ tools/bpf/bpf_asm /tmp/tcp-over-ipv4.bpf
6,40 0 0 12,21 0 3 2048,48 0 0 23,21 0 1 6,6 0 0 4294967295,6 0 0 0,Dla wygody programistów C można użyć innego formatu wyjścia:
$ tools/bpf/bpf_asm -c /tmp/tcp-over-ipv4.bpf
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 3, 0x00000800 },
{ 0x30, 0, 0, 0x00000017 },
{ 0x15, 0, 1, 0x00000006 },
{ 0x06, 0, 0, 0xffffffff },
{ 0x06, 0, 0, 0000000000 },Ten tekst można skopiować do definicji struktury typu struct sock_filter, tak jak zrobiliśmy to na początku tego rozdziału.
Rozszerzenia Linuxa i netsniff-ng
Oprócz standardowych instrukcji BPF, Linux i tools/bpf/bpf_asm wspierają też . Głównie instrukcje służą do dostępu do pól struktury struct sk_buff, opisującej pakiet sieciowy w jądrze. Istnieją jednak również instrukcje pomocnicze innego rodzaju, na przykład ldw cpu załadował w rejestr A wynik wykonania funkcji jądra raw_smp_processor_id(). (W nowej wersji BPF te niestandardowe rozszerzenia zostały rozszerzone o zestaw kernel helpers umożliwiających dostęp do pamięci, struktur i generowania zdarzeń.) Oto ciekawy przykład filtra, w którym kopiujemy do przestrzeni użytkownika tylko nagłówki pakietów, używając rozszerzenia poff, offset ładunku:
ld poff
ret aRozszerzenia BPF nie mogą być używane w tcpdump, ale to dobry powód, by poznać pakiet narzędzi , który, oprócz innych rzeczy, zawiera zaawansowany program netsniff-ng, który, oprócz filtrowania za pomocą BPF, zawiera także wydajny generator ruchu oraz bardziej zaawansowany niż tools/bpf/bpf_asm, assembler BPF o nazwie bpfc. Pakiet zawiera dość szczegółową dokumentację, zob. także linki na końcu artykułu.
seccomp
A zatem, już umiemy pisać programy BPF o dowolnym stopniu skomplikowania i jesteśmy gotowi na nowe przykłady, z których pierwszym jest technologia seccomp, umożliwiająca za pomocą filtrów BPF zarządzanie wieloma oraz zestawem argumentów wywołań systemowych dostępnych dla danego procesu oraz jego potomków.
Pierwsza wersja seccomp została dodana do jądra w 2005 roku i nie cieszyła się dużą popularnością, gdyż oferowała jedynie jedną możliwość — ograniczenie liczby wywołań systemowych dostępnych dla procesu, a w tym przypadku: read, write, exit i sigreturn, a proces, który naruszył zasady, był kończony za pomocą SIGKILL. Jednak w 2012 roku do seccomp dodano możliwość używania filtrów BPF, które pozwalają definiować wiele dozwolonych wywołań systemowych, a nawet przeprowadzać kontrole ich argumentów. (Ciekawe, że jednym z pierwszych użytkowników tej funkcjonalności był Chrome, a obecnie zespół Chrome opracowuje mechanizm KRSI, bazujący na nowej wersji BPF, który umożliwia dostosowywanie modułów bezpieczeństwa Linuksa.) Linki do dodatkowej dokumentacji można znaleźć na końcu artykułu.
Zaznaczmy, że na Habrze już były artykuły na temat korzystania z seccomp, być może ktoś zechce je przeczytać przed (lub zamiast) czytania kolejnych podrozdziałów. W artykule podano przykłady użycia seccomp, zarówno wersji z 2007 roku, jak i wersji wykorzystującej BPF (filtry generowane za pomocą libseccomp), omawia się związki seccomp z Dockerem, a także podano wiele pomocnych linków. W artykule opisano między innymi, jak dodawać czarne lub białe listy wywołań systemowych dla demonów działających pod kontrolą systemd.
Następnie przyjrzymy się, jak pisać i ładować filtry dla seccomp w czystym C oraz przy użyciu biblioteki libseccomp i jakie są plusy i minusy każdej metody, a na koniec zobaczymy, jak seccomp jest używany przez program strace.
Piszemy i ładujemy filtry dla seccomp
Już umiejemy pisać programy BPF, więc najpierw przyjrzymy się interfejsowi programistycznemu seccomp. Filtr można ustawić na poziomie procesu, przy czym wszystkie procesy potomne dziedziczą te ograniczenia. Dzieje się to za pomocą wywołania systemowego :
seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)gdzie &filter — to wskaźnik do znanej nam struktury struct sock_fprog, t.j. programu BPF.
Czym różnią się programy dla seccomp od programów dla gniazd? Przekazywanym kontekstem. W przypadku gniazd przekazywano nam obszar pamięci zawierający pakiet, a w przypadku seccomp przekazywana jest struktura typu
struct seccomp_data {
int nr;
__u32 arch;
__u64 instruction_pointer;
__u64 args[6];
};Tutaj nr — to numer wywoływanego systemowego wywołania, arch — aktualna architektura (o tym poniżej), args — do sześciu argumentów wywołania systemowego, a instruction_pointer — to wskaźnik do instrukcji w przestrzeni użytkownika, która wywołała to wywołanie systemowe. Na przykład, aby załadować numer wywołania systemowego do rejestru A musimy powiedzieć
ldw [0]Dla programów seccomp istnieją inne cechy, na przykład dostęp do kontekstu możliwy jest tylko na 32-bitowym wyrównaniu i nie można ładować półsłowa ani bajtu — próba załadowania filtra ldh [0] wywołanie systemowe seccomp zwróci EINVAL. Sprawdzenie ładowanych filtrów wykonuje funkcja jądra. (Zabawne, w oryginalnym commitie, który dodawał funkcjonalność seccomp, zapomniano dodać zezwolenie na użycie instrukcji mod (reszta z dzielenia) i teraz jest niedostępna dla programów seccomp BPF, ponieważ jej dodanie ABI.)
W zasadzie już wiemy wszystko, co potrzebne do pisania i czytania programów seccomp. Zwykle logika programu jest zbudowana jako biały lub czarny lista wywołań systemowych, na przykład program
ld [0]
jeq #304, bad
jeq #176, bad
jeq #239, bad
jeq #279, bad
good: ret #0x7fff0000 /* SECCOMP_RET_ALLOW */
bad: ret #0sprawdza czarną listę czterech wywołań systemowych o numerach 304, 176, 239, 279. Cóż, jakie to wywołania systemowe? Nie możemy powiedzieć dokładnie, ponieważ nie wiemy, dla jakiej architektury napisano program. Dlatego autorzy seccomp powinni zaczynać wszystkie programy od sprawdzenia architektury (aktualna architektura jest podawana w kontekście jako pole arch struktury struct seccomp_data). Przy sprawdzeniu architektury początek przykładu wyglądałby następująco:
ld [4]
jne #0xc000003e, bad_arch ; SCMP_ARCH_X86_64a wtedy nasze numery wywołań systemowych miałyby określone wartości.
Pisanie i ładowanie filtrów dla seccomp za pomocą libseccomp
Pisanie filtrów w kodzie maszynowym lub dla assemblera BPF pozwala na pełną kontrolę nad wynikiem, ale czasem preferowane jest posiadanie przenośnego i/lub czytelnego kodu. W tym pomoże nam biblioteka , która zapewnia standardowy interfejs do pisania czarnych lub białych filtrów.
Na przykład napiszmy program, który uruchamia plik binarny wybrany przez użytkownika, ustawiając wcześniej czarną listę wywołań systemowych z (program uproszczony dla większej czytelności, pełna wersja dostępna jest ):
#include <seccomp.h>
#include <unistd.h>
#include <err.h>
static int sys_numbers[] = {
__NR_mount,
__NR_umount2,
// ... еще 40 системных вызовов ...
__NR_vmsplice,
__NR_perf_event_open,
};
int main(int argc, char **argv)
{
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
for (size_t i = 0; i < sizeof(sys_numbers)/sizeof(sys_numbers[0]); i++)
seccomp_rule_add(ctx, SCMP_ACT_TRAP, sys_numbers[i], 0);
seccomp_load(ctx);
execvp(argv[1], &argv[1]);
err(1, "execlp: %s", argv[1]);
}Na początku definiujemy tablicę sys_numbers z ponad 40 numerów wywołań systemowych do blokowania. Następnie inicjujemy kontekst ctx i informujemy bibliotekę, że chcemy zezwolić (SCMP_ACT_ALLOW) na wszystkie wywołania systemowe domyślnie (łatwiej budować czarne listy). Potem, jeden po drugim, dodajemy wszystkie wywołania systemowe z czarnej listy. W reakcji na wywołanie systemowe z listy żądamy SCMP_ACT_TRAP, w takim przypadku seccomp wyśle procesowi sygnał SIGSYS z opisem, które dokładnie wywołanie systemowe naruszyło zasady. Na koniec ładujemy program do jądra przy pomocy seccomp_load, który skompiluje program i podłączy go do procesu za pomocą wywołania systemowego seccomp(2).
Aby pomyślnie skompilować, program należy zlinkować z biblioteką libseccomp, na przykład:
cc -std=c17 -Wall -Wextra -c -o seccomp_lib.o seccomp_lib.c
cc -o seccomp_lib seccomp_lib.o -lseccompPrzykład pomyślnego uruchomienia:
$ ./seccomp_lib echo ok
okPrzykład zablokowanego wywołania systemowego:
$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Zły wywołanie systemoweUżywamy strace, aby uzyskać więcej informacji:
$ sudo strace -e seccomp ./seccomp_lib mount -t bpf bpf /tmp
seccomp(SECCOMP_SET_MODE_FILTER, 0, {len=50, filter=0x55d8e78428e0}) = 0
--- SIGSYS {si_signo=SIGSYS, si_code=SYS_SECCOMP, si_call_addr=0xboobdeadbeef, si_syscall=__NR_mount, si_arch=AUDIT_ARCH_X86_64} ---
+++ zabity przez SIGSYS (zrzut rdzenia) +++
Zły wywołanie systemoweskąd możemy się dowiedzieć, że program został zakończony z powodu użycia zabronionego wywołania systemowego mount(2).
W sumie, napisaliśmy filtr za pomocą biblioteki libseccomp, zmieszczając nietrywialny kod w czterech linijkach. W powyższym przykładzie, przy dużej liczbie wywołań systemowych, czas wykonania może znacznie się zmniejszyć, ponieważ sprawdzenie to po prostu lista porównań. Aby zoptymalizować, ostatnio w libseccomp wprowadzono , która dodaje wsparcie dla atrybutu filtra SCMP_FLTATR_CTL_OPTIMIZE. Jeśli ustawimy ten atrybut na 2, filtr zostanie przekształcony w program wyszukiwania binarnego.
Jeśli chcesz zobaczyć, jak działają filtry z wyszukiwaniem binarnym, rzuć okiem na , generujący takie programy w assemblerze BPF na podstawie zestawu numerów wywołań systemowych, na przykład:
$ echo 1 3 6 8 13 | ./generate_bin_search_bpf.py
ld [0]
jeq #6, bad
jgt #6, check8
jeq #1, bad
jeq #3, bad
ret #0x7fff0000
check8:
jeq #8, bad
jeq #13, bad
ret #0x7fff0000
bad: ret #0Nie da się napisać nic znacznie szybszego, ponieważ programy BPF nie mogą wykonywać skoków do względnych offsetów (nie możemy na przykład zrobić jmp A lub jmp [label+X]) i dlatego wszystkie skoki są statyczne.
seccomp i strace
Wszyscy znają narzędzie strace — niezastąpione narzędzie do badania zachowań procesów w systemie Linux. Jednak wielu użytkowników słyszało również o związanych z używaniem tego narzędzia. Problem polega na tym, że strace zrealizowano z pomocą ptrace(2), a w tym mechanizmie nie możemy dokładnie określić, na jakim zestawie wywołań systemowych chcemy zatrzymać proces, tzn. na przykład komendy
$ time strace du /usr/share/ >/dev/null 2>&1
real 0m3.081s
user 0m0.531s
sys 0m2.073si
$ time strace -e open du /usr/share/ >/dev/null 2>&1
real 0m2.404s
user 0m0.193s
sys 0m1.800swykonują się w mniej więcej tym samym czasie, chociaż w drugim przypadku chcemy śledzić tylko jedno wywołanie systemowe.
Nowa opcja --seccomp-bpf, dodana w strace wersji 5.3, pozwala znacznie przyspieszyć proces, a czas uruchamiania pod śledzeniem jednego wywołania systemowego staje się porównywalny z czasem zwykłego uruchamiania:
$ time strace --seccomp-bpf -e open du /usr/share/ >/dev/null 2>&1
real 0m0.148s
user 0m0.017s
sys 0m0.131s
$ time du /usr/share/ >/dev/null 2>&1
real 0m0.140s
user 0m0.024s
sys 0m0.116s(Oczywiście jest małe oszustwo w tym, że śledzimy nie podstawowe wywołanie systemowe tego polecenia. Gdybyśmy śledzili, na przykład, newfsstat, to strace , to działałoby równie wolno, jak bez --seccomp-bpf.)
Jak działa ta opcja? Bez niej strace podłącza się do procesu i uruchamia go z pomocą PTRACE_SYSCALL. Kiedy zarządzany proces wywołuje (jakiekolwiek) wywołanie systemowe, kontrola przechodzi strace, który sprawdza argumenty wywołania systemowego i uruchamia go z pomocą PTRACE_SYSCALL. Po pewnym czasie proces kończy wywołanie systemowe, a po jego zakończeniu kontrola z powrotem przechodzi strace, który sprawdza wartości zwracane i uruchamia proces z pomocą PTRACE_SYSCALL, itd.

Jednak dzięki seccomp ten proces można zoptymalizować dokładnie tak, jak byśmy chcieli. Tak więc, jeśli chcemy obserwować tylko wywołanie systemowe X, możemy napisać filtr BPF, który dla X zwraca wartość SECCOMP_RET_TRACE, a dla wywołań, które nas nie interesują — SECCOMP_RET_ALLOW:
ld [0]
jneq #X, ignore
trace: ret #0x7ff00000
ignore: ret #0x7fff0000W tym przypadku strace początkowo uruchamia proces jako PTRACE_CONT, na każde wywołanie systemowe wykonuje się nasz filtr, jeśli wywołanie systemowe nie jest X, to proces kontynuuje pracę, ale jeśli to X, seccomp przekaże kontrolę strace, który sprawdzi argumenty i uruchomi proces jako PTRACE_SYSCALL (ponieważ w seccomp nie ma możliwości uruchomienia programu w wyniku wywołania systemowego). Kiedy wywołanie systemowe powróci, strace zrestartuje proces przy pomocy PTRACE_CONT i będzie oczekiwał na nowe wiadomości od seccomp.

Przy użyciu opcji --seccomp-bpf są dwa ograniczenia. Po pierwsze, nie można dołączyć do już istniejącego procesu (opcja -p programu strace), ponieważ nie jest to wspierane przez seccomp. Po drugie, nie ma możliwości nie spojrzenia na procesy potomne, ponieważ filtry seccomp są dziedziczone przez wszystkie procesy potomne bez możliwości ich wyłączenia.
Nieco więcej szczegółów na temat tego, jak dokładnie strace działa z seccomp można znaleźć w . Dla nas najbardziej interesującym faktem jest to, że klasyczny BPF w postaci seccomp znajduje zastosowanie do dziś.
xt_bpf
Wróćmy teraz do świata sieci.
Tło: dawno temu, w 2007 roku, do jądra został dodany moduł xt_u32 dla netfilter. Został napisany na wzór jeszcze starszego klasyfikatora ruchu cls_u32 i pozwalał na pisanie dowolnych binarnych reguł dla iptables przy pomocy kilku prostych operacji: załaduj 32 bity z pakietu i przeprowadź na nich zestaw operacji arytmetycznych. Na przykład,
sudo iptables -A INPUT -m u32 --u32 "6&0xFF=1" -j LOG --log-prefix "seen-by-xt_u32"Ładuje 32 bity nagłówka IP, zaczynając od przesunięcia 6, i stosuje do nich maskę 0xFF (zatrzymaj najmłodszy bajt). To jest pole protocol nagłówka IP i porównujemy je z 1 (ICMP). W jednej regule można łączyć wiele sprawdzeń, a także można wykonać operator @ — przejść o X bajtów w prawo. Na przykład, reguła
iptables -m u32 --u32 "6&0xFF=0x6 && 0>>22&0x3C@4=0x29"sprawdza, czy numer sekwencyjny TCP 0x29. Nie będę już zgłębiać szczegółów, ponieważ jest już jasne, że ręczne pisanie takich reguł nie jest zbyt wygodne. W artykule , znajduje się kilka linków z przykładami użycia i generowania reguł dla xt_u32. Zobacz także linki na końcu tego artykułu.
Od 2013 roku zamiast modułu xt_u32 można używać modułu opartego na BPF. xt_bpfKażdy, kto doszedł do tego momentu, powinien już znać zasadę jego działania: uruchamianie bajtkodu BPF jako reguły iptables. Nową regułę można stworzyć, na przykład, w ten sposób:
iptables -A INPUT -m bpf --bytecode -j LOGtutaj <байткод> — to kod w formacie wyjściowym assemblera bpf_asm domyślnie, na przykład,
$ cat /tmp/test.bpf
ldb [9]
jneq #17, ignore
ret #1
ignore: ret #0
$ bpf_asm /tmp/test.bpf
4,48 0 0 9,21 0 1 17,6 0 0 1,6 0 0 0,
# iptables -A INPUT -m bpf --bytecode "$(bpf_asm /tmp/test.bpf)" -j LOGW tym przykładzie filtrujemy wszystkie pakiety UDP. Kontekst programu BPF w module xt_bpf, oczywiście, wskazuje na dane pakietu, w przypadku iptables — na początek nagłówka IPv4. Wartość zwrotna z programu BPF , gdzie false oznacza, że pakiet nie pasuje.
Oczywiście, że moduł xt_bpf obsługuje bardziej skomplikowane filtry niż w powyższym przykładzie. Spójrzmy na rzeczywiste przykłady od firmy Cloudfare. Do niedawna stosowali moduł xt_bpf w celu ochrony przed atakami DDoS. W artykule opisują, jak (i dlaczego) generują filtry BPF i publikują odnośniki do zestawu narzędzi do tworzenia takich filtrów. Na przykład, przy pomocy narzędzia bpfgen można stworzyć program BPF, który dopasowuje zapytanie DNS do nazwy habr.com:
$ ./bpfgen --assembly dns -- habr.com
ldx 4*([0]&0xf)
ld #20
add x
tax
lb_0:
ld [x + 0]
jneq #0x04686162, lb_1
ld [x + 4]
jneq #0x7203636f, lb_1
ldh [x + 8]
jneq #0x6d00, lb_1
ret #65535
lb_1:
ret #0W programie najpierw ładujemy do rejestru X adres początku ciągu x04habrx03comx00 wewnątrz datagramu UDP, a następnie sprawdzamy zapytanie: 0x04686162 "x04hab" itd.
Niedługo później Cloudfare opublikowało kod kompilatora p0f -> BPF. W artykule opisują, czym jest p0f i jak przekształcać podpisy p0f w BPF:
$ ./bpfgen p0f -- 4:64:0:0:*,0::ack+:0
39,0 0 0 0,48 0 0 8,37 35 0 64,37 0 34 29,48 0 0 0,
84 0 0 15,21 0 31 5,48 0 0 9,21 0 29 6,40 0 0 6,
...Obecnie Cloudfare już go nie używa, xt_bpfponieważ przeszli na XDP — jeden z wariantów użycia nowej wersji BPF, zob. .
cls_bpf
Ostatni z przykładów użycia klasycznego BPF w jądrze to klasyfikator cls_bpf dla podsystemu kontroli ruchu w Linuxie, dodany do Linuksa pod koniec 2013 roku i koncepcyjnie zastąpił starożytne cls_u32.
Jednakże nie będziemy teraz opisywać jego działania, cls_bpfponieważ z punktu widzenia wiedzy o klasycznym BPF nic nam to nie da — już poznaliśmy całą funkcjonalność. Ponadto w kolejnych artykułach, opisujących Extended BPF, jeszcze raz spotkamy się z tym klasyfikatorem.
Kolejnym powodem, dla którego nie opowiadać o użyciu klasycznego BPF, cls_bpf jest to, że w porównaniu do Extended BPF w tym przypadku znacznie zawęża się zakres zastosowań: klasyczne programy nie mogą zmieniać zawartości pakietów i nie mogą zachowywać stanu między wywołaniami.
Czas więc pożegnać się z klasycznym BPF i zajrzeć w przyszłość.
Pożegnanie z klasycznym BPF
Przyjrzeliśmy się temu, jak technologia BPF, opracowana na początku lat dziewięćdziesiątych, przez ćwierć wieku z powodzeniem znajdowała nowe zastosowania. Jednak, podobnie jak przejście z maszyn stakowych na RISC, które zapoczątkowało rozwój klasycznego BPF, w latach 2000-tych nastąpiło przejście z maszyn 32-bitowych na 64-bitowe, co doprowadziło do tego, że klasyczny BPF zaczął się dezaktualizować. Ponadto możliwości klasycznego BPF są mocno ograniczone i poza przestarzałą architekturą — nie mamy możliwości zachowywania stanu między wywołaniami programów BPF, nie mamy możliwości bezpośredniej interakcji z użytkownikiem, ani też interakcji z jądrem, poza odczytem ograniczonej liczby pól struktury. sk_buff i uruchamiania najprostszych funkcji pomocniczych, nie można zmieniać zawartości pakietów ani przekierowywać ich.
W rzeczy samej, obecnie z klasycznego BPF w Linuxie pozostał jedynie interfejs API, a wewnątrz jądra wszystkie klasyczne programy, takie jak filtry gniazdowe czy filtry seccomp, automatycznie są tłumaczone na nowy format, rozszerzony BPF. (Opowiemy o tym, jak dokładnie to przebiega w następnym artykule.)
Przejście na nową architekturę zaczęło się w 2013 roku, gdy Aleksiej Starowojtow zaproponował schemat aktualizacji BPF. W 2014 roku odpowiednie poprawki w jądrze. O ile wiem, początkowo planowano jedynie zoptymalizować architekturę i kompilator JIT dla bardziej efektywnej pracy na maszynach 64-bitowych, lecz zamiast tego te optymalizacje zapoczątkowały nowy rozdział w rozwoju Linuxa.
Kolejne artykuły w tej serii opowiedzą o architekturze i zastosowaniach nowej technologii, pierwotnie znanej jako internal BPF, później extended BPF, a obecnie po prostu BPF.
Linki
- Steven McCanne i Van Jacobson, "The BSD Packet Filter: A New Architecture for User-level Packet Capture",
https://www.tcpdump.org/papers/bpf-usenix93.pdf - Steven McCanne, "libpcap: An Architecture and Optimization Methodology for Packet Capture",
https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf tcpdump,libpcap:- .
- BPF — zapomniany bajt kodu:
https://blog.cloudflare.com/bpf-the-forgotten-bytecode/ - Wprowadzenie do narzędzia BPF:
https://blog.cloudflare.com/introducing-the-bpf-tools/ bpf_cls:http://man7.org/linux/man-pages/man8/tc-bpf.8.html- Przegląd seccomp:
https://lwn.net/Articles/656307/ https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst- Paul Chaignon, "strace —seccomp-bpf: spojrzenie pod maskę",
https://fosdem.org/2020/schedule/event/debugging_strace_bpf/ netsniff-ng:http://netsniff-ng.org/
Źródło: habr.com
