BPF dla najmłodszych, część zero: klasyczny BPF

Berkeley Packet Filters (BPF) to technologia jądra Linuksa, która od kilku lat znajduje się na pierwszych stronach anglojęzycznych publikacji technicznych. Konferencje wypełnione są raportami na temat wykorzystania i rozwoju BPF. David Miller, opiekun podsystemu sieciowego Linuksa, wygłasza swoje wystąpienie na Linux Plumbers 2018 „Ta rozmowa nie dotyczy XDP” (XDP to jeden z przypadków użycia BPF). Brendan Gregg wygłasza prelekcje pt Supermocy Linux BPF. Toke Høiland-Jørgensen śmiechże jądro jest teraz mikrojądrem. Thomas Graf propaguje ideę, że BPF to JavaScript dla jądra.

Brakuje jeszcze systematycznego opisu BPF na temat Habré, dlatego w serii artykułów postaram się opowiedzieć o historii technologii, opisać architekturę i narzędzia programistyczne oraz nakreślić obszary zastosowań i praktyki wykorzystania BPF. Artykuł ten, zerowy z serii, opowiada historię i architekturę klasycznego BPF, a także odkrywa tajemnice zasad jego działania. tcpdump, seccomp, strace, i wiele więcej.

Rozwój BPF jest kontrolowany przez społeczność sieciową Linuksa, główne istniejące aplikacje BPF są związane z sieciami i dlatego za pozwoleniem @eukariota, nazwałem serię „BPF dla najmłodszych”, na cześć wspaniałej serii „Sieci dla najmłodszych”.

Krótki kurs historii BPF(c)

Nowoczesna technologia BPF jest ulepszoną i rozszerzoną wersją starej technologii o tej samej nazwie, obecnie nazywanej klasycznym BPF, aby uniknąć nieporozumień. Na bazie klasycznego BPF stworzono znane narzędzie tcpdump, mechanizm seccomp, a także mniej znane moduły xt_bpf dla iptables i klasyfikator cls_bpf. We współczesnym Linuksie klasyczne programy BPF są automatycznie tłumaczone na nową formę, jednak z punktu widzenia użytkownika API pozostało niezmienione i wciąż odkrywane są nowe zastosowania klasycznego BPF, jak zobaczymy w tym artykule. Z tego powodu, a także dlatego, że śledząc historię rozwoju klasycznego BPF w Linuksie, stanie się jaśniejsze, jak i dlaczego ewoluował on do swojej nowoczesnej formy, zdecydowałem się zacząć od artykułu o klasycznym BPF.

Pod koniec lat osiemdziesiątych ubiegłego wieku inżynierowie ze słynnego Lawrence Berkeley Laboratory zainteresowali się pytaniem, jak prawidłowo filtrować pakiety sieciowe na sprzęcie nowoczesnym jak na koniec lat osiemdziesiątych ubiegłego wieku. Podstawową ideą filtrowania, pierwotnie zaimplementowaną w technologii CSPF (CMU/Stanford Packet Filter), było jak najwcześniejsze filtrowanie zbędnych pakietów, tj. w przestrzeni jądra, ponieważ pozwala to uniknąć kopiowania niepotrzebnych danych do przestrzeni użytkownika. Aby zapewnić bezpieczeństwo środowiska uruchomieniowego podczas uruchamiania kodu użytkownika w przestrzeni jądra, wykorzystano maszynę wirtualną w trybie piaskownicy.

Jednakże maszyny wirtualne dla istniejących filtrów zostały zaprojektowane do działania na maszynach opartych na stosie i nie działały tak wydajnie na nowszych maszynach RISC. W efekcie, dzięki staraniom inżynierów z Berkeley Labs, opracowano nową technologię BPF (Berkeley Packet Filters), której architekturę maszyny wirtualnej zaprojektowano w oparciu o procesor Motorola 6502 – konia pociągowego tak znanych produktów jak Apple II lub NES. Nowa maszyna wirtualna zwiększyła wydajność filtrów kilkudziesięciokrotnie w porównaniu do istniejących rozwiązań.

Architektura maszyny BPF

Zapoznamy się z architekturą w sposób roboczy, analizując przykłady. Jednak na początek załóżmy, że maszyna miała dwa 32-bitowe rejestry dostępne dla użytkownika, akumulator A i rejestr indeksowy X, 64 bajty pamięci (16 słów), dostępne do zapisu i późniejszego odczytu oraz niewielki system poleceń do pracy z tymi obiektami. W programach dostępne były także instrukcje skoków służące do realizacji wyrażeń warunkowych, jednak aby zagwarantować terminowe zakończenie programu, skoki można było wykonywać jedynie do przodu, czyli w szczególności zakazano tworzenia pętli.

Ogólny schemat uruchamiania maszyny jest następujący. Użytkownik tworzy program dla architektury BPF i wykorzystując niektóre mechanizm jądra (taki jak wywołanie systemowe), ładuje i łączy program do niektórych do generatora zdarzeń w jądrze (przykładowo zdarzeniem jest przybycie kolejnego pakietu na kartę sieciową). Gdy wystąpi jakieś zdarzenie, jądro uruchamia program (na przykład w interpreterze), a pamięć maszyny odpowiada do niektórych obszar pamięci jądra (na przykład dane przychodzącego pakietu).

