BPF za mališane, nulti dio: klasični BPF

Berkeley Packet Filters (BPF) je Linux kernel tehnologija koja je već nekoliko godina na naslovnim stranicama tehničkih publikacija na engleskom jeziku. Konferencije su ispunjene izvještajima o korištenju i razvoju BPF-a. David Miller, održavatelj Linux mrežnog podsistema, održava svoj govor na Linux Plumbers 2018 “Ovaj razgovor nije o XDP-u” (XDP je jedan slučaj upotrebe za BPF). Brendan Gregg drži govor pod naslovom Linux BPF supermoći. Toke Høiland-Jørgensen smije seda je kernel sada mikrokernel. Thomas Graf promoviše ideju da BPF je javascript za kernel.

Još uvijek ne postoji sistematski opis BPF-a na Habré-u, pa ću stoga u nizu članaka pokušati govoriti o povijesti tehnologije, opisati arhitekturu i razvojne alate, te skicirati područja primjene i prakse korištenja BPF-a. Ovaj članak, nula, u seriji, govori o istoriji i arhitekturi klasičnog BPF-a, a otkriva i tajne njegovih principa rada. tcpdump, seccomp, strace, i mnogo više.

Razvoj BPF-a kontroliše Linux mrežna zajednica, glavne postojeće aplikacije BPF-a su vezane za mreže i stoga, uz dozvolu @eucariot, seriju sam nazvao “BPF za male”, u čast sjajne serije "Mreže za najmlađe".

Kratak kurs iz istorije BPF-a (c)

Moderna BPF tehnologija je poboljšana i proširena verzija stare tehnologije sa istim imenom, koja se sada zove klasični BPF kako bi se izbjegla zabuna. Dobro poznati uslužni program kreiran je na osnovu klasičnog BPF-a tcpdump, mehanizam seccomp, kao i manje poznati moduli xt_bpf do iptables i klasifikator cls_bpf. U modernom Linuxu, klasični BPF programi se automatski prevode u novi oblik, međutim, sa korisničke tačke gledišta, API je ostao na mjestu i nove namjene za klasični BPF, kao što ćemo vidjeti u ovom članku, još uvijek se pronalaze. Iz tog razloga, a i zbog toga što će nakon istorije razvoja klasičnog BPF-a u Linuxu biti jasnije kako je i zašto evoluirao u svoj moderni oblik, odlučio sam da počnem sa člankom o klasičnom BPF-u.

Krajem osamdesetih godina prošlog veka inženjere iz čuvene laboratorije Lawrence Berkeley zainteresovalo je pitanje kako pravilno filtrirati mrežne pakete na hardveru koji je bio moderan krajem osamdesetih godina prošlog veka. Osnovna ideja filtriranja, prvobitno implementirana u CSPF (CMU/Stanford Packet Filter) tehnologiji, bila je filtriranje nepotrebnih paketa što je prije moguće, tj. u prostoru kernela, jer se time izbjegava kopiranje nepotrebnih podataka u korisnički prostor. Da bi se osigurala sigurnost u vrijeme izvođenja za pokretanje korisničkog koda u prostoru kernela, korištena je virtuelna mašina u zaštićenom okruženju.

Međutim, virtuelne mašine za postojeće filtere su dizajnirane da rade na mašinama zasnovanim na steku i nisu radile tako efikasno na novijim RISC mašinama. Kao rezultat toga, trudom inženjera iz Berkeley Labsa, razvijena je nova tehnologija BPF (Berkeley Packet Filters) čija je arhitektura virtuelne mašine dizajnirana na osnovu Motorola 6502 procesora - radnog konja poznatih proizvoda kao što su Apple II ili NES. Nova virtuelna mašina povećala je performanse filtera desetine puta u poređenju sa postojećim rešenjima.

Arhitektura BPF mašina

Sa arhitekturom ćemo se upoznati na radni način, analizirajući primjere. Međutim, za početak, recimo da je mašina imala dva 32-bitna registra dostupna korisniku, akumulator A i indeksni registar X, 64 bajta memorije (16 reči), dostupne za pisanje i naknadno čitanje, i mali sistem komandi za rad sa ovim objektima. Instrukcije za preskakanje za implementaciju uslovnih izraza takođe su bile dostupne u programima, ali da bi se garantovao pravovremeni završetak programa, skokovi su se mogli vršiti samo unapred, odnosno zabranjeno je kreiranje petlji.

