BPF pentru cei mici, partea zero: BPF clasic

Berkeley Packet Filters (BPF) este o tehnologie de nucleu Linux care se află pe primele pagini ale publicațiilor tehnologice în limba engleză de câțiva ani. Conferințele sunt pline cu rapoarte despre utilizarea și dezvoltarea BPF. David Miller, întreținătorul subsistemului de rețea Linux, își susține discursul la Linux Plumbers 2018 „Această discuție nu este despre XDP” (XDP este un caz de utilizare pentru BPF). Brendan Gregg susține discursuri intitulate Superputeri Linux BPF. Toke Høiland-Jørgensen râdecă nucleul este acum un microkernel. Thomas Graf promovează ideea că BPF este javascript pentru nucleu.

Încă nu există o descriere sistematică a BPF pe Habré și, prin urmare, într-o serie de articole voi încerca să vorbesc despre istoria tehnologiei, să descriu arhitectura și instrumentele de dezvoltare și să subliniez domeniile de aplicare și practică ale utilizării BPF. Acest articol, zero, din serie, spune istoria și arhitectura BPF clasică și, de asemenea, dezvăluie secretele principiilor sale de funcționare. tcpdump, seccomp, strace, și mult mai mult.

Dezvoltarea BPF este controlată de comunitatea de rețele Linux, principalele aplicații existente ale BPF sunt legate de rețele și, prin urmare, cu permisiunea @eucariot, am numit serialul „BPF pentru cei mici”, în cinstea marelui serial „Rețele pentru cei mici”.

Un scurt curs în istoria BPF(c)

Tehnologia modernă BPF este o versiune îmbunătățită și extinsă a vechii tehnologii cu același nume, numită acum BPF clasic pentru a evita confuzia. Un utilitar binecunoscut a fost creat pe baza clasicului BPF tcpdump, mecanism seccomp, precum și module mai puțin cunoscute xt_bpf pentru iptables și clasificator cls_bpf. În Linux modern, programele clasice BPF sunt traduse automat în noua formă, cu toate acestea, din punct de vedere al utilizatorului, API-ul a rămas pe loc și noi utilizări pentru BPF clasic, așa cum vom vedea în acest articol, sunt încă găsite. Din acest motiv și, de asemenea, pentru că în urma istoriei dezvoltării BPF clasice în Linux, va deveni mai clar cum și de ce a evoluat în forma sa modernă, am decis să încep cu un articol despre BPF clasic.

La sfârșitul anilor optzeci ai secolului trecut, inginerii de la renumitul laborator Lawrence Berkeley au devenit interesați de întrebarea cum să filtreze corect pachetele de rețea pe hardware care era modern la sfârșitul anilor opt ai secolului trecut. Ideea de bază a filtrării, implementată inițial în tehnologia CSPF (CMU/Stanford Packet Filter), a fost aceea de a filtra pachetele inutile cât mai devreme posibil, adică. în spațiul kernel, deoarece acest lucru evită copierea datelor inutile în spațiul utilizatorului. Pentru a asigura securitatea de rulare pentru rularea codului utilizatorului în spațiul kernel, a fost folosită o mașină virtuală cu nisip.

Cu toate acestea, mașinile virtuale pentru filtrele existente au fost proiectate să ruleze pe mașini bazate pe stivă și nu au funcționat la fel de eficient pe mașinile RISC mai noi. Drept urmare, prin eforturile inginerilor de la Berkeley Labs, a fost dezvoltată o nouă tehnologie BPF (Berkeley Packet Filters), a cărei arhitectură a mașinii virtuale a fost proiectată pe baza procesorului Motorola 6502 - calul de bătaie al unor produse atât de cunoscute precum Apple II sau Nespecificate în altă parte. Noua mașină virtuală a crescut performanța filtrului de zeci de ori în comparație cu soluțiile existente.

Arhitectura mașinii BPF

Ne vom familiariza cu arhitectura într-un mod de lucru, analizând exemple. Cu toate acestea, pentru început, să spunem că mașina avea două registre de 32 de biți accesibile utilizatorului, un acumulator A și registrul index X, 64 de octeți de memorie (16 cuvinte), disponibil pentru scriere și citire ulterioară și un mic sistem de comenzi pentru lucrul cu aceste obiecte. Instrucțiunile de salt pentru implementarea expresiilor condiționate erau, de asemenea, disponibile în programe, dar pentru a garanta finalizarea la timp a programului, salturile puteau fi făcute numai înainte, adică, în special, era interzisă crearea de bucle.