Powyższe wystarczy, abyśmy zaczęli przyglądać się przykładom: w razie potrzeby zapoznamy się z systemem i formatem poleceń. Jeśli chcesz od razu przestudiować system poleceń maszyny wirtualnej i poznać wszystkie jej możliwości, możesz przeczytać oryginalny artykuł Filtr pakietów BSD i/lub pierwszą połowę pliku Dokumentacja/sieć/filter.txt z dokumentacji jądra. Ponadto możesz przestudiować prezentację libpcap: Metodologia architektury i optymalizacji przechwytywania pakietów, w którym McCanne, jeden z autorów BPF, opowiada o historii stworzenia libpcap.

Przejdźmy teraz do rozważenia wszystkich znaczących przykładów użycia klasycznego BPF w systemie Linux: tcpdump (libpcap), sekundnik, xt_bpf, cls_bpf.

tcpdump

Rozwój BPF prowadzono równolegle z rozwojem frontendu do filtrowania pakietów – dobrze znanego narzędzia tcpdump. A ponieważ jest to najstarszy i najbardziej znany przykład wykorzystania klasycznego BPF, dostępnego w wielu systemach operacyjnych, od niego zaczniemy badanie tej technologii.

(Wszystkie przykłady w tym artykule uruchomiłem w systemie Linux 5.6.0-rc6. Dane wyjściowe niektórych poleceń zostały zmodyfikowane w celu zapewnienia lepszej czytelności.)

Przykład: obserwacja pakietów IPv6

Wyobraźmy sobie, że chcemy sprawdzić wszystkie pakiety IPv6 w interfejsie eth0. W tym celu możemy uruchomić program tcpdump z prostym filtrem ip6:

$ sudo tcpdump -i eth0 ip6

W tym przypadku, tcpdump kompiluje filtr ip6 do kodu bajtowego architektury BPF i wysłać go do jądra (szczegóły w sekcji Tcpdump: ładowanie). Załadowany filtr zostanie uruchomiony dla każdego pakietu przechodzącego przez interfejs eth0. Jeśli filtr zwróci wartość różną od zera n, potem do n bajty pakietu zostaną skopiowane do przestrzeni użytkownika i zobaczymy to na wyjściu tcpdump.

BPF dla najmłodszych, część zero: klasyczny BPF

Okazuje się, że łatwo możemy dowiedzieć się, który kod bajtowy został wysłany do jądra tcpdump przy pomocy 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      #0

W linii zerowej uruchamiamy polecenie ldh [12], co oznacza „załaduj do rejestru A pół słowa (16 bitów) umieszczonego pod adresem 12” i pytanie tylko o jaki rodzaj pamięci mamy do czynienia? Odpowiedź brzmi, że o godz x zaczyna się (x+1)bajt analizowanego pakietu sieciowego. Odczytujemy pakiety z interfejsu Ethernet eth0i to środkiże pakiet wygląda tak (dla uproszczenia zakładamy, że w pakiecie nie ma tagów VLAN):

       6              6          2
|Destination MAC|Source MAC|Ether Type|...|

Zatem po wykonaniu polecenia ldh [12] w rejestrze A będzie pole Ether Type — typ pakietu przesyłanego w tej ramce Ethernet. W linii 1 porównujemy zawartość rejestru A (rodzaj opakowania) c 0x86ddi to i mieć Typ, który nas interesuje to IPv6. W linii 1 oprócz polecenia porównania znajdują się jeszcze dwie kolumny - jt 2 и jf 3 — znaczniki, do których należy się udać, jeśli porównanie wypadnie pomyślnie (A == 0x86dd) i nieudane. Zatem w przypadku pomyślnym (IPv6) przechodzimy do linii 2, a w przypadku niepowodzenia do linii 3. W linii 3 program kończy się kodem 0 (nie kopiuj pakietu), w linii 2 program kończy się kodem 262144 (skopiuj mi pakiet o maksymalnej wielkości 256 kilobajtów).

Bardziej skomplikowany przykład: patrzymy na pakiety TCP według portu docelowego