Opća shema za pokretanje stroja je sljedeća. Korisnik kreira program za BPF arhitekturu i, koristeći neki mehanizam kernela (kao što je sistemski poziv), učitava i povezuje program sa nekima generatoru događaja u kernelu (na primjer, događaj je dolazak sljedećeg paketa na mrežnu karticu). Kada se dogodi neki događaj, kernel pokreće program (na primjer, u interpretatoru), a memorija mašine odgovara nekima region memorije kernela (na primjer, podaci o dolaznom paketu).

Gore navedeno će nam biti dovoljno da počnemo da gledamo primere: upoznaćemo se sa sistemom i formatom komande po potrebi. Ako želite odmah proučiti komandni sistem virtuelne mašine i naučiti o svim njenim mogućnostima, onda možete pročitati originalni članak BSD filter paketa i/ili prvu polovinu fajla Documentation/networking/filter.txt iz dokumentacije kernela. Osim toga, možete proučiti prezentaciju libpcap: Arhitektura i metodologija optimizacije za hvatanje paketa, u kojem McCanne, jedan od autora BPF-a, govori o istoriji stvaranja libpcap.

Sada prelazimo na razmatranje svih značajnih primjera korištenja klasičnog BPF-a na Linuxu: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

Razvoj BPF-a odvijao se paralelno sa razvojem frontenda za filtriranje paketa - dobro poznatog uslužnog programa tcpdump. A pošto je ovo najstariji i najpoznatiji primjer korištenja klasičnog BPF-a, dostupan na mnogim operativnim sistemima, s njim ćemo započeti naše proučavanje tehnologije.

(Pokrenuo sam sve primjere u ovom članku na Linuxu 5.6.0-rc6. Izlaz nekih naredbi je uređen radi bolje čitljivosti.)

Primjer: promatranje IPv6 paketa

Zamislimo da želimo da pogledamo sve IPv6 pakete na interfejsu eth0. Da bismo to uradili, možemo pokrenuti program tcpdump sa jednostavnim filterom ip6:

$ sudo tcpdump -i eth0 ip6

tako tcpdump kompajlira filter ip6 u bajt kod BPF arhitekture i pošaljite ga kernelu (pogledajte detalje u odjeljku Tcpdump: učitavanje). Učitani filter će se pokrenuti za svaki paket koji prolazi kroz interfejs eth0. Ako filter vrati vrijednost različitu od nule n, zatim do n bajtovi paketa će biti kopirani u korisnički prostor i to ćemo vidjeti u izlazu tcpdump.

BPF za mališane, nulti dio: klasični BPF

Ispostavilo se da lako možemo saznati koji je bajt kod poslan kernelu tcpdump uz pomoć tcpdump, ako ga pokrenemo s opcijom -d:

$ sudo tcpdump -i eth0 -d ip6
(000) ldh      [12]
(001) jeq      #0x86dd          jt 2    jf 3
(002) ret      #262144
(003) ret      #0

Na liniji nula pokrećemo naredbu ldh [12], što znači „učitaj u registar A pola riječi (16 bita) koja se nalazi na adresi 12” i samo je pitanje kakvoj memoriji se obraćamo? Odgovor je da u x počinje (x+1)bajta analiziranog mrežnog paketa. Čitamo pakete sa Ethernet interfejsa eth0i to značida paket izgleda ovako (radi jednostavnosti pretpostavljamo da u paketu nema VLAN oznaka):

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

Dakle, nakon izvršenja naredbe ldh [12] u registru A biće polje Ether Type — tip paketa koji se prenosi u ovom Ethernet okviru. Na liniji 1 upoređujemo sadržaj registra A (tip paketa) c 0x86ddi to i imaju Tip koji nas zanima je IPv6. U prvom redu, pored komande za poređenje, nalaze se još dve kolone - jt 2 и jf 3 — oznake na koje trebate ići ako je poređenje uspješno (A == 0x86dd) i neuspješno. Dakle, u uspješnom slučaju (IPv6) idemo na red 2, a u neuspješnom - na red 3. Na liniji 3 program završava kodom 0 (ne kopirajte paket), na liniji 2 program završava kodom 262144 (kopiraj mi paket od maksimalno 256 kilobajta).

Složeniji primjer: gledamo TCP pakete prema odredišnom portu

Pogledajmo kako izgleda filter koji kopira sve TCP pakete sa odredišnim portom 666. Razmotrićemo IPv4 slučaj, pošto je IPv6 slučaj jednostavniji. Nakon što proučite ovaj primjer, možete sami istražiti IPv6 filter kao vježbu (ip6 and tcp dst port 666) i filter za opći slučaj (tcp dst port 666). Dakle, filter koji nas zanima izgleda ovako:

$ 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