Schema generală de pornire a mașinii este următoarea. Utilizatorul creează un program pentru arhitectura BPF și, folosind niste mecanismul nucleului (cum ar fi un apel de sistem), încarcă și conectează programul la unora la generatorul de evenimente din nucleu (de exemplu, un eveniment este sosirea următorului pachet pe placa de rețea). Când are loc un eveniment, nucleul rulează programul (de exemplu, într-un interpret), iar memoria mașinii corespunde cu unora regiunea de memorie a nucleului (de exemplu, datele unui pachet primit).

Cele de mai sus ne vor fi suficiente pentru a începe să ne uităm la exemple: ne vom familiariza cu sistemul și formatul de comandă, după caz. Dacă doriți să studiați imediat sistemul de comandă al unei mașini virtuale și să aflați despre toate capacitățile sale, atunci puteți citi articolul original Filtrul de pachete BSD și/sau prima jumătate a dosarului Documentație/rețea/filter.txt din documentația nucleului. În plus, puteți studia prezentarea libpcap: O arhitectură și o metodologie de optimizare pentru capturarea pachetelor, în care McCanne, unul dintre autorii BPF, vorbește despre istoria creației libpcap.

Acum trecem să luăm în considerare toate exemplele semnificative de utilizare a BPF clasic pe Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

Dezvoltarea BPF a fost realizată în paralel cu dezvoltarea frontend-ului pentru filtrarea pachetelor - un utilitar binecunoscut tcpdump. Și, deoarece acesta este cel mai vechi și mai faimos exemplu de utilizare a BPF clasic, disponibil pe multe sisteme de operare, vom începe studiul nostru asupra tehnologiei cu acesta.

(Am rulat toate exemplele din acest articol pe Linux 5.6.0-rc6. Ieșirea unor comenzi a fost editată pentru o mai bună lizibilitate.)

Exemplu: observarea pachetelor IPv6

Să ne imaginăm că vrem să ne uităm la toate pachetele IPv6 de pe o interfață eth0. Pentru a face acest lucru putem rula programul tcpdump cu un filtru simplu ip6:

$ sudo tcpdump -i eth0 ip6

În acest caz, tcpdump compilează filtrul ip6 în codul octet al arhitecturii BPF și trimiteți-l la kernel (vezi detaliile în secțiunea Tcpdump: se încarcă). Filtrul încărcat va fi rulat pentru fiecare pachet care trece prin interfață eth0. Dacă filtrul returnează o valoare diferită de zero n, apoi până la n octeții pachetului vor fi copiați în spațiul utilizatorului și îl vom vedea în rezultat tcpdump.

BPF pentru cei mici, partea zero: BPF clasic

Se pare că putem afla cu ușurință ce bytecode a fost trimis la kernel tcpdump cu ajutorul tcpdump, dacă îl rulăm cu opțiunea -d:

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

Pe linia zero rulăm comanda ldh [12], care înseamnă „încărcare în registru A jumătate de cuvânt (16 biți) situat la adresa 12” și singura întrebare este ce fel de memorie ne adresăm? Răspunsul este că la x începe (x+1)al-lea octet al pachetului de rețea analizat. Citim pachete de pe interfața Ethernet eth0si asta mijloacecă pachetul arată astfel (pentru simplitate, presupunem că nu există etichete VLAN în pachet):

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

Deci după executarea comenzii ldh [12] în registru A va fi un câmp Ether Type — tipul de pachet transmis în acest cadru Ethernet. Pe linia 1 comparăm conținutul registrului A (tipul pachetului) c 0x86ddsi asta și au Tipul care ne interesează este IPv6. Pe linia 1, pe lângă comanda de comparare, mai există două coloane - jt 2 и jf 3 — note la care trebuie să mergeți dacă comparația are succes (A == 0x86dd) și fără succes. Deci, într-un caz de succes (IPv6) mergem la linia 2, iar în cazul nereușit - la linia 3. Pe linia 3 programul se termină cu codul 0 (nu copiați pachetul), pe linia 2 programul se termină cu cod 262144 (copiați-mi un pachet de maximum 256 kiloocteți).

Un exemplu mai complicat: ne uităm la pachetele TCP după portul de destinație

Să vedem cum arată un filtru care copiază toate pachetele TCP cu portul de destinație 666. Vom lua în considerare cazul IPv4, deoarece cazul IPv6 este mai simplu. După ce ați studiat acest exemplu, puteți explora singur filtrul IPv6 ca exercițiu (ip6 and tcp dst port 666) și un filtru pentru cazul general (tcp dst port 666). Deci, filtrul care ne interesează arată astfel:

$ 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