Zobaczmy jak wygląda filtr kopiujący wszystkie pakiety TCP z portem docelowym 666. Rozważymy przypadek IPv4, ponieważ przypadek IPv6 jest prostszy. Po przestudiowaniu tego przykładu możesz samodzielnie wypróbować filtr IPv6 w ramach ćwiczenia (ip6 and tcp dst port 666) i filtr dla przypadku ogólnego (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      #0

Wiemy już, co robią linie 0 i 1. W linii 2 sprawdziliśmy już, że jest to pakiet IPv4 (typ Ether = 0x800) i załaduj go do rejestru A 24-ty bajt pakietu. Nasza paczka wygląda

       14            8      1     1
|ethernet header|ip fields|ttl|protocol|...|

co oznacza, że ​​ładujemy do rejestru A pole Protokół nagłówka IP, co jest logiczne, ponieważ chcemy kopiować tylko pakiety TCP. Porównujemy Protokół z 0x6 (IPPROTO_TCP) w linii 3.

W liniach 4 i 5 ładujemy półsłowa znajdujące się pod adresem 20 i używamy polecenia jset sprawdź, czy jeden z trzech jest ustawiony flagi - noszenie wydanej maseczki jset trzy najbardziej znaczące bity są usuwane. Dwa z trzech bitów mówią nam, czy pakiet jest częścią pofragmentowanego pakietu IP, a jeśli tak, to czy jest to ostatni fragment. Trzeci bit jest zarezerwowany i musi wynosić zero. Nie chcemy sprawdzać pakietów niekompletnych lub uszkodzonych, więc sprawdzamy wszystkie trzy bity.

Linia 6 jest najciekawsza w tym zestawieniu. Wyrażenie ldxb 4*([14]&0xf) oznacza, że ​​ładujemy do rejestru X najmniej znaczące cztery bity piętnastego bajtu pakietu pomnożone przez 4. Najmniej znaczące cztery bity piętnastego bajtu to pole Długość nagłówka internetowego Nagłówek IPv4, który przechowuje długość nagłówka słownie, więc trzeba następnie pomnożyć przez 4. Co ciekawe, wyrażenie 4*([14]&0xf) to oznaczenie specjalnego schematu adresowania, którego można używać tylko w tej formie i tylko dla rejestru X, tj. my też nie możemy powiedzieć ldb 4*([14]&0xf) ani ldxb 5*([14]&0xf) (możemy podać jedynie inne przesunięcie, np. ldxb 4*([16]&0xf)). Oczywiste jest, że ten schemat adresowania został dodany do BPF właśnie w celu odbioru X (rejestr indeksowy) Długość nagłówka IPv4.

Zatem w linii 7 próbujemy załadować pół słowa (X+16). Pamiętając, że 14 bajtów jest zajętych przez nagłówek Ethernet, i X zawiera długość nagłówka IPv4, rozumiemy, że w A Port docelowy TCP jest ładowany:

       14           X           2             2
|ethernet header|ip header|source port|destination port|

Na koniec w linii 8 porównujemy port docelowy z żądaną wartością, a w liniach 9 lub 10 zwracamy wynik – czy skopiować pakiet, czy nie.

Tcpdump: ładowanie

W poprzednich przykładach nie rozwodziliśmy się szczegółowo nad tym, jak dokładnie ładujemy kod bajtowy BPF do jądra w celu filtrowania pakietów. Ogólnie rzecz biorąc, tcpdump przeniesiony na wiele systemów i do pracy z filtrami tcpdump korzysta z biblioteki libpcap. W skrócie, aby umieścić filtr na interfejsie za pomocą libpcap, musisz wykonać następujące czynności:

Aby zobaczyć, jak działa funkcja pcap_setfilter zaimplementowane w Linuksie, 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ściowych tworzymy surowe gniazdo aby odczytać wszystkie ramki Ethernet i powiązać je z interfejsem eth0, z nasz pierwszy przykład wiemy, że filtr ip będzie składać się z czterech instrukcji BPF, a w trzeciej linii zobaczymy jak skorzystać z opcji SO_ATTACH_FILTER wywołanie systemowe setsockopt ładujemy i podłączamy filtr o długości 4. To jest nasz filtr.

Warto zaznaczyć, że w klasycznym BPF ładowanie i podłączanie filtra zawsze następuje jako operacja atomowa, natomiast w nowej wersji BPF ładowanie programu i powiązanie go z generatorem zdarzeń są rozdzielone w czasie.

Ukryta prawda

Nieco pełniejsza wersja wyników 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 (Resource temporarily unavailable)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

Jak wspomniałem powyżej, ładujemy i podłączamy nasz filtr do gniazda na linii 5, ale co dzieje się na liniach 3 i 4? Okazuje się, że to libpcap dba o nas - aby na wyjściu naszego filtra nie znalazły się pakiety, które go nie spełniają, biblioteka łączy atrapa filtra ret #0 (upuść wszystkie pakiety), przełącza gniazdo w tryb nieblokujący i próbuje odjąć wszystkie pakiety, które mogły pozostać z poprzednich filtrów.

W sumie, aby filtrować pakiety na Linuksie przy użyciu klasycznego BPF, trzeba mieć filtr w postaci struktury typu struct sock_fprog oraz otwarte gniazdo, po czym filtr można podłączyć do gniazda za pomocą wywołania systemowego setsockopt.

Co ciekawe, filtr można podłączyć do dowolnego gniazda, nie tylko surowego. Tutaj przykład program, który odcina wszystkie przychodzące datagramy UDP z wyjątkiem pierwszych dwóch bajtów. (Dodałem komentarze w kodzie, aby nie zaśmiecać artykułu.)

Więcej szczegółów na temat użytkowania setsockopt podłączanie filtrów, patrz gniazdo(7), ale o pisaniu własnych filtrów, np struct sock_fprog bez pomocy tcpdump porozmawiamy w dziale Programowanie BPF własnymi rękami.

Klasyczny BPF i XXI wiek

BPF został włączony do Linuksa w 1997 roku i przez długi czas pozostawał najważniejszym narzędziem libpcap bez żadnych specjalnych zmian (oczywiście zmiany specyficzne dla Linuksa, były, ale nie zmieniły one obrazu świata). Pierwsze poważne oznaki ewolucji BPF pojawiły się w 2011 roku, kiedy złożył ofertę Eric Dumazet łatka, który dodaje do jądra kompilator Just In Time - tłumacz do konwersji kodu bajtowego BPF na natywny x86_64 kod.

Kompilator JIT był pierwszym w łańcuchu zmian: w 2012 roku pojawił się możliwość pisania filtrów dla druga komp, przy użyciu BPF, w styczniu 2013 r. nie było dodany moduł xt_bpf, co pozwala na pisanie reguł dla iptables przy pomocy BPF, a w październiku 2013 r dodany także moduł cls_bpf, który umożliwia pisanie klasyfikatorów ruchu przy użyciu BPF.

Wkrótce przyjrzymy się wszystkim tym przykładom bardziej szczegółowo, ale najpierw przyda nam się nauka pisania i kompilowania dowolnych programów dla BPF, ponieważ możliwości zapewniane przez bibliotekę libpcap ograniczone (prosty przykład: wygenerowano filtr libpcap może zwrócić tylko dwie wartości - 0 lub 0x40000) lub ogólnie, jak w przypadku seccompa, nie mają zastosowania.

Programowanie BPF własnymi rękami

Zapoznajmy się z binarnym formatem instrukcji BPF, jest to bardzo proste:

   16    8    8     32
| code | jt | jf |  k  |

Każda instrukcja zajmuje 64 bity, z czego pierwsze 16 bitów to kod instrukcji, następnie znajdują się dwa ośmiobitowe wcięcia, jt и jfi 32 bity jako argument K, którego cel różni się w zależności od polecenia. Na przykład polecenie ret, co kończy program mający kod 6, a wartość zwracana jest pobierana ze stałej K. W C pojedyncza instrukcja BPF jest reprezentowana jako struktura

struct sock_filter {
        __u16   code;
        __u8    jt;
        __u8    jf;
        __u32   k;
}

a cały program ma formę struktury

struct sock_fprog {
        unsigned short len;
        struct sock_filter *filter;
}

Tym samym możemy już pisać programy (znamy np. kody instrukcji z [1]). Tak będzie wyglądał filtr ip6 z nasz pierwszy przykład:

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 rozmowie

setsockopt(sk, SOL_SOCKET, SO_ATTACH_FILTER, &prog, sizeof(prog))

Pisanie programów w postaci kodów maszynowych nie jest zbyt wygodne, ale czasami jest konieczne (na przykład do debugowania, tworzenia testów jednostkowych, pisania artykułów o Habré itp.). Dla wygody w pliku <linux/filter.h> zdefiniowano makra pomocnicze - ten sam przykład co powyżej można 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 ta opcja nie jest zbyt wygodna. Tak rozumowali programiści jądra Linuksa, a zatem w katalogu tools/bpf jądra, możesz znaleźć asembler i debugger do pracy z klasycznym BPF.

Język asemblera jest bardzo podobny do wyjścia debugowania tcpdump, ale dodatkowo możemy określić etykiety symboliczne. Na przykład, oto program, który odrzuca wszystkie pakiety z wyjątkiem protokołu TCP/IPv4:

$ cat /tmp/tcp-over-ipv4.bpf
ldh [12]
jne #0x800, drop
ldb [23]
jneq #6, drop
ret #-1
drop: ret #0

Domyślnie asembler generuje kod w formacie <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., w naszym przykładzie z TCP będzie to możliwe

$ 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 zastosować inny format wyjściowy:

$ 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 },