Već znamo šta rade linije 0 i 1. Na liniji 2 već smo provjerili da je ovo IPv4 paket (Ether Type = 0x800) i učitajte ga u registar A 24. bajt paketa. Naš paket izgleda tako

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

što znači da učitavamo u registar A polje Protocol IP zaglavlja, što je logično, jer želimo da kopiramo samo TCP pakete. Upoređujemo Protokol sa 0x6 (IPPROTO_TCP) na liniji 3.

U redove 4 i 5 učitavamo poluriječi koje se nalaze na adresi 20 i koristimo naredbu jset provjerite da li je jedno od tri postavljeno zastave - nošenje izdate maske jset tri najznačajnija bita se brišu. Dva od tri bita nam govore da li je paket dio fragmentiranog IP paketa, i ako jeste, da li je posljednji fragment. Treći bit je rezerviran i mora biti nula. Ne želimo da proveravamo ni celobrojne ni polomljene pakete, pa proveravamo sva tri bita.

Red 6 je najzanimljiviji u ovoj listi. Izraz ldxb 4*([14]&0xf) znači učitavamo u registar X četiri najmanje značajna bita petnaestog bajta paketa pomnožena sa 4. Najmanja značajna četiri bita petnaestog bajta je polje Dužina internet zaglavlja IPv4 zaglavlje, koje pohranjuje dužinu zaglavlja u riječima, pa onda morate pomnožiti sa 4. Zanimljivo je da izraz 4*([14]&0xf) je oznaka za posebnu šemu adresiranja koja se može koristiti samo u ovom obliku i samo za registar X, tj. ne možemo ni reći ldb 4*([14]&0xf) niti ldxb 5*([14]&0xf) (možemo samo odrediti drugačiji pomak, na primjer, ldxb 4*([16]&0xf)). Jasno je da je ova šema adresiranja dodata BPF-u upravo radi primanja X (indeksni registar) Dužina IPv4 zaglavlja.

Dakle, na liniji 7 pokušavamo učitati pola riječi na (X+16). Imajući na umu da 14 bajtova zauzima Ethernet zaglavlje, i X sadrži dužinu IPv4 zaglavlja, to razumijemo u A TCP odredišni port je učitan:

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

Konačno, na liniji 8 upoređujemo odredišni port sa željenom vrijednošću i na redovima 9 ili 10 vraćamo rezultat - da li da kopiramo paket ili ne.

Tcpdump: učitavanje

U prethodnim primjerima nismo se posebno bavili detaljima o tome kako tačno učitavamo BPF bajt kod u kernel za filtriranje paketa. Općenito govoreći, tcpdump portovano na mnoge sisteme i za rad sa filterima tcpdump koristi biblioteku libpcap. Ukratko, da postavite filter na interfejs koristeći libpcap, morate da uradite sljedeće:

Da vidite kako funkcionira pcap_setfilter implementiran u Linux, mi koristimo strace (neki redovi su uklonjeni):

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

Na prve dvije linije izlaza kreiramo sirova utičnica da pročitate sve Ethernet okvire i povežete ih sa interfejsom eth0. Of naš prvi primjer znamo da je filter ip će se sastojati od četiri BPF instrukcije, a u trećem redu vidimo kako se koristi opcija SO_ATTACH_FILTER sistemski poziv setsockopt učitavamo i povezujemo filter dužine 4. Ovo je naš filter.

Vrijedi napomenuti da se u klasičnom BPF-u učitavanje i povezivanje filtera uvijek odvija kao atomska operacija, au novoj verziji BPF-a učitavanje programa i njegovo povezivanje sa generatorom događaja su vremenski razdvojeni.

Hidden Truth

Malo kompletnija verzija izlaza izgleda ovako:

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

Kao što je gore spomenuto, učitavamo i spajamo naš filter na utičnicu na liniji 5, ali šta se događa na linijama 3 i 4? Ispostavilo se da je ovo libpcap brine o nama - tako da izlaz našeg filtera ne uključuje pakete koji ga ne zadovoljavaju, biblioteka povezuje lažni filter ret #0 (ispusti sve pakete), prebacuje soket u neblokirajući način rada i pokušava oduzeti sve pakete koji bi mogli ostati od prethodnih filtera.

Ukupno, da biste filtrirali pakete na Linuxu koristeći klasični BPF, morate imati filter u obliku strukture kao što je struct sock_fprog i otvorenu utičnicu, nakon čega se filter može priključiti na utičnicu pomoću sistemskog poziva setsockopt.