Știm deja ce fac liniile 0 și 1. Pe linia 2 am verificat deja că acesta este un pachet IPv4 (Ether Type = 0x800) și încărcați-l în registru A Al 24-lea octet al pachetului. Pachetul nostru arată ca

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

ceea ce înseamnă că încărcăm în registru A câmpul Protocol al antetului IP, care este logic, deoarece dorim să copiem doar pachete TCP. Comparăm Protocolul cu 0x6 (IPPROTO_TCP) pe linia 3.

Pe rândurile 4 și 5 încărcăm jumătățile de cuvinte situate la adresa 20 și folosim comanda jset verificați dacă unul dintre cele trei este setat steaguri - purtarea măștii eliberate jset cei trei biți cei mai semnificativi sunt șterși. Doi dintre cei trei biți ne spun dacă pachetul face parte dintr-un pachet IP fragmentat și, dacă da, dacă este ultimul fragment. Al treilea bit este rezervat și trebuie să fie zero. Nu vrem să verificăm pachetele incomplete sau rupte, așa că verificăm toți cei trei biți.

Linia 6 este cea mai interesantă din această listă. Expresie ldxb 4*([14]&0xf) înseamnă că încărcăm în registru X cei mai puțin semnificativi patru biți ai celui de-al cincisprezecelea octet al pachetului înmulțiți cu 4. Cei mai puțin semnificativi patru biți ai celui de-al cincisprezecelea octet sunt câmpul Lungimea antetului Internetului Antet IPv4, care stochează lungimea antetului în cuvinte, așa că trebuie apoi să înmulțiți cu 4. Interesant este că expresia 4*([14]&0xf) este o desemnare pentru o schemă specială de adresare care poate fi utilizată numai în acest formular și numai pentru un registru X, adică nici noi nu putem spune ldb 4*([14]&0xf) sau ldxb 5*([14]&0xf) (putem specifica doar un offset diferit, de exemplu, ldxb 4*([16]&0xf)). Este clar că această schemă de adresare a fost adăugată la BPF tocmai pentru a primi X (registru index) lungime antet IPv4.

Deci pe linia 7 încercăm să încărcăm o jumătate de cuvânt la (X+16). Amintiți-vă că 14 octeți sunt ocupați de antetul Ethernet și X conține lungimea antetului IPv4, înțelegem că în A Portul de destinație TCP este încărcat:

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

În cele din urmă, pe linia 8 comparăm portul de destinație cu valoarea dorită și pe rândurile 9 sau 10 returnăm rezultatul - dacă să copiem pachetul sau nu.

Tcpdump: se încarcă

În exemplele anterioare, nu ne-am oprit în detaliu asupra modului exact în care încărcăm bytecode BPF în nucleu pentru filtrarea pachetelor. In general vorbind, tcpdump portat la multe sisteme și pentru lucrul cu filtre tcpdump folosește biblioteca libpcap. Pe scurt, pentru a plasa un filtru pe o interfață folosind libpcap, trebuie să faceți următoarele:

Pentru a vedea cum funcționează pcap_setfilter implementat în Linux, folosim strace (unele rânduri au fost eliminate):

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

Pe primele două linii de ieșire le creăm priză brută pentru a citi toate cadrele Ethernet și a le lega la interfață eth0. De la primul nostru exemplu știm că filtrul ip va consta din patru instrucțiuni BPF, iar pe a treia linie vedem cum se utilizează opțiunea SO_ATTACH_FILTER apel de sistem setsockopt încărcăm și conectăm un filtru de lungime 4. Acesta este filtrul nostru.

Este de remarcat faptul că în BPF clasic, încărcarea și conectarea unui filtru are loc întotdeauna ca o operație atomică, iar în noua versiune a BPF, încărcarea programului și legarea acestuia la generatorul de evenimente sunt separate în timp.

Adevărul Ascuns

O versiune puțin mai completă a rezultatului arată astfel:

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

După cum am menționat mai sus, încărcăm și conectăm filtrul nostru la priza de pe linia 5, dar ce se întâmplă pe liniile 3 și 4? Se dovedește că asta libpcap are grija de noi - astfel incat iesirea filtrului nostru sa nu includa pachete care nu il satisfac, biblioteca conectează filtru fals ret #0 (elimină toate pachetele), comută soclul în modul neblocant și încearcă să scadă toate pachetele care ar putea rămâne din filtrele anterioare.

În total, pentru a filtra pachetele pe Linux folosind BPF clasic, trebuie să aveți un filtru sub forma unei structuri precum struct sock_fprog și o priză deschisă, după care filtrul poate fi atașat la priză folosind un apel de sistem setsockopt.