Tekst ten można skopiować do definicji struktury typu struct sock_filtertak jak to zrobiliśmy na początku tej sekcji.

Rozszerzenia dla Linuksa i netsniff-ng

Oprócz standardowego BPF, Linux i tools/bpf/bpf_asm wsparcie i zestaw niestandardowy. Zasadniczo instrukcje służą do uzyskiwania dostępu do pól struktury struct sk_buff, który opisuje pakiet sieciowy w jądrze. Istnieją jednak również inne typy instrukcji pomocniczych, na przykład ldw cpu zostanie załadowany do rejestru A wynik uruchomienia funkcji jądra raw_smp_processor_id(). (W nowej wersji BPF te niestandardowe rozszerzenia zostały rozszerzone, aby zapewnić programom zestaw pomocników jądra umożliwiających dostęp do pamięci, struktur i generowanie zdarzeń.) Oto ciekawy przykład filtra, w którym kopiujemy tylko nagłówki pakietów do przestrzeni użytkownika za pomocą rozszerzenia poff, przesunięcie ładunku:

ld poff
ret a

Nie można używać rozszerzeń BPF tcpdump, ale jest to dobry powód, aby zapoznać się z pakietem narzędzi netsniff-ng, który zawiera między innymi zaawansowany program netsniff-ng, który oprócz filtrowania za pomocą BPF zawiera także efektywny generator ruchu, w dodatku bardziej zaawansowany tools/bpf/bpf_asm, asembler BPF o nazwie bpfc. Pakiet zawiera dość szczegółową dokumentację, zobacz także linki na końcu artykułu.