Zanimljivo je da se filter može priključiti na bilo koju utičnicu, a ne samo na sirovi. Evo primer program koji odsijeca sve osim prva dva bajta iz svih dolaznih UDP datagrama. (Dodao sam komentare u kod kako ne bih zatrpao članak.)

Više detalja o upotrebi setsockopt za povezivanje filtera, vidi utičnica(7), već o pisanju vlastitih filtera poput struct sock_fprog bez pomoći tcpdump razgovaraćemo u sekciji Programiranje BPF-a vlastitim rukama.

Klasični BPF i XNUMX. vek

BPF je uključen u Linux 1997. godine i dugo je ostao radni konj libpcap bez ikakvih posebnih izmjena (izmjena specifičnih za Linux, naravno, to je bio, ali nisu promijenili globalnu sliku). Prvi ozbiljni znaci da će BPF evoluirati došli su 2011. godine, kada je Eric Dumazet predložio patch, koji kernelu dodaje kompajler Just In Time - prevodilac za pretvaranje BPF bajt koda u izvorni x86_64 kod.

JIT kompajler je bio prvi u lancu promena: 2012 pojavila sposobnost pisanja filtera za seccomp, koristeći BPF, u januaru 2013. godine dodano modul xt_bpf, što vam omogućava da pišete pravila za iptables uz pomoć BPF-a, a u oktobru 2013 dodano takođe modul cls_bpf, koji vam omogućava da pišete klasifikatore saobraćaja koristeći BPF.

Uskoro ćemo detaljnije pogledati sve ove primjere, ali prvo će nam biti korisno da naučimo kako pisati i kompajlirati proizvoljne programe za BPF, budući da su mogućnosti koje pruža biblioteka libpcap ograničeno (jednostavan primjer: generiran filter libpcap može vratiti samo dvije vrijednosti - 0 ili 0x40000) ili općenito, kao u slučaju seccomp, nisu primjenjive.

Programiranje BPF-a vlastitim rukama

Hajde da se upoznamo sa binarnim formatom BPF instrukcija, vrlo je jednostavno:

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

Svaka instrukcija zauzima 64 bita, u kojima je prvih 16 bita kod instrukcije, zatim postoje dva osmobitna uvlaka, jt и jf, i 32 bita za argument K, čija svrha varira od komande do komande. Na primjer, naredba ret, koji prekida program ima kod 6, a povratna vrijednost se uzima iz konstante K. U C-u, jedna BPF instrukcija je predstavljena kao struktura

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

a cijeli program je u obliku strukture

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

Dakle, već možemo pisati programe (na primjer, znamo kodove instrukcija iz [1]). Ovako će izgledati filter ip6 из naš prvi primjer:

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žemo legalno koristiti u pozivu

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

Pisanje programa u obliku mašinskih kodova nije baš zgodno, ali ponekad je neophodno (na primjer, za otklanjanje grešaka, kreiranje jediničnih testova, pisanje članaka na Habréu, itd.). Radi praktičnosti, u fajlu <linux/filter.h> Pomoćni makroi su definirani - isti primjer kao gore može se prepisati kao

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

Međutim, ova opcija nije baš zgodna. To je ono što su programeri jezgra Linuxa zaključili, i stoga u direktoriju tools/bpf kernela možete pronaći asembler i debugger za rad sa klasičnim BPF-om.

Jezik asemblera je vrlo sličan izlazu za otklanjanje grešaka tcpdump, ali pored toga možemo specificirati simboličke oznake. Na primjer, evo programa koji ispušta sve pakete osim TCP/IPv4:

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

Podrazumevano, asembler generiše kod u formatu <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., za naš primjer sa TCP-om to će biti

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

Za praktičnost C programera, može se koristiti drugačiji izlazni format:

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

Ovaj tekst se može kopirati u definiciju strukture tipa struct sock_filter, kao što smo uradili na početku ovog odeljka.

Linux i netsniff-ng ekstenzije

Pored standardnog BPF-a, Linux i tools/bpf/bpf_asm podrška i nestandardni set. U osnovi, instrukcije se koriste za pristup poljima strukture struct sk_buff, koji opisuje mrežni paket u kernelu. Međutim, postoje i druge vrste pomoćnih uputstava, na primjer ldw cpu će se učitati u registar A rezultat pokretanja funkcije kernela raw_smp_processor_id(). (U novoj verziji BPF-a, ove nestandardne ekstenzije su proširene da obezbede programe sa skupom pomoćnika kernela za pristup memoriji, strukturama i generisanje događaja.) Evo zanimljivog primera filtera u koji kopiramo samo zaglavlja paketa u korisnički prostor pomoću ekstenzije poff, pomak nosivosti:

ld poff
ret a

BPF ekstenzije se ne mogu koristiti u tcpdump, ali ovo je dobar razlog da se upoznate sa uslužnim paketom netsniff-ng, koji između ostalog sadrži i napredni program netsniff-ng, koji pored filtriranja pomoću BPF-a sadrži i efikasan generator saobraćaja, i napredniji od tools/bpf/bpf_asm, pozvao je BPF asembler bpfc. Paket sadrži dosta detaljnu dokumentaciju, pogledajte i linkove na kraju članka.

seccomp

Dakle, već znamo kako pisati BPF programe proizvoljne složenosti i spremni smo da pogledamo nove primjere, od kojih je prvi tehnologija seccomp, koja omogućava, koristeći BPF filtere, da upravlja skupom i skupom argumenata sistemskog poziva koji su dostupni za dati proces i njegovi potomci.

Prva verzija seccompa dodata je kernelu 2005. godine i nije bila jako popularna, jer je pružala samo jednu opciju - ograničiti skup sistemskih poziva dostupnih procesu na sljedeće: read, write, exit и sigreturn, a proces koji je prekršio pravila je ubijen upotrebom SIGKILL. Međutim, 2012. godine, seccomp je dodao mogućnost korištenja BPF filtera, omogućavajući vam da definirate skup dozvoljenih sistemskih poziva, pa čak i izvršite provjere njihovih argumenata. (Zanimljivo je da je Chrome bio jedan od prvih korisnika ove funkcionalnosti, a ljudi iz Chromea trenutno razvijaju KRSI mehanizam zasnovan na novoj verziji BPF-a i omogućavaju prilagođavanje Linux sigurnosnih modula.) Linkovi na dodatnu dokumentaciju mogu se naći na kraju. članka.

Imajte na umu da je već bilo članaka na čvorištu o korištenju seccompa, možda će ih neko htjeti pročitati prije (ili umjesto) čitanja sljedećih pododjeljaka. U članku Kontejneri i sigurnost: seccomp daje primere korišćenja seccomp-a, i verziju iz 2007. i verziju koja koristi BPF (filteri se generišu pomoću libseccomp-a), govori o vezi seccompa sa Docker-om, a takođe pruža mnoge korisne veze. U članku Izolacija demona sa systemd-om ili "ne treba vam Docker za ovo!" Pokriva, posebno, kako dodati crne liste ili bele liste sistemskih poziva za demone koji pokreću systemd.

Zatim ćemo vidjeti kako napisati i učitati filtere za seccomp u golom C i koristeći biblioteku libseccomp i koje su prednosti i nedostatke svake opcije, i na kraju, da vidimo kako seccomp koristi program strace.

Zapisivanje i učitavanje filtera za seccomp

Već znamo kako pisati BPF programe, pa hajde da prvo pogledamo programski interfejs seccomp. Možete postaviti filter na nivou procesa i svi podređeni procesi će naslijediti ograničenja. Ovo se radi pomoću sistemskog poziva seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

gdje &filter - ovo je pokazivač na strukturu koja nam je već poznata struct sock_fprog, tj. BPF program.

Po čemu se programi za seccomp razlikuju od programa za utičnice? Preneseni kontekst. U slučaju soketa, dato nam je memorijsko područje koje sadrži paket, au slučaju seccomp nam je data struktura kao što je

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

to je nr je broj sistemskog poziva koji treba pokrenuti, arch - trenutna arhitektura (više o tome u nastavku), args - do šest argumenata sistemskog poziva, i instruction_pointer je pokazivač na instrukciju korisničkog prostora koja je izvršila sistemski poziv. Tako, na primjer, učitati broj sistemskog poziva u registar A moramo reći

ldw [0]

Postoje i druge karakteristike za seccomp programe, na primjer, kontekstu se može pristupiti samo 32-bitnim poravnanjem i ne možete učitati pola riječi ili bajt - kada pokušavate učitati filter ldh [0] sistemski poziv seccomp Će se vratiti EINVAL. Funkcija provjerava učitane filtere seccomp_check_filter() jezgra. (Smiješna stvar je da su u originalnom urezivanju koji je dodao seccomp funkcionalnost zaboravili dodati dozvolu za korištenje instrukcije ovoj funkciji mod (ostatak podjela) i sada je nedostupan za seccomp BPF programe, od njegovog dodavanja će se slomiti ABI.)

U osnovi, već znamo sve za pisanje i čitanje seccomp programa. Obično je programska logika uređena kao bijela ili crna lista sistemskih poziva, na primjer programa

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