Interesant este că filtrul poate fi atașat la orice priză, nu doar brut. Aici exemplu un program care decupează toți, cu excepția primilor doi octeți, din toate datagramele UDP primite. (Am adăugat comentarii în cod pentru a nu aglomera articolul.)

Mai multe detalii despre utilizare setsockopt pentru conectarea filtrelor, vezi priză (7), ci despre scrierea propriilor filtre ca struct sock_fprog fără ajutor tcpdump vorbim in sectiune Programarea BPF cu propriile noastre mâini.

BPF clasic și secolul XNUMX

BPF a fost inclus în Linux în 1997 și a rămas un cal de muncă multă vreme libpcap fără modificări speciale (modificări specifice Linux, desigur, au fost, dar nu au schimbat imaginea globală). Primele semne serioase că BPF ar evolua au venit în 2011, când Eric Dumazet a propus plasture, care adaugă Just In Time Compiler la nucleu - un traducător pentru conversia codului de octet BPF în nativ x86_64 cod.

Compilatorul JIT a fost primul din lanțul de modificări: în 2012 a apărut capacitatea de a scrie filtre pentru seccomp, folosind BPF, în ianuarie 2013 a existat adăugat modul xt_bpf, care vă permite să scrieți reguli pentru iptables cu ajutorul BPF, iar în octombrie 2013 a fost adăugat de asemenea un modul cls_bpf, care vă permite să scrieți clasificatoare de trafic folosind BPF.

Ne vom uita la toate aceste exemple mai detaliat în curând, dar mai întâi ne va fi util să învățăm cum să scriem și să compilam programe arbitrare pentru BPF, deoarece capacitățile oferite de bibliotecă libpcap limitat (exemplu simplu: filtru generat libpcap poate returna doar două valori - 0 sau 0x40000) sau, în general, ca în cazul seccomp, nu sunt aplicabile.

Programarea BPF cu propriile noastre mâini

Să ne familiarizăm cu formatul binar al instrucțiunilor BPF, este foarte simplu:

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

Fiecare instrucțiune ocupă 64 de biți, în care primii 16 biți sunt codul instrucțiunii, apoi există două indentări de opt biți, jt и jf, și 32 de biți pentru argument K, al cărui scop variază de la comandă la comandă. De exemplu, comanda ret, care termină programul are codul 6, iar valoarea returnată este luată din constantă K. În C, o singură instrucțiune BPF este reprezentată ca structură

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

iar întregul program este sub forma unei structuri

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

Astfel, putem deja să scriem programe (de exemplu, știm codurile de instrucțiuni din [1]). Așa va arăta filtrul ip6 de primul nostru exemplu:

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 putem folosi legal într-un apel

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

Scrierea de programe sub formă de coduri de mașină nu este foarte convenabilă, dar uneori este necesară (de exemplu, pentru depanare, crearea de teste unitare, scrierea articolelor despre Habré etc.). Pentru comoditate, în dosar <linux/filter.h> macrocomenzile helper sunt definite - același exemplu ca mai sus ar putea fi rescris ca

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

Cu toate acestea, această opțiune nu este foarte convenabilă. Acesta este ceea ce au motivat programatorii kernel-ului Linux și, prin urmare, în director tools/bpf nuclee puteți găsi un asamblator și un depanator pentru a lucra cu BPF clasic.

Limbajul de asamblare este foarte asemănător cu ieșirea de depanare tcpdump, dar în plus putem specifica etichete simbolice. De exemplu, iată un program care elimină toate pachetele, cu excepția TCP/IPv4:

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

În mod implicit, asamblatorul generează cod în format <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., pentru exemplul nostru cu TCP va fi

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

Pentru comoditatea programatorilor C, poate fi folosit un format de ieșire diferit:

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

Acest text poate fi copiat în definiția structurii de tip struct sock_filter, așa cum am făcut la începutul acestei secțiuni.

Linux și extensii netsniff-ng

Pe lângă BPF standard, Linux și tools/bpf/bpf_asm sprijin și set non-standard. Practic, instrucțiunile sunt folosite pentru a accesa câmpurile unei structuri struct sk_buff, care descrie un pachet de rețea din nucleu. Cu toate acestea, există și alte tipuri de instrucțiuni de ajutor, de exemplu ldw cpu se va încărca în registru A rezultat al rulării unei funcții de nucleu raw_smp_processor_id(). (În noua versiune de BPF, aceste extensii non-standard au fost extinse pentru a oferi programelor un set de ajutoare pentru nucleu pentru accesarea memoriei, structurilor și generarea de evenimente.) Iată un exemplu interesant de filtru în care copiem doar antetele pachetelor în spațiul utilizatorului folosind extensia poff, compensarea sarcinii utile:

ld poff
ret a

Extensiile BPF nu pot fi utilizate în tcpdump, dar acesta este un motiv bun pentru a vă familiariza cu pachetul de utilitate netsniff-ng, care, printre altele, conține un program avansat netsniff-ng, care, pe lângă filtrarea folosind BPF, conține și un generator de trafic eficient și mai avansat decât tools/bpf/bpf_asm, numit un asamblator BPF bpfc. Pachetul conține documentație destul de detaliată, vezi și linkurile de la sfârșitul articolului.

seccomp

Deci, știm deja cum să scriem programe BPF de complexitate arbitrară și suntem gata să privim noi exemple, primul dintre care este tehnologia seccomp, care permite, folosind filtre BPF, să gestionăm setul și setul de argumente de apel de sistem disponibile pentru un proces dat și descendenții acestuia.

Prima versiune de seccomp a fost adăugată la nucleu în 2005 și nu a fost foarte populară, deoarece a oferit doar o singură opțiune - pentru a limita setul de apeluri de sistem disponibile unui proces la următoarele: read, write, exit и sigreturn, iar procesul care a încălcat regulile a fost ucis folosind SIGKILL. Cu toate acestea, în 2012, seccomp a adăugat posibilitatea de a utiliza filtre BPF, permițându-vă să definiți un set de apeluri de sistem permise și chiar să efectuați verificări ale argumentelor acestora. (Interesant, Chrome a fost unul dintre primii utilizatori ai acestei funcționalități, iar oamenii Chrome dezvoltă în prezent un mecanism KRSI bazat pe o nouă versiune a BPF și care permite personalizarea modulelor de securitate Linux.) Link-uri către documentație suplimentară pot fi găsite la sfârșit. a articolului.

Rețineți că au existat deja articole pe hub despre utilizarea seccomp, poate cineva va dori să le citească înainte (sau în loc să citească) următoarele subsecțiuni. In articol Containere si securitate: seccomp oferă exemple de utilizare a seccomp, atât versiunea 2007, cât și versiunea care utilizează BPF (filtrele sunt generate folosind libseccomp), vorbește despre conexiunea seccomp cu Docker și oferă, de asemenea, multe link-uri utile. In articol Izolarea demonilor cu systemd sau „nu aveți nevoie de Docker pentru asta!” Acesta acoperă, în special, cum să adăugați liste negre sau liste albe de apeluri de sistem pentru demonii care rulează systemd.

În continuare vom vedea cum să scriem și să încărcăm filtrele pentru seccomp în C gol și folosind biblioteca libseccomp și care sunt avantajele și dezavantajele fiecărei opțiuni și, în sfârșit, să vedem cum este folosit seccomp de către program strace.

Scrierea și încărcarea filtrelor pentru seccomp

Știm deja cum să scriem programe BPF, așa că să ne uităm mai întâi la interfața de programare seccomp. Puteți seta un filtru la nivel de proces și toate procesele secundare vor moșteni restricțiile. Acest lucru se face folosind un apel de sistem seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

unde &filter - acesta este un indicator către o structură deja familiară nouă struct sock_fprog, adică Programul BPF.

Cum diferă programele pentru seccomp de programele pentru socket? Context transmis. În cazul socket-urilor, ni s-a oferit o zonă de memorie care conține pachetul, iar în cazul seccomp ni s-a oferit o structură de genul

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

Aici nr este numărul apelului de sistem care urmează să fie lansat, arch - arhitectura actuală (mai multe despre aceasta mai jos), args - până la șase argumente de apel de sistem și instruction_pointer este un pointer către instrucțiunea spațiului utilizator care a efectuat apelul de sistem. Astfel, de exemplu, pentru a încărca numărul de apel de sistem în registru A trebuie să spunem

ldw [0]

Există și alte caracteristici pentru programele seccomp, de exemplu, contextul poate fi accesat doar prin aliniere pe 32 de biți și nu puteți încărca o jumătate de cuvânt sau un octet - atunci când încercați să încărcați un filtru ldh [0] apel de sistem seccomp va reveni EINVAL. Funcția verifică filtrele încărcate seccomp_check_filter() miezuri. (Lucru amuzant este că în commit-ul original care a adăugat funcționalitatea seccomp, au uitat să adauge permisiunea de a folosi instrucțiunea la această funcție mod (diviziune rest) și acum nu este disponibilă pentru programele seccomp BPF, de la adăugarea sa Se va rupe ABI.)