druga komp

Zatem wiemy już, jak pisać programy BPF o dowolnej złożoności i jesteśmy gotowi przyjrzeć się nowym przykładom, z których pierwszym jest technologia seccomp, która pozwala za pomocą filtrów BPF zarządzać zbiorem argumentów wywołań systemowych dostępnych dla dany proces i jego potomkowie.

Pierwsza wersja seccomp została dodana do jądra w 2005 roku i nie była zbyt popularna, ponieważ udostępniała tylko jedną opcję - ograniczenie zestawu wywołań systemowych dostępnych dla procesu do następujących: read, write, exit и sigreturn, a proces, który naruszył zasady, został zabity przy użyciu SIGKILL. Jednak w 2012 roku seccomp dodał możliwość korzystania z filtrów BPF, umożliwiając zdefiniowanie zestawu dozwolonych wywołań systemowych, a nawet sprawdzenie ich argumentów. (Co ciekawe, Chrome był jednym z pierwszych użytkowników tej funkcjonalności, a ludzie z Chrome pracują obecnie nad mechanizmem KRSI opartym na nowej wersji BPF i umożliwiającym dostosowywanie modułów bezpieczeństwa Linuksa.) Linki do dodatkowej dokumentacji znajdziesz na końcu artykułu.

Zauważ, że na hubie pojawiły się już artykuły na temat używania seccomp, może ktoś będzie chciał je przeczytać przed (lub zamiast) przeczytaniem kolejnych podsekcji. W artykule Kontenery i zabezpieczenia: seccomp podaje przykłady użycia seccomp, zarówno wersji 2007, jak i wersji wykorzystującej BPF (filtry generowane są przy użyciu libseccomp), mówi o połączeniu seccomp z Dockerem, a także podaje wiele przydatnych linków. W artykule Izolowanie demonów za pomocą systemd lub „nie potrzebujesz do tego Dockera!” Omówiono w szczególności sposób dodawania czarnych i białych list wywołań systemowych dla demonów działających na platformie systemd.

Następnie zobaczymy, jak pisać i ładować filtry dla seccomp w gołym C i korzystając z biblioteki libseccomp i jakie są zalety i wady każdej opcji, i na koniec zobaczmy, jak program wykorzystuje seccomp strace.

Zapisywanie i ładowanie filtrów dla seccom

Wiemy już, jak pisać programy w BPF, więc przyjrzyjmy się najpierw interfejsowi programistycznemu seccomp. Możesz ustawić filtr na poziomie procesu, a wszystkie procesy podrzędne odziedziczą ograniczenia. Odbywa się to za pomocą wywołania systemowego seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

gdzie &filter - jest to wskaźnik do znanej nam już struktury struct sock_fprog, tj. programu BPF.

Czym programy dla seccomp różnią się od programów dla gniazd? Przekazany kontekst. W przypadku gniazd otrzymaliśmy obszar pamięci zawierający pakiet, a w przypadku seccomp otrzymaliśmy strukturę przypominającą

struct seccomp_data {
    int   nr;
    __u32 arch;
    __u64 instruction_pointer;
    __u64 args[6];
};

Tutaj nr to numer wywołania systemowego, które ma zostać uruchomione, arch - obecna architektura (więcej na ten temat poniżej), args - do sześciu argumentów wywołań systemowych, oraz instruction_pointer jest wskaźnikiem do instrukcji przestrzeni użytkownika, która wykonała wywołanie systemowe. Czyli np. wczytanie do rejestru numeru wywołania systemowego A musimy powiedzieć

ldw [0]

Istnieją inne funkcje programów seccomp, na przykład dostęp do kontekstu można uzyskać tylko poprzez wyrównanie 32-bitowe i nie można załadować połowy słowa ani bajtu - podczas próby załadowania filtra ldh [0] wywołanie systemowe seccomp wróci EINVAL. Funkcja sprawdza załadowane filtry seccomp_check_filter() jądra. (Zabawne jest to, że w oryginalnym zatwierdzeniu, które dodało funkcjonalność seccomp, zapomniano dodać pozwolenie na użycie instrukcji do tej funkcji mod (reszta z dzielenia) i jest teraz niedostępny dla programów seccomp BPF od czasu jego dodania złamie się ABI.)

W zasadzie wiemy już wszystko, aby pisać i czytać programy seccomp. Zwykle logika programu jest ułożona w postaci białej lub czarnej listy wywołań systemowych, na przykład programu

ld [0]
jeq #304, bad
jeq #176, bad
jeq #239, bad
jeq #279, bad
good: ret #0x7fff0000 /* SECCOMP_RET_ALLOW */
bad: ret #0