provjerava crnu listu od četiri sistemska poziva pod brojevima 304, 176, 239, 279. Koji su to sistemski pozivi? Ne možemo sa sigurnošću reći, jer ne znamo za koju arhitekturu je program napisan. Stoga su autori seccomp ponuda pokrenite sve programe provjerom arhitekture (trenutna arhitektura je naznačena u kontekstu kao polje arch strukture struct seccomp_data). Sa provjerenom arhitekturom, početak primjera bi izgledao ovako:

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

i tada bi naši sistemski pozivni brojevi dobili određene vrijednosti.

Pišemo i učitavamo filtere za korištenje seccompa libseccomp

Pisanje filtera u izvornom kodu ili u BPF asembleru omogućava vam da imate potpunu kontrolu nad rezultatom, ali u isto vrijeme, ponekad je poželjno imati prenosivi i/ili čitljivi kod. Biblioteka će nam pomoći u tome libseccomp, koji pruža standardni interfejs za pisanje crnih ili belih filtera.

Napišimo, na primjer, program koji pokreće binarnu datoteku po izboru korisnika, nakon što je prethodno instalirao crnu listu sistemskih poziva sa gornji članak (program je pojednostavljen radi veće čitljivosti, puna verzija se može naći ovdje):

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

Prvo definiramo niz sys_numbers od 40+ brojeva sistemskog poziva za blokiranje. Zatim inicijalizirajte kontekst ctx i reci biblioteci šta želimo da dozvolimo (SCMP_ACT_ALLOW) svi sistemski pozivi prema zadanim postavkama (lakše je napraviti crne liste). Zatim, jedan po jedan, dodajemo sve sistemske pozive sa crne liste. Kao odgovor na sistemski poziv sa liste, tražimo SCMP_ACT_TRAP, u ovom slučaju će seccomp poslati signal procesu SIGSYS sa opisom koji sistemski poziv je prekršio pravila. Konačno, učitavamo program u kernel pomoću seccomp_load, koji će kompajlirati program i priložiti ga procesu koristeći sistemski poziv seccomp(2).

Za uspješnu kompilaciju, program mora biti povezan s bibliotekom libseccomp, na primjer:

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

Primjer uspješnog lansiranja:

$ ./seccomp_lib echo ok
ok

Primjer blokiranog sistemskog poziva:

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

Koristimo straceza detalje:

$ 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

kako možemo znati da je program prekinut zbog korištenja ilegalnog sistemskog poziva mount(2).

Dakle, napisali smo filter koristeći biblioteku libseccomp, uklapajući netrivijalni kod u četiri reda. U gornjem primjeru, ako postoji veliki broj sistemskih poziva, vrijeme izvršenja može biti značajno smanjeno, jer je provjera samo lista poređenja. Za optimizaciju, libseccomp je nedavno imao patch uključen, koji dodaje podršku za atribut filtera SCMP_FLTATR_CTL_OPTIMIZE. Postavljanje ovog atributa na 2 će pretvoriti filter u binarni program za pretraživanje.

Ako želite vidjeti kako funkcioniraju filteri binarnog pretraživanja, pogledajte jednostavna skripta, koji generiše takve programe u BPF asembleru biranjem brojeva sistema, na primjer:

$ 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

Nećete moći pisati ništa značajno brže, jer BPF programi ne mogu izvršiti skokove uvlačenja (mi ne možemo npr. jmp A ili jmp [label+X]) i stoga su svi prijelazi statični.

seccomp i strace

Svi znaju korisnost strace je nezamjenjiv alat za proučavanje ponašanja procesa na Linuxu. Međutim, mnogi su i čuli za pitanja performansi kada koristite ovaj uslužni program. Činjenica je da strace implementirano korištenjem ptrace(2), a u ovom mehanizmu ne možemo specificirati na kom skupu sistemskih poziva trebamo zaustaviti proces, tj. na primjer, komande

$ 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

se obrađuju u približno istom vremenu, iako u drugom slučaju želimo pratiti samo jedan sistemski poziv.

Nova opcija --seccomp-bpf, dodano strace verzija 5.3, omogućava vam da ubrzate proces mnogo puta i vrijeme pokretanja pod tragom jednog sistemskog poziva je već uporedivo s vremenom običnog pokretanja:

$ 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

(Ovdje, naravno, postoji mala obmana u tome da ne pratimo glavni sistemski poziv ove komande. Ako bismo pratili npr. newfsstat, onda strace kočio bi jednako snažno kao i bez njega --seccomp-bpf.)