Practic, știm deja totul pentru a scrie și a citi programe seccomp. De obicei, logica programului este aranjată ca o listă albă sau neagră de apeluri de sistem, de exemplu programul

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

verifică o listă neagră de patru apeluri de sistem numerotate 304, 176, 239, 279. Care sunt aceste apeluri de sistem? Nu putem spune cu certitudine, deoarece nu știm pentru ce arhitectură a fost scris programul. Prin urmare, autorii seccomp ofertă porniți toate programele cu o verificare a arhitecturii (arhitectura curentă este indicată în context ca un câmp arch structuri struct seccomp_data). Cu arhitectura verificată, începutul exemplului ar arăta astfel:

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

și atunci numerele noastre de apel de sistem ar obține anumite valori.

Scriem și încărcăm filtre pentru seccomp folosind libseccomp

Scrierea filtrelor în cod nativ sau în asamblare BPF vă permite să aveți control deplin asupra rezultatului, dar, în același timp, uneori este de preferat să aveți cod portabil și/sau citibil. Biblioteca ne va ajuta cu asta libseccomp, care oferă o interfață standard pentru scrierea filtrelor alb sau negru.

Să scriem, de exemplu, un program care rulează un fișier binar la alegerea utilizatorului, având instalată anterior o listă neagră de apeluri de sistem de la articolul de mai sus (programul a fost simplificat pentru o mai mare lizibilitate, versiunea completă poate fi găsită aici):

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

Mai întâi definim o matrice sys_numbers a peste 40 de numere de sistem de blocat. Apoi, inițializați contextul ctx și spuneți bibliotecii ce vrem să permitem (SCMP_ACT_ALLOW) toate apelurile de sistem în mod implicit (este mai ușor să construiți liste negre). Apoi, unul câte unul, adăugăm toate apelurile de sistem din lista neagră. Ca răspuns la un apel de sistem din listă, solicităm SCMP_ACT_TRAP, în acest caz seccomp va trimite un semnal către proces SIGSYS cu o descriere a apelului de sistem care a încălcat regulile. În cele din urmă, încărcăm programul în nucleu folosind seccomp_load, care va compila programul și îl va atașa procesului folosind un apel de sistem seccomp(2).

Pentru o compilare cu succes, programul trebuie să fie conectat cu biblioteca libseccomp, de exemplu:

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

Exemplu de lansare reușită:

$ ./seccomp_lib echo ok
ok

Exemplu de apel de sistem blocat:

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

Folosim stracepentru detalii:

$ 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

de unde putem ști că programul a fost încheiat din cauza utilizării unui apel de sistem ilegal mount(2).

Deci, am scris un filtru folosind biblioteca libseccomp, încadrând codul non-trivial în patru rânduri. În exemplul de mai sus, dacă există un număr mare de apeluri de sistem, timpul de execuție poate fi redus considerabil, deoarece verificarea este doar o listă de comparații. Pentru optimizare, libseccomp a avut recent plasture inclus, care adaugă suport pentru atributul de filtru SCMP_FLTATR_CTL_OPTIMIZE. Setarea acestui atribut la 2 va converti filtrul într-un program de căutare binar.

Dacă doriți să vedeți cum funcționează filtrele binare de căutare, aruncați o privire la script simplu, care generează astfel de programe în asamblatorul BPF prin formarea numerelor de apel de sistem, de exemplu:

$ 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

Este imposibil să scrieți ceva semnificativ mai rapid, deoarece programele BPF nu pot efectua salturi de indentare (nu putem face, de exemplu, jmp A sau jmp [label+X]) și prin urmare toate tranzițiile sunt statice.

seccomp si strace

Toată lumea știe utilitatea strace este un instrument indispensabil pentru studierea comportamentului proceselor pe Linux. Cu toate acestea, mulți au auzit și despre probleme de performanta atunci când utilizați acest utilitar. Adevărul este că strace implementat folosind ptrace(2), iar în acest mecanism nu putem specifica la ce set de apeluri de sistem trebuie să oprim procesul, adică, de exemplu, comenzi

$ 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

sunt procesate aproximativ în același timp, deși în al doilea caz dorim să urmărim un singur apel de sistem.

Opțiune nouă --seccomp-bpf, adăugat la strace versiunea 5.3, vă permite să accelerați procesul de mai multe ori, iar timpul de pornire sub urma unui apel de sistem este deja comparabil cu timpul unei porniri obișnuite:

$ 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

(Aici, desigur, există o ușoară înșelăciune prin faptul că nu urmărim apelul de sistem principal al acestei comenzi. Dacă am urmări, de exemplu, newfsstat, Apoi, strace ar frâna la fel de tare ca și fără --seccomp-bpf.)