sprawdza czarną listę czterech wywołań systemowych o numerach 304, 176, 239, 279. Co to są za wywołania systemowe? Nie możemy tego powiedzieć na pewno, ponieważ nie wiemy, dla jakiej architektury program został napisany. Dlatego autorzy seccomp oferta uruchamiaj wszystkie programy ze sprawdzeniem architektury (bieżąca architektura jest wskazywana w kontekście jako pole arch struktury struct seccomp_data). Po zaznaczeniu architektury początek przykładu będzie wyglądał następująco:

ld [4]
jne #0xc000003e, bad_arch ; SCMP_ARCH_X86_64

a wtedy nasze numery wywołań systemowych otrzymają określone wartości.

Piszemy i ładujemy filtry dla seccomp za pomocą libseccomp

Pisanie filtrów w kodzie natywnym lub w asemblerze BPF pozwala mieć pełną kontrolę nad wynikiem, ale jednocześnie czasami lepiej jest mieć przenośny i/lub czytelny kod. Pomoże nam w tym biblioteka libseccomp, który zapewnia standardowy interfejs do pisania filtrów czarno-białych.

Napiszmy np. program uruchamiający wybrany przez użytkownika plik binarny, mając wcześniej zainstalowaną czarną listę wywołań systemowych z powyższy artykuł (program został uproszczony dla większej czytelności, dostępna jest pełna wersja tutaj):

#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]);
}

Najpierw definiujemy tablicę sys_numbers z ponad 40 numerów połączeń systemowych do zablokowania. Następnie zainicjuj kontekst ctx i powiedz bibliotece, na co chcemy pozwolić (SCMP_ACT_ALLOW) domyślnie wszystkie wywołania systemowe (łatwiej jest budować czarne listy). Następnie po kolei dodajemy wszystkie wywołania systemowe z czarnej listy. W odpowiedzi na wywołanie systemowe z listy prosimy SCMP_ACT_TRAP, w tym przypadku seccomp wyśle ​​sygnał do procesu SIGSYS z opisem, które wywołanie systemowe naruszyło zasady. Na koniec ładujemy program do jądra za pomocą seccomp_load, który skompiluje program i dołączy go do procesu za pomocą wywołania systemowego seccomp(2).

Aby kompilacja przebiegła pomyślnie, program musi być powiązany z biblioteką libseccompna przykład:

cc -std=c17 -Wall -Wextra -c -o seccomp_lib.o seccomp_lib.c
cc -o seccomp_lib seccomp_lib.o -lseccomp

Przykład udanego startu:

$ ./seccomp_lib echo ok
ok

Przykład zablokowanego wywołania systemowego:

$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Bad system call

Używamy stracedla szczegółów:

$ 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} ---
+++ killed by SIGSYS (core dumped) +++
Bad system call

skąd możemy wiedzieć, że program został zakończony z powodu użycia nielegalnego wywołania systemowego mount(2).

Napisaliśmy więc filtr, korzystając z biblioteki libseccomp, dopasowując nietrywialny kod do czterech linii. W powyższym przykładzie, jeśli istnieje duża liczba wywołań systemowych, czas wykonania może zostać zauważalnie skrócony, ponieważ kontrola jest tylko listą porównań. W celu optymalizacji ostatnio użyto libseccomp łatka w zestawie, który dodaje obsługę atrybutu filter SCMP_FLTATR_CTL_OPTIMIZE. Ustawienie tego atrybutu na 2 spowoduje przekształcenie filtru w program wyszukiwania binarnego.

Jeśli chcesz zobaczyć jak działają binarne filtry wyszukiwania, zajrzyj na prosty skrypt, który generuje takie programy w asemblerze BPF poprzez wybieranie systemowych numerów wywołań, 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 #0

Nie będziesz w stanie napisać niczego znacznie szybciej, ponieważ programy BPF nie mogą wykonywać skoków wcięć (nie możemy tego zrobić np. jmp A lub jmp [label+X]) i dlatego wszystkie przejścia są statyczne.

seccomp i strace

Każdy zna tę użyteczność strace jest niezbędnym narzędziem do badania zachowania procesów w systemie Linux. Jednak wielu też o tym słyszało problemy z wydajnością podczas korzystania z tego narzędzia. Fakt jest taki strace realizowane za pomocą ptrace(2), i w tym mechanizmie nie możemy określić przy jakim zestawie wywołań systemowych potrzebujemy zatrzymać proces, czyli np. komendach

$ time strace du /usr/share/ >/dev/null 2>&1

real    0m3.081s
user    0m0.531s
sys     0m2.073s

и

$ time strace -e open du /usr/share/ >/dev/null 2>&1

real    0m2.404s
user    0m0.193s
sys     0m1.800s

przetwarzane są mniej więcej w tym samym czasie, chociaż w drugim przypadku chcemy prześledzić tylko jedno wywołanie systemowe.