Kako ova opcija funkcionira? Bez nje strace povezuje se na proces i pokreće ga koristeći PTRACE_SYSCALL. Kada upravljani proces izda (bilo koji) sistemski poziv, kontrola se prenosi na strace, koji gleda argumente sistemskog poziva i pokreće ga sa PTRACE_SYSCALL. Nakon nekog vremena proces završava sistemski poziv i pri izlasku iz njega kontrola se ponovo prenosi strace, koji gleda povratne vrijednosti i pokreće proces koristeći PTRACE_SYSCALL, i tako dalje.

BPF za mališane, nulti dio: klasični BPF

Sa seccompom, međutim, ovaj proces se može optimizirati upravo onako kako bismo željeli. Naime, ako želimo da pogledamo samo sistemski poziv X, tada možemo napisati BPF filter koji za X vraća vrijednost SECCOMP_RET_TRACE, a za pozive koji nas ne interesuju - SECCOMP_RET_ALLOW:

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

U ovom slučaju strace u početku pokreće proces kao PTRACE_CONT, naš filter se obrađuje za svaki sistemski poziv, ako sistemski poziv nije X, tada proces nastavlja da se izvodi, ali ako je ovo X, tada će seccomp prenijeti kontrolu stracekoji će pogledati argumente i započeti proces kao PTRACE_SYSCALL (pošto seccomp nema mogućnost pokretanja programa po izlasku iz sistemskog poziva). Kada se sistemski poziv vrati, strace ponovo će pokrenuti proces koristeći PTRACE_CONT i čekat će nove poruke od seccompa.

BPF za mališane, nulti dio: klasični BPF

Kada koristite opciju --seccomp-bpf postoje dva ograničenja. Prvo, neće biti moguće pridružiti se već postojećem procesu (opcija -p programi strace), budući da ovo nije podržano od strane seccomp. Drugo, ne postoji mogućnost ne pogledajte podređene procese, pošto seccomp filtere nasljeđuju svi podređeni procesi bez mogućnosti da se ovo onemogući.

Malo detaljnije kako tačno strace rad sa seccomp može se pronaći od nedavni izvještaj. Za nas je najzanimljivija činjenica da se klasični BPF koji predstavlja seccomp koristi i danas.

xt_bpf

Vratimo se sada u svijet mreža.

Pozadina: davno, 2007. godine, jezgro je bilo dodano modul xt_u32 za netfilter. Napisan je po analogiji sa još starijim klasifikatorom saobraćaja cls_u32 i omogućio vam da napišete proizvoljna binarna pravila za iptables koristeći sljedeće jednostavne operacije: učitajte 32 bita iz paketa i izvršite skup aritmetičkih operacija na njima. Na primjer,

sudo iptables -A INPUT -m u32 --u32 "6&0xFF=1" -j LOG --log-prefix "seen-by-xt_u32"

Učitava 32 bita IP zaglavlja, počevši od paddinga 6, i primjenjuje masku na njih 0xFF (uzmite niži bajt). Ovo polje protocol IP zaglavlje i poredimo ga sa 1 (ICMP). Možete kombinirati mnoge provjere u jednom pravilu, a možete i izvršiti operator @ — pomerite X bajtova udesno. Na primjer, pravilo

iptables -m u32 --u32 "6&0xFF=0x6 && 0>>22&0x3C@4=0x29"

provjerava da li broj TCP sekvence nije jednak 0x29. Neću dalje ulaziti u detalje, jer je već jasno da pisanje takvih pravila rukom nije baš zgodno. U članku BPF - zaboravljeni bajt kod, postoji nekoliko veza s primjerima korištenja i generiranja pravila za xt_u32. Pogledajte i linkove na kraju ovog članka.

Od 2013. modul umjesto modula xt_u32 možete koristiti modul baziran na BPF-u xt_bpf. Svako ko je čitao do sada već bi trebao biti jasan o principu njegovog rada: pokrenite BPF bytecode kao iptables pravila. Možete kreirati novo pravilo, na primjer, ovako:

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

ovdje <байткод> - ovo je kod u izlaznom formatu asemblera bpf_asm po defaultu, npr.

$ 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

U ovom primjeru filtriramo sve UDP pakete. Kontekst za BPF program u modulu xt_bpf, naravno, ukazuje na paketne podatke, u slučaju iptables, na početak IPv4 zaglavlja. Povratna vrijednost iz BPF programa booleangde false znači da se paket nije podudarao.