Cum funcționează această opțiune? Fara ea strace se conectează la proces și îl începe să utilizeze PTRACE_SYSCALL. Când un proces gestionat emite (orice) apel de sistem, controlul este transferat către strace, care analizează argumentele apelului de sistem și îl rulează folosind PTRACE_SYSCALL. După ceva timp, procesul finalizează apelul de sistem și la ieșirea din acesta, controlul este transferat din nou strace, care se uită la valorile returnate și începe procesul folosind PTRACE_SYSCALL, și așa mai departe.

BPF pentru cei mici, partea zero: BPF clasic

Cu seccomp, totuși, acest proces poate fi optimizat exact așa cum ne-am dori. Și anume, dacă vrem să ne uităm doar la apelul de sistem X, atunci putem scrie un filtru BPF pentru care X returnează o valoare SECCOMP_RET_TRACEși pentru apelurile care nu ne interesează - SECCOMP_RET_ALLOW:

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

În acest caz strace începe inițial procesul ca PTRACE_CONT, filtrul nostru este procesat pentru fiecare apel de sistem, dacă apelul de sistem nu este X, atunci procesul continuă să ruleze, dar dacă acest lucru X, apoi seccomp va transfera controlul stracecare se va uita la argumente și va începe procesul ca PTRACE_SYSCALL (deoarece seccomp nu are capacitatea de a rula un program la ieșirea dintr-un apel de sistem). Când apelul de sistem revine, strace va reporni procesul folosind PTRACE_CONT și va aștepta mesaje noi de la seccomp.

BPF pentru cei mici, partea zero: BPF clasic

Când utilizați opțiunea --seccomp-bpf sunt doua restrictii. În primul rând, nu va fi posibil să vă alăturați unui proces deja existent (opțiune -p programe strace), deoarece acest lucru nu este acceptat de seccomp. În al doilea rând, nu există nicio posibilitate nu uitați-vă la procesele copil, deoarece filtrele seccomp sunt moștenite de toate procesele copil fără posibilitatea de a dezactiva acest lucru.

Mai multe detalii despre cum anume strace Functioneaza cu seccomp poate fi găsit de la raport recent. Pentru noi, cel mai interesant fapt este că clasicul BPF reprezentat de seccomp este folosit și astăzi.

xt_bpf

Să ne întoarcem acum la lumea rețelelor.

Context: cu mult timp în urmă, în 2007, miezul era adăugat modul xt_u32 pentru netfilter. A fost scris prin analogie cu un clasificator de trafic și mai vechi cls_u32 și v-a permis să scrieți reguli binare arbitrare pentru iptables folosind următoarele operații simple: încărcați 32 de biți dintr-un pachet și efectuați un set de operații aritmetice asupra acestora. De exemplu,

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

Încarcă cei 32 de biți ai antetului IP, începând cu padding 6 și le aplică o mască 0xFF (luați octetul mic). Acest câmp protocol Antetul IP și îl comparăm cu 1 (ICMP). Într-o singură regulă puteți combina multe verificări și puteți executa și operatorul @ — mutați X octeți la dreapta. De exemplu, regula

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

verifică dacă Numărul de secvență TCP nu este egal 0x29. Nu voi intra în detalii mai departe, deoarece este deja clar că scrierea manuală a unor astfel de reguli nu este foarte convenabilă. In articol BPF - bytecode uitat, există mai multe link-uri cu exemple de utilizare și generare de reguli pentru xt_u32. Vezi și linkurile de la sfârșitul acestui articol.

Din 2013 modul în loc de modul xt_u32 puteți utiliza un modul bazat pe BPF xt_bpf. Oricine a citit până aici ar trebui să fie deja clar despre principiul funcționării acestuia: rulați codul de octeți BPF ca reguli iptables. Puteți crea o nouă regulă, de exemplu, astfel:

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

aici <байткод> - acesta este codul în format de ieșire de asamblare bpf_asm implicit, de exemplu,

$ 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

În acest exemplu, filtram toate pachetele UDP. Context pentru un program BPF într-un modul xt_bpf, desigur, indică către pachetele de date, în cazul iptables, spre începutul antetului IPv4. Valoarea returnată din programul BPF booleanUnde false înseamnă că pachetul nu s-a potrivit.

Este clar că modulul xt_bpf acceptă filtre mai complexe decât exemplul de mai sus. Să ne uităm la exemple reale din Cloudfare. Până de curând au folosit modulul xt_bpf pentru a vă proteja împotriva atacurilor DDoS. In articol Vă prezentăm instrumentele BPF ei explică cum (și de ce) generează filtre BPF și publică link-uri către un set de utilități pentru crearea unor astfel de filtre. De exemplu, folosind utilitarul bpfgen puteți crea un program BPF care se potrivește cu o interogare DNS pentru un nume 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