Nowa opcja --seccomp-bpfdodano do strace wersja 5.3 pozwala na wielokrotne przyspieszenie procesu, a czas uruchomienia pod śladem jednego wywołania systemowego jest już porównywalny z czasem zwykłego uruchomienia:

$ 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

(Tutaj oczywiście jest lekkie oszustwo polegające na tym, że nie śledzimy głównego wywołania systemowego tego polecenia. Gdybyśmy prześledzili np. newfsstatnastępnie strace hamowałby tak samo mocno, jak bez --seccomp-bpf.)

Jak działa ta opcja? Bez niej strace łączy się z procesem i uruchamia go za pomocą PTRACE_SYSCALL. Kiedy zarządzany proces wywołuje (dowolne) wywołanie systemowe, kontrola jest przekazywana do strace, który sprawdza argumenty wywołania systemowego i uruchamia je za pomocą PTRACE_SYSCALL. Po pewnym czasie proces kończy wywołanie systemowe i po wyjściu z niego sterowanie zostaje ponownie przekazane strace, który sprawdza zwrócone wartości i rozpoczyna proces za pomocą PTRACE_SYSCALL, i tak dalej.

BPF dla najmłodszych, część zero: klasyczny BPF

Jednak dzięki seccomp proces ten można zoptymalizować dokładnie tak, jak byśmy tego chcieli. Mianowicie, jeśli chcemy patrzeć tylko na wywołanie systemowe X, wówczas możemy napisać filtr BPF dla tego X zwraca wartość SECCOMP_RET_TRACE, a dla połączeń, które nas nie interesują - SECCOMP_RET_ALLOW:

ld [0]
jneq #X, ignore
trace: ret #0x7ff00000
ignore: ret #0x7fff0000

W tym przypadku strace początkowo rozpoczyna proces jako PTRACE_CONT, nasz filtr jest przetwarzany dla każdego wywołania systemowego, jeśli nie jest to wywołanie systemowe X, proces będzie kontynuowany, ale jeśli to X, następnie seccomp przekaże kontrolę stracektóry sprawdzi argumenty i rozpocznie proces w podobny sposób PTRACE_SYSCALL (ponieważ seccomp nie ma możliwości uruchomienia programu po wyjściu z wywołania systemowego). Kiedy wywołanie systemowe powróci, strace ponownie uruchomi proces za pomocą PTRACE_CONT i będę czekać na nowe wiadomości od seccomp.

BPF dla najmłodszych, część zero: klasyczny BPF

Podczas korzystania z opcji --seccomp-bpf istnieją dwa ograniczenia. Po pierwsze, nie będzie możliwości dołączenia do już istniejącego procesu (opcja -p программы strace), ponieważ nie jest to obsługiwane przez seccomp. Po drugie nie ma takiej możliwości nie spójrz na procesy potomne, ponieważ filtry seccomp są dziedziczone przez wszystkie procesy potomne bez możliwości wyłączenia tego.

Trochę więcej szczegółów jak dokładnie strace pracuje z seccomp można znaleźć od najnowszy raport. Dla nas najciekawszy jest fakt, że klasyczny BPF reprezentowany przez seccomp jest nadal używany.

xt_bpf

Wróćmy teraz do świata sieci.

Tło: dawno temu, w 2007 r., rdzeń był dodany moduł xt_u32 dla netfiltera. Został napisany przez analogię do jeszcze starszego klasyfikatora ruchu cls_u32 i umożliwił napisanie dowolnych reguł binarnych dla iptables przy użyciu następujących prostych operacji: załaduj 32 bity z pakietu i wykonaj 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 dopełnienia 6, i stosuje do nich maskę 0xFF (weź młodszy bajt). To pole protocol nagłówek IP i porównujemy go z 1 (ICMP). Możesz połączyć wiele kontroli w jedną regułę, możesz także wykonać operator @ — przesuń 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 nie jest równy 0x29. Nie będę wdawał się w szczegóły, bo już wiadomo, że ręczne pisanie takich reguł nie jest zbyt wygodne. W artykule BPF - zapomniany kod bajtowy, istnieje 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 r. moduł zamiast modułu xt_u32 możesz użyć modułu opartego na BPF xt_bpf. Każdy, kto doczytał aż do tego miejsca, powinien już mieć jasność co do zasady jego działania: uruchom kod bajtowy BPF jako reguły iptables. Możesz utworzyć nową regułę, na przykład w następujący sposób:

iptables -A INPUT -m bpf --bytecode <байткод> -j LOG

tutaj <байткод> - to jest kod w formacie wyjściowym asemblera bpf_asm domyślnie, np.

$ 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 LOG

W tym przykładzie filtrujemy wszystkie pakiety UDP. Kontekst dla programu BPF w module xt_bpfoczywiście wskazuje na dane pakietu, w przypadku iptables na początek nagłówka IPv4. Wartość zwrócona z programu BPF wartość logicznaGdzie false oznacza, że ​​pakiet nie pasował.