Jasno je da je modul xt_bpf podržava složenije filtere od gornjeg primjera. Pogledajmo stvarne primjere iz Cloudfarea. Do nedavno su koristili modul xt_bpf za zaštitu od DDoS napada. U članku Predstavljamo BPF alate objašnjavaju kako (i zašto) generišu BPF filtere i objavljuju veze do skupa uslužnih programa za kreiranje takvih filtera. Na primjer, korištenjem uslužnog programa bpfgen možete kreirati BPF program koji odgovara DNS upitu za ime 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

U programu prvo učitavamo u registar X adresa početka linije x04habrx03comx00 unutar UDP datagrama, a zatim provjerite zahtjev: 0x04686162 <-> "x04hab" i tako dalje.

Malo kasnije, Cloudfare je objavio p0f -> BPF kompajlerski kod. U članku Predstavljamo p0f BPF kompajler govore o tome šta je p0f i kako pretvoriti p0f potpise u 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,
...

Trenutno više ne koristim Cloudfare xt_bpf, pošto su prešli na XDP - jednu od opcija za korištenje nove verzije BPF-a, vidi. L4Drop: XDP DDoS ublažavanja.

cls_bpf

Posljednji primjer korištenja klasičnog BPF-a u kernelu je klasifikator cls_bpf za podsistem kontrole prometa u Linuxu, dodat Linuxu krajem 2013. i konceptualno zamjenjujući drevni cls_u32.

Međutim, sada nećemo opisivati ​​rad cls_bpf, pošto nam sa stanovišta znanja o klasičnom BPF-u to neće dati ništa - već smo se upoznali sa svim funkcionalnostima. Osim toga, u narednim člancima koji govore o proširenom BPF-u, susrećemo se s ovim klasifikatorom više puta.

Još jedan razlog da ne govorimo o korištenju klasičnog BPF-a c cls_bpf Problem je u tome što je, u poređenju sa proširenim BPF-om, opseg primenljivosti u ovom slučaju radikalno sužen: klasični programi ne mogu da menjaju sadržaj paketa i ne mogu da sačuvaju stanje između poziva.

Dakle, vrijeme je da se oprostimo od klasičnog BPF-a i pogledamo u budućnost.

Zbogom klasičnom BPF-u

Pogledali smo kako je BPF tehnologija, razvijena početkom devedesetih, uspješno živjela četvrt vijeka i do kraja našla nove primjene. Međutim, slično prelasku sa stek mašina na RISC, koji je poslužio kao podsticaj za razvoj klasičnog BPF-a, 32-ih je došlo do prelaska sa 64-bitnih na XNUMX-bitne mašine i klasični BPF je počeo da zastareva. Osim toga, mogućnosti klasičnog BPF-a su vrlo ograničene, a pored zastarjele arhitekture - nemamo mogućnost snimanja stanja između poziva u BPF programe, nema mogućnosti direktne interakcije korisnika, nema mogućnosti interakcije. sa kernelom, osim za čitanje ograničenog broja strukturnih polja sk_buff i pokretanjem najjednostavnijih pomoćnih funkcija, ne možete promijeniti sadržaj paketa i preusmjeriti ih.

Zapravo, trenutno sve što je ostalo od klasičnog BPF-a u Linuxu je API sučelje, a unutar kernela svi klasični programi, bilo da se radi o filterima socketa ili seccomp filterima, automatski se prevode u novi format, Extended BPF. (O tome kako se to tačno događa govorit ćemo u sljedećem članku.)

Prelazak na novu arhitekturu započeo je 2013. godine, kada je Aleksej Starovoitov predložio šemu ažuriranja BPF-a. U 2014. odgovarajuće zakrpe počeo da se pojavljuje u jezgru. Koliko sam shvatio, prvobitni plan je bio samo da se optimizira arhitektura i JIT kompajler kako bi se efikasnije pokrenuli na 64-bitnim mašinama, ali umjesto toga ove optimizacije su označile početak novog poglavlja u razvoju Linuxa.

Dalji članci u ovoj seriji će pokriti arhitekturu i primjenu nove tehnologije, prvobitno poznate kao interni BPF, zatim prošireni BPF, a sada jednostavno BPF.

reference

  1. Steven McCanne i Van Jacobson, "BSD paketni filter: nova arhitektura za hvatanje paketa na korisničkom nivou", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: Arhitektura i metodologija optimizacije za hvatanje paketa", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. IPtable U32 Vodič za meč.
  5. BPF - zaboravljeni bajt kod: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Predstavljamo BPF alat: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Seccomp pregled: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Kontejneri i sigurnost: seccomp
  11. habr: Izolacija demona sa systemd-om ili "ne treba vam Docker za ovo!"
  12. Paul Chaignon, "strace --seccomp-bpf: pogled ispod haube", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

izvor: www.habr.com

Dodajte komentar