În program încărcăm mai întâi în registru X adresa de început a liniei x04habrx03comx00 în interiorul unei datagrame UDP și apoi verificați solicitarea: 0x04686162 <-> "x04hab" etc

Puțin mai târziu, Cloudfare a publicat codul de compilare p0f -> BPF. In articol Vă prezentăm compilatorul p0f BPF ei vorbesc despre ce este p0f și cum să convertești semnăturile p0f în 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,
...

În prezent, nu mai folosește Cloudfare xt_bpf, deoarece s-au mutat la XDP - una dintre opțiunile de utilizare a noii versiuni de BPF, vezi. L4Drop: atenuări XDP DDoS.

cls_bpf

Ultimul exemplu de utilizare a BPF clasic în nucleu este clasificatorul cls_bpf pentru subsistemul de control al traficului din Linux, adăugat la Linux la sfârșitul anului 2013 și înlocuind conceptual vechiul cls_u32.

Cu toate acestea, nu vom descrie acum lucrarea cls_bpf, deoarece din punctul de vedere al cunoștințelor despre BPF clasic, acest lucru nu ne va oferi nimic - ne-am familiarizat deja cu toată funcționalitatea. În plus, în articolele ulterioare care vorbesc despre Extended BPF, vom întâlni acest clasificator de mai multe ori.

Un alt motiv pentru a nu vorbi despre utilizarea BPF clasică c cls_bpf Problema este că, în comparație cu Extended BPF, domeniul de aplicare în acest caz este radical restrâns: programele clasice nu pot modifica conținutul pachetelor și nu pot salva starea între apeluri.

Așa că este timpul să ne luăm rămas bun de la BPF clasic și să privim spre viitor.

Adio BPF clasic

Ne-am uitat la modul în care tehnologia BPF, dezvoltată la începutul anilor nouăzeci, a trăit cu succes timp de un sfert de secol și până la sfârșit a găsit noi aplicații. Cu toate acestea, similar cu trecerea de la mașinile stive la RISC, care a servit ca un impuls pentru dezvoltarea BPF clasic, în anii 32 a existat o tranziție de la mașinile pe 64 de biți la mașinile pe XNUMX de biți și BPF clasic a început să devină învechit. În plus, capacitățile BPF clasice sunt foarte limitate și, pe lângă arhitectura învechită - nu avem capacitatea de a salva starea între apelurile la programele BPF, nu există posibilitatea de interacțiune directă cu utilizatorul, nu există posibilitatea de a interacționa cu nucleul, cu excepția citirii unui număr limitat de câmpuri de structură sk_buff și lansând cele mai simple funcții de ajutor, nu puteți modifica conținutul pachetelor și le redirecționați.

De fapt, în prezent tot ce rămâne din BPF clasic în Linux este interfața API, iar în interiorul nucleului toate programele clasice, fie că este vorba de filtre socket sau filtre seccomp, sunt traduse automat într-un nou format, Extended BPF. (Vom vorbi despre exact cum se întâmplă acest lucru în articolul următor.)

Tranziția la o nouă arhitectură a început în 2013, când Alexey Starovoitov a propus o schemă de actualizare BPF. În 2014 patch-urile corespunzătoare a inceput sa apara în miez. Din câte am înțeles, planul inițial a fost doar de a optimiza arhitectura și compilatorul JIT pentru a rula mai eficient pe mașini pe 64 de biți, dar în schimb aceste optimizări au marcat începutul unui nou capitol în dezvoltarea Linux.

Alte articole din această serie vor acoperi arhitectura și aplicațiile noii tehnologii, cunoscută inițial ca BPF intern, apoi BPF extins și acum pur și simplu BPF.

referințe

  1. Steven McCanne și Van Jacobson, „Filtrul de pachete BSD: O nouă arhitectură pentru capturarea pachetelor la nivel de utilizator”, https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, „libpcap: O arhitectură și o metodologie de optimizare pentru capturarea pachetelor”, https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. Tutorial de potrivire IPtable U32.
  5. BPF - bytecode uitat: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Vă prezentăm instrumentul BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. O prezentare generală a seccomp: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Containere si securitate: seccomp
  11. habr: Izolarea demonilor cu systemd sau „nu aveți nevoie de Docker pentru asta!”
  12. Paul Chaignon, „strace --seccomp-bpf: o privire sub capotă”, https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Sursa: www.habr.com

Adauga un comentariu