Wiadomo, że moduł xt_bpf obsługuje bardziej złożone filtry niż powyższy przykład. Spójrzmy na prawdziwe przykłady z Cloudfare. Do niedawna korzystali z modułu xt_bpf w celu ochrony przed atakami DDoS. W artykule Przedstawiamy narzędzia BPF wyjaśniają, jak (i ​​dlaczego) generują filtry BPF i publikują linki do zestawu narzędzi do tworzenia takich filtrów. Na przykład za pomocą narzędzia bpfgen możesz utworzyć program BPF pasujący do zapytania DNS o nazwę 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 #0

W programie najpierw ładujemy do rejestru X początek adresu linii x04habrx03comx00 wewnątrz datagramu UDP, a następnie sprawdź żądanie: 0x04686162 <-> "x04hab" itd.

Nieco później Cloudfare opublikowało kod kompilatora p0f -> BPF. W artykule Przedstawiamy kompilator p0f BPF mówią o tym, czym jest p0f i jak konwertować podpisy p0f na 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 nie korzystam już z Cloudfare xt_bpf, odkąd przeszli do XDP - jedna z opcji korzystania z nowej wersji BPF, patrz. L4Drop: Ograniczenia XDP DDoS.

cls_bpf

Ostatnim przykładem wykorzystania klasycznego BPF w jądrze jest klasyfikator cls_bpf dla podsystemu kontroli ruchu w systemie Linux, dodany do Linuksa pod koniec 2013 roku i koncepcyjnie zastępując starożytny cls_u32.

Nie będziemy jednak teraz opisywać dzieła cls_bpf, gdyż z punktu widzenia wiedzy o klasycznym BPF nic nam to nie da - poznaliśmy już całą funkcjonalność. Dodatkowo w kolejnych artykułach mówiących o Extended BPF, jeszcze nie raz spotkamy się z tym klasyfikatorem.

Kolejny powód, aby nie mówić o używaniu klasycznego BPF c cls_bpf Problem w tym, że w porównaniu z Extended BPF zakres zastosowania w tym przypadku jest radykalnie zawężony: klasyczne programy nie mogą zmieniać zawartości pakietów ani nie mogą zapisywać stanu pomiędzy wywołaniami.

Czas więc pożegnać się z klasycznym BPF i spojrzeć w przyszłość.

Pożegnanie z klasycznym BPF

Przyjrzeliśmy się, jak opracowana na początku lat dziewięćdziesiątych technologia BPF z powodzeniem przetrwała ćwierć wieku i do końca znalazła nowe zastosowania. Jednak podobnie jak przejście z maszyn stosowych na RISC, które było impulsem do rozwoju klasycznego BPF, w pierwszej dekadzie XXI wieku nastąpiło przejście z maszyn 32-bitowych na 64-bitowe i klasyczny BPF zaczął odchodzić od lamusa. Dodatkowo możliwości klasycznego BPF są bardzo ograniczone, a poza przestarzałą architekturą - nie mamy możliwości zapisywania stanu pomiędzy wywołaniami programów BPF, nie ma możliwości bezpośredniej interakcji z użytkownikiem, nie ma możliwości interakcji z jądrem, z wyjątkiem odczytu ograniczonej liczby pól struktury sk_buff i uruchomieniu najprostszych funkcji pomocniczych, nie można zmieniać zawartości pakietów i ich przekierowywać.

Tak naprawdę, obecnie z klasycznego BPF w Linuksie pozostało jedynie interfejs API, a wewnątrz jądra wszystkie klasyczne programy, czy to filtry gniazd, czy filtry seccomp, są automatycznie tłumaczone na nowy format, Extended BPF. (O tym dokładnie, jak to się dzieje, porozmawiamy w następnym artykule.)

Przejście na nową architekturę rozpoczęło się w 2013 roku, kiedy Aleksiej Starovoitov zaproponował schemat aktualizacji BPF. W 2014 roku odpowiednie poprawki zaczęło się pojawiać w rdzeniu. O ile rozumiem, początkowy plan zakładał jedynie optymalizację architektury i kompilatora JIT w celu wydajniejszego działania na komputerach 64-bitowych, ale zamiast tego te optymalizacje zapoczątkowały nowy rozdział w rozwoju Linuksa.

Kolejne artykuły z tej serii omówią architekturę i zastosowania nowej technologii, początkowo znanej jako wewnętrzny BPF, następnie rozszerzony BPF, a teraz po prostu BPF.

referencje

  1. Steven McCanne i Van Jacobson, „Filtr pakietów BSD: nowa architektura przechwytywania pakietów na poziomie użytkownika”, https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, „libpcap: metodologia architektury i optymalizacji przechwytywania pakietów”, https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. Samouczek dotyczący dopasowania IPtable U32.
  5. BPF - zapomniany kod bajtowy: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Przedstawiamy narzędzie BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Przegląd seccom: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Kontenery i bezpieczeństwo: seccomp
  11. habr: Izolowanie demonów za pomocą systemd lub „nie potrzebujesz do tego Dockera!”
  12. Paul Chaignon, „strace --seccomp-bpf: spojrzenie pod maskę”, https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Źródło: www.habr.com

Dodaj komentarz