BPF pre najmenších, časť nula: klasický BPF

Berkeley Packet Filters (BPF) je technológia linuxového jadra, ktorá je už niekoľko rokov na titulných stránkach technických publikácií v anglickom jazyku. Konferencie sú plné správ o využívaní a vývoji BPF. David Miller, správca sieťového subsystému Linux, zvoláva svoju prednášku na Linux Plumbers 2018 “Táto diskusia nie je o XDP” (XDP je jeden prípad použitia pre BPF). Brendan Gregg vedie prednášky s názvom Linux BPF Superpowers. Toke Høiland-Jørgensen smeje saže jadro je teraz mikrokernel. Thomas Graf presadzuje myšlienku, že BPF je javascript pre jadro.

Na Habré stále neexistuje systematický popis BPF, a preto sa v sérii článkov pokúsim porozprávať o histórii technológie, popísať architektúru a vývojové nástroje a načrtnúť oblasti aplikácie a praxe používania BPF. Tento článok, nula zo série, rozpráva o histórii a architektúre klasického BPF a tiež odhaľuje tajomstvá jeho princípov fungovania. tcpdump, seccomp, strace, a oveľa viac.

Vývoj BPF je riadený sieťovou komunitou Linuxu, hlavné existujúce aplikácie BPF súvisia so sieťami, a preto so súhlasom @eucariot, sériu som nazval „BPF pre najmenších“, na počesť veľkej série "Siete pre najmenších".

Krátky kurz histórie BPF(c)

Moderná technológia BPF je vylepšená a rozšírená verzia starej technológie s rovnakým názvom, ktorá sa teraz nazýva klasická BPF, aby nedošlo k zámene. Na základe klasického BPF vznikla známa utilita tcpdump, mechanizmus seccomp, ako aj menej známe moduly xt_bpf pre iptables a klasifikátor cls_bpf. V modernom Linuxe sa klasické BPF programy automaticky prekladajú do novej podoby, avšak z užívateľského hľadiska zostalo API na svojom mieste a stále sa nachádzajú nové možnosti využitia klasického BPF, ako uvidíme v tomto článku. Z tohto dôvodu a tiež preto, že po histórii vývoja klasického BPF v Linuxe bude jasnejšie, ako a prečo sa vyvinul do modernej podoby, som sa rozhodol začať článkom o klasickom BPF.

Koncom osemdesiatych rokov minulého storočia sa inžinieri zo slávneho laboratória Lawrence Berkeley začali zaujímať o otázku, ako správne filtrovať sieťové pakety na hardvéri, ktorý bol moderný koncom osemdesiatych rokov minulého storočia. Základnou myšlienkou filtrovania, pôvodne implementovaného v technológii CSPF (CMU/Stanford Packet Filter), bolo filtrovanie nepotrebných paketov čo najskôr, t.j. v priestore jadra, pretože sa tým zabráni kopírovaniu nepotrebných údajov do používateľského priestoru. Na zabezpečenie zabezpečenia behu pre spustenie používateľského kódu v priestore jadra sa použil virtuálny stroj v karanténe.

Virtuálne stroje pre existujúce filtre však boli navrhnuté tak, aby bežali na strojoch so zásobníkom a na novších strojoch RISC nebežali tak efektívne. Výsledkom je, že vďaka úsiliu inžinierov z Berkeley Labs bola vyvinutá nová technológia BPF (Berkeley Packet Filters), ktorej architektúra virtuálneho stroja bola navrhnutá na základe procesora Motorola 6502 – ťažného koňa takých známych produktov, ako je napr. Apple II alebo NES. Nový virtuálny stroj zvýšil výkon filtra desaťkrát v porovnaní s existujúcimi riešeniami.

Architektúra stroja BPF

Pracovným spôsobom sa zoznámime s architektúrou na príkladoch. Na začiatok však povedzme, že stroj mal dva 32-bitové registre prístupné používateľovi, akumulátor A a indexový register X, 64 bajtov pamäte (16 slov), dostupných na zápis a následné čítanie, a malý systém príkazov na prácu s týmito objektmi. V programoch boli k dispozícii aj skokové inštrukcie na implementáciu podmienených výrazov, ale aby sa zaručilo včasné dokončenie programu, skoky bolo možné robiť len dopredu, t.j. najmä bolo zakázané vytvárať slučky.

Všeobecná schéma spustenia stroja je nasledovná. Používateľ vytvorí program pre architektúru BPF a pomocou niektoré mechanizmus jadra (napríklad systémové volanie), načíta program a pripojí sa k nemu pre niektoré ku generátoru udalostí v jadre (napríklad udalosť je príchod ďalšieho paketu na sieťovú kartu). Keď nastane udalosť, jadro spustí program (napríklad v tlmočníku) a pamäť počítača zodpovedá pre niektoré oblasť pamäte jadra (napríklad údaje prichádzajúceho paketu).

Vyššie uvedené nám bude stačiť na to, aby sme sa mohli pozrieť na príklady: podľa potreby sa zoznámime so systémom a formátom príkazov. Ak chcete okamžite študovať príkazový systém virtuálneho stroja a dozvedieť sa o všetkých jeho schopnostiach, môžete si prečítať pôvodný článok Paketový filter BSD a/alebo prvá polovica súboru Documentation/networking/filter.txt z dokumentácie jadra. Okrem toho si môžete preštudovať prezentáciu libpcap: Architektúra a metodika optimalizácie pre zachytávanie paketov, v ktorej McCanne, jeden z autorov BPF, hovorí o histórii stvorenia libpcap.

Teraz prejdeme k zváženiu všetkých významných príkladov používania klasického BPF v systéme Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

Vývoj BPF prebiehal súbežne s vývojom frontendu pre filtrovanie paketov - známej utility tcpdump. A keďže ide o najstarší a najznámejší príklad použitia klasického BPF, dostupného na mnohých operačných systémoch, začneme našu štúdiu technológie práve ním.

(Spustil som všetky príklady v tomto článku o Linuxe 5.6.0-rc6. Výstup niektorých príkazov bol upravený kvôli lepšej čitateľnosti.)

Príklad: pozorovanie paketov IPv6

Predstavme si, že sa chceme pozrieť na všetky pakety IPv6 na rozhraní eth0. Aby sme to dosiahli, môžeme spustiť program tcpdump s jednoduchým filtrom ip6:

$ sudo tcpdump -i eth0 ip6

V tomto prípade, tcpdump zostaví filter ip6 do bajtkódu architektúry BPF a odošlite ho do jadra (podrobnosti nájdete v časti Tcpdump: načítava sa). Načítaný filter sa spustí pre každý paket prechádzajúci cez rozhranie eth0. Ak filter vráti nenulovú hodnotu n, potom až n bajtov paketu sa skopíruje do užívateľského priestoru a uvidíme to vo výstupe tcpdump.

BPF pre najmenších, časť nula: klasický BPF

Ukazuje sa, že ľahko zistíme, ktorý bajtkód bol odoslaný do jadra tcpdump s pomocou tcpdump, ak ho spustíme s opciou -d:

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

Na riadku nula spustíme príkaz ldh [12], čo znamená „načítať do registra A pol slova (16 bitov) umiestneného na adrese 12” a jedinou otázkou je, aký druh pamäte riešime? Odpoveď je, že na x začína (x+1)bajtu analyzovaného sieťového paketu. Čítame pakety z rozhrania Ethernet eth0a to rozumieže paket vyzerá takto (pre jednoduchosť predpokladáme, že v pakete nie sú žiadne značky VLAN):

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

Takže po vykonaní príkazu ldh [12] v registri A bude pole Ether Type — typ paketu prenášaného v tomto ethernetovom rámci. Na riadku 1 porovnávame obsah registra A (typ balenia) c 0x86dda to a tam je Typ, ktorý nás zaujíma, je IPv6. Na riadku 1 sú okrem porovnávacieho príkazu ďalšie dva stĺpce - jt 2 и jf 3 — známky, ku ktorým sa musíte dostať, ak je porovnanie úspešné (A == 0x86dd) a neúspešne. Takže v úspešnom prípade (IPv6) prejdeme na riadok 2 av neúspešnom prípade na riadok 3. Na riadku 3 program končí kódom 0 (nekopírujte paket), na riadku 2 program končí kódom 262144 (skopírujte mi maximálne 256 kilobajtový balík).

Zložitejší príklad: pozrieme sa na TCP pakety podľa cieľového portu

Pozrime sa, ako vyzerá filter, ktorý skopíruje všetky TCP pakety s cieľovým portom 666. Budeme uvažovať o prípade IPv4, pretože prípad IPv6 je jednoduchší. Po preštudovaní tohto príkladu si môžete sami preskúmať filter IPv6 ako cvičenie (ip6 and tcp dst port 666) a filter pre všeobecný prípad (tcp dst port 666). Takže filter, ktorý nás zaujíma, vyzerá takto:

$ 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

Už vieme, čo robia riadky 0 a 1. Na riadku 2 sme už skontrolovali, že ide o paket IPv4 (Ether Type = 0x800) a nahrajte ho do registra A 24. bajt paketu. Náš balík vyzerá

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

čo znamená, že načítame do registra A pole Protocol hlavičky IP, čo je logické, pretože chceme kopírovať iba pakety TCP. Protokol porovnávame s 0x6 (IPPROTO_TCP) na riadku 3.

Na riadky 4 a 5 načítame polslová nachádzajúce sa na adrese 20 a použijeme príkaz jset skontrolujte, či je nastavený jeden z troch vlajky - nosenie vydanej masky jset tri najvýznamnejšie bity sa vymažú. Dva z troch bitov nám hovoria, či je paket súčasťou fragmentovaného IP paketu, a ak áno, či ide o posledný fragment. Tretí bit je rezervovaný a musí byť nula. Nechceme kontrolovať ani neúplné, ani poškodené pakety, preto kontrolujeme všetky tri bity.

Riadok 6 je najzaujímavejší v tomto zozname. Výraz ldxb 4*([14]&0xf) znamená, že načítame do registra X najmenej významné štyri bity pätnásteho bajtu paketu vynásobené 4. Najmenej významné štyri bity pätnásteho bajtu je pole Dĺžka internetovej hlavičky IPv4 hlavička, v ktorej je uložená dĺžka hlavičky v slovách, takže ju následne treba vynásobiť 4. Zaujímavosťou je, že výraz 4*([14]&0xf) je označenie špeciálnej adresnej schémy, ktorú možno použiť len v tejto forme a len pre register X, t.j. ani my nevieme povedať ldb 4*([14]&0xf) ani ldxb 5*([14]&0xf) (môžeme zadať iba iný posun, napr. ldxb 4*([16]&0xf)). Je jasné, že táto schéma adresovania bola pridaná do BPF práve s cieľom prijímať X (indexový register) dĺžka hlavičky IPv4.

Takže na riadku 7 sa snažíme načítať pol slova na (X+16). Pamätajte, že 14 bajtov zaberá hlavička Ethernet a X obsahuje dĺžku hlavičky IPv4, rozumieme, že v A Cieľový port TCP je načítaný:

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

Nakoniec na riadku 8 porovnáme cieľový port s požadovanou hodnotou a na riadkoch 9 alebo 10 vrátime výsledok - či sa má paket skopírovať alebo nie.

Tcpdump: načítava sa

V predchádzajúcich príkladoch sme sa konkrétne nezaoberali podrobne tým, ako presne načítame bajtový kód BPF do jadra na filtrovanie paketov. Všeobecne povedané, tcpdump portované do mnohých systémov a na prácu s filtrami tcpdump používa knižnicu libpcap. Stručne povedané, umiestniť filter na rozhranie pomocou libpcap, musíte urobiť nasledovné:

Ak chcete vidieť, ako funguje pcap_setfilter implementované v Linuxe, používame strace (niektoré riadky boli odstránené):

$ 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 prvých dvoch riadkoch výstupu vytvoríme surová zásuvka prečítať všetky ethernetové rámce a naviazať ich na rozhranie eth0, Od náš prvý príklad vieme, že filter ip bude pozostávať zo štyroch inštrukcií BPF a na treťom riadku vidíme, ako sa táto možnosť používa SO_ATTACH_FILTER systémové volanie setsockopt naložíme a pripojíme filter dĺžky 4. Toto je náš filter.

Stojí za zmienku, že v klasickom BPF prebieha načítanie a pripojenie filtra vždy ako atómová operácia a v novej verzii BPF je načítanie programu a jeho naviazanie na generátor udalostí časovo oddelené.

Skrytá pravda

Trochu kompletnejšia verzia výstupu vyzerá takto:

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

Ako je uvedené vyššie, načítame a pripájame náš filter do zásuvky na riadku 5, ale čo sa stane na riadkoch 3 a 4? Ukazuje sa, že toto libpcap sa o nás stará - aby výstup nášho filtra nezahŕňal pakety, ktoré ho nevyhovujú, knižnica spája slepý filter ret #0 (zahodiť všetky pakety), prepne soket do neblokovacieho režimu a pokúsi sa odčítať všetky pakety, ktoré by mohli zostať z predchádzajúcich filtrov.

Celkovo na filtrovanie balíkov na Linuxe pomocou klasického BPF potrebujete mať filter vo forme štruktúry ako struct sock_fprog a otvorenú zásuvku, po ktorej možno filter pripevniť k zásuvke pomocou systémového volania setsockopt.

Zaujímavosťou je, že filter je možné pripevniť na akúkoľvek zásuvku, nielen surovú. Tu príklad program, ktorý odreže zo všetkých prichádzajúcich datagramov UDP všetky bajty okrem prvých dvoch. (Do kódu som pridal komentáre, aby sa článok nepreplnil.)

Viac podrobností o použití setsockopt pripojenie filtrov viď zásuvka (7), ale o písaní vlastných filtrov ako struct sock_fprog bez pomoci tcpdump porozprávame sa v sekcii Programovanie BPF vlastnými rukami.

Klasické BPF a XNUMX. storočie

BPF bol zahrnutý do Linuxu v roku 1997 a zostal ťažným koňom na dlhú dobu libpcap bez akýchkoľvek špeciálnych zmien (samozrejme, špecifické pre Linux, boli, ale nezmenili globálny obraz). Prvé vážne signály, že sa BPF bude vyvíjať, prišli v roku 2011, keď Eric Dumazet navrhol náplasť, ktorý do jadra pridáva Just In Time Compiler – prekladač na konverziu BPF bajtkódu na natívny x86_64 code.

Kompilátor JIT bol prvým v reťazci zmien: v roku 2012 objavil schopnosť písať filtre pre seccomp, pomocou BPF, v januári 2013 došlo dodal modul xt_bpf, ktorý vám umožňuje písať pravidlá pre iptables s pomocou BPF av októbri 2013 bola dodal aj modul cls_bpf, ktorý vám umožňuje zapisovať klasifikátory návštevnosti pomocou BPF.

Čoskoro sa na všetky tieto príklady pozrieme podrobnejšie, ale najprv bude pre nás užitočné naučiť sa písať a kompilovať ľubovoľné programy pre BPF, pretože možnosti poskytované knižnicou libpcap obmedzené (jednoduchý príklad: vygenerovaný filter libpcap môže vrátiť iba dve hodnoty - 0 alebo 0x40000) alebo vo všeobecnosti, ako v prípade seccomp, nie sú použiteľné.

Programovanie BPF vlastnými rukami

Poďme sa zoznámiť s binárnym formátom inštrukcií BPF, je to veľmi jednoduché:

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

Každá inštrukcia zaberá 64 bitov, z ktorých prvých 16 bitov je kód inštrukcie, potom sú tu dve osembitové zarážky, jt и jf, a 32 bitov pre argument K, ktorých účel sa líši od príkazu k príkazu. Napríklad príkaz ret, ktorý ukončí program, má kód 6a návratová hodnota je prevzatá z konštanty K. V C je jedna BPF inštrukcia reprezentovaná ako štruktúra

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

a celý program je vo forme štruktúry

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

Môžeme teda už písať programy (napríklad poznáme kódy inštrukcií z [1]). Takto bude vyzerať filter ip6 z náš prvý príklad:

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 môžeme legálne použiť v hovore

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

Písanie programov vo forme strojových kódov nie je príliš pohodlné, ale niekedy je potrebné (napríklad pri ladení, vytváraní unit testov, písaní článkov na Habré a pod.). Pre pohodlie v súbore <linux/filter.h> sú definované pomocné makrá - rovnaký príklad ako vyššie by sa dal prepísať ako

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

Táto možnosť však nie je príliš pohodlná. Toto zdôvodnili programátori jadra Linuxu, a teda v adresári tools/bpf jadrách nájdete assembler a debugger pre prácu s klasickým BPF.

Jazyk symbolických inštrukcií je veľmi podobný výstupu ladenia tcpdump, ale okrem toho môžeme určiť symbolické štítky. Tu je napríklad program, ktorý zahodí všetky pakety okrem TCP/IPv4:

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

V predvolenom nastavení assembler generuje kód vo formáte <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., pre náš príklad s TCP to bude

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

Pre pohodlie programátorov C je možné použiť iný výstupný formát:

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

Tento text je možné skopírovať do definície typovej štruktúry struct sock_filter, ako sme to urobili na začiatku tejto časti.

Linux a rozšírenia netsniff-ng

Okrem štandardných BPF, Linux a tools/bpf/bpf_asm podporu a neštandardná sada. V zásade sa inštrukcie používajú na prístup k poliam štruktúry struct sk_buff, ktorý popisuje sieťový paket v jadre. Existujú však aj iné typy pomocných návodov, napr ldw cpu sa načíta do registra A výsledok spustenia funkcie jadra raw_smp_processor_id(). (V novej verzii BPF boli tieto neštandardné rozšírenia rozšírené tak, aby poskytovali programom sadu pomocníkov jadra na prístup k pamäti, štruktúram a generovaniu udalostí.) Tu je zaujímavý príklad filtra, v ktorom kopírujeme iba hlavičky paketov do užívateľského priestoru pomocou rozšírenia poff, posun užitočného zaťaženia:

ld poff
ret a

Rozšírenia BPF nie je možné použiť tcpdump, ale to je dobrý dôvod na zoznámenie sa s balíkom nástrojov netsniff-ng, ktorý okrem iného obsahuje pokročilý program netsniff-ng, ktorý okrem filtrovania pomocou BPF obsahuje aj efektívny generátor návštevnosti a pokročilejší ako tools/bpf/bpf_asm, volal assembler BPF bpfc. Balík obsahuje pomerne podrobnú dokumentáciu, pozri aj odkazy na konci článku.

seccomp

Takže už vieme, ako písať BPF programy ľubovoľnej zložitosti a sme pripravení pozrieť sa na nové príklady, z ktorých prvým je technológia seccomp, ktorá umožňuje pomocou BPF filtrov spravovať množinu a množinu argumentov systémových volaní dostupných pre daného procesu a jeho potomkov.

Prvá verzia seccomp bola pridaná do jadra v roku 2005 a nebola veľmi populárna, pretože poskytovala iba jedinú možnosť - obmedziť množinu systémových volaní dostupných pre proces na nasledovné: read, write, exit и sigreturna proces, ktorý porušoval pravidlá, bol zabitý pomocou SIGKILL. V roku 2012 však seccomp pridal možnosť používať BPF filtre, čo vám umožňuje definovať množinu povolených systémových volaní a dokonca vykonávať kontroly ich argumentov. (Zaujímavé je, že Chrome bol jedným z prvých používateľov tejto funkcie a ľudia z Chrome v súčasnosti vyvíjajú mechanizmus KRSI založený na novej verzii BPF a umožňujúci prispôsobenie bezpečnostných modulov Linuxu.) Odkazy na ďalšiu dokumentáciu nájdete na konci článku.

Všimnite si, že na hube už boli články o používaní seccomp, možno si ich niekto bude chcieť prečítať pred (alebo namiesto) prečítaním nasledujúcich podsekcií. V článku Kontajnery a zabezpečenie: seccomp poskytuje príklady použitia seccompu, verzie z roku 2007 aj verzie používajúcej BPF (filtre sa generujú pomocou libseccomp), hovorí o prepojení seccompu s Dockerom a tiež poskytuje mnoho užitočných odkazov. V článku Izolácia démonov pomocou systemd alebo „na toto nepotrebujete Docker!“ Zaoberá sa najmä tým, ako pridať čierne alebo biele listiny systémových volaní pre démonov spustených systemd.

Ďalej uvidíme, ako písať a načítať filtre pre seccomp v bare C a pomocou knižnice libseccomp a aké sú výhody a nevýhody jednotlivých možností a nakoniec sa pozrime, ako program používa seccomp strace.

Zápis a načítanie filtrov pre seccomp

BPF programy už vieme písať, tak sa najprv pozrime na programovacie rozhranie seccomp. Môžete nastaviť filter na úrovni procesu a všetky podriadené procesy zdedia obmedzenia. To sa vykonáva pomocou systémového volania seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

kde &filter - toto je ukazovateľ na nám už známu štruktúru struct sock_fprog, t.j. program BPF.

Ako sa programy pre seccomp líšia od programov pre zásuvky? Prenesený kontext. V prípade soketov sme dostali pamäťovú oblasť obsahujúcu paket a v prípade seccomp sme dostali štruktúru ako

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

Tu nr je číslo systémového volania, ktoré sa má spustiť, arch - súčasná architektúra (viac o tom nižšie), args - až šesť argumentov systémového volania a instruction_pointer je ukazovateľ na inštrukciu užívateľského priestoru, ktorá vykonala systémové volanie. Teda napríklad nahrať číslo systémového volania do registra A musíme povedať

ldw [0]

Existujú aj ďalšie funkcie pre programy seccomp, napríklad kontext je prístupný iba pomocou 32-bitového zarovnania a nemôžete načítať pol slova alebo bajt - pri pokuse o načítanie filtra ldh [0] systémové volanie seccomp vráti sa EINVAL. Funkcia kontroluje načítané filtre seccomp_check_filter() jadier. (Zábavné je, že v pôvodnom odovzdaní, ktoré pridalo funkciu seccomp, zabudli pridať povolenie na použitie inštrukcie k tejto funkcii mod (zostatok divízie) a je teraz nedostupný pre programy seccomp BPF od jeho pridania zlomí sa ABI.)

V podstate už vieme všetko na to, aby sme mohli písať a čítať programy seccomp. Zvyčajne je programová logika usporiadaná ako biely alebo čierny zoznam systémových volaní, napríklad programu

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

skontroluje čierny zoznam štyroch systémových volaní očíslovaných 304, 176, 239, 279. Čo sú tieto systémové volania? Nemôžeme to povedať s istotou, pretože nevieme, pre ktorú architektúru bol program napísaný. Preto autori seccomp ponuka spustite všetky programy s kontrolou architektúry (aktuálna architektúra je v kontexte označená ako pole arch štruktúra struct seccomp_data). Po začiarknutí architektúry by začiatok príkladu vyzeral takto:

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

a potom by naše systémové volacie čísla získali určité hodnoty.

Zapisujeme a načítavame filtre pre použitie seccomp libseccomp

Zápis filtrov v natívnom kóde alebo v zostave BPF vám umožňuje mať plnú kontrolu nad výsledkom, no zároveň je niekedy vhodnejšie mať prenosný a/alebo čitateľný kód. Knižnica nám v tom pomôže libseccomp, ktorý poskytuje štandardné rozhranie pre písanie čiernych alebo bielych filtrov.

Napíšme napríklad program, ktorý spustí binárny súbor podľa výberu používateľa po nainštalovaní čiernej listiny systémových volaní z vyššie uvedený článok (program bol pre lepšiu čitateľnosť zjednodušený, plnú verziu nájdete tu):

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

Najprv definujeme pole sys_numbers z viac ako 40 čísel systémových volaní, ktoré chcete zablokovať. Potom inicializujte kontext ctx a povedzme knižnici, čo chceme povoliť (SCMP_ACT_ALLOW) predvolene všetky systémové volania (je jednoduchšie vytvárať čierne listiny). Potom jeden po druhom pridáme všetky systémové volania z čiernej listiny. V reakcii na systémové volanie zo zoznamu žiadame SCMP_ACT_TRAP, v tomto prípade seccomp pošle signál procesu SIGSYS s popisom, ktoré systémové volanie porušilo pravidlá. Nakoniec program nahráme do jadra pomocou seccomp_load, ktorý skompiluje program a pripojí ho k procesu pomocou systémového volania seccomp(2).

Pre úspešnú kompiláciu musí byť program prepojený s knižnicou libseccomp, napríklad:

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

Príklad úspešného spustenia:

$ ./seccomp_lib echo ok
ok

Príklad zablokovaného systémového volania:

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

Používame stracepodrobnosti:

$ 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

ako môžeme vedieť, že program bol ukončený z dôvodu použitia nelegálneho systémového volania mount(2).

Napísali sme teda filter pomocou knižnice libseccomp, vloženie netriviálneho kódu do štyroch riadkov. Vo vyššie uvedenom príklade, ak existuje veľký počet systémových volaní, čas vykonania sa môže výrazne skrátiť, pretože kontrola je len zoznam porovnaní. Pre optimalizáciu nedávno mal libseccomp náplasť súčasťou, ktorý pridáva podporu pre atribút filter SCMP_FLTATR_CTL_OPTIMIZE. Nastavenie tohto atribútu na 2 prevedie filter na binárny vyhľadávací program.

Ak chcete vidieť, ako fungujú binárne vyhľadávacie filtre, pozrite sa na jednoduchý skript, ktorá generuje takéto programy v assembleri BPF vytáčaním systémových volacích čísel, napríklad:

$ 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 je možné písať nič výrazne rýchlejšie, keďže programy BPF nedokážu vykonávať odsadzovacie skoky (nemôžeme napr. jmp A alebo jmp [label+X]) a preto sú všetky prechody statické.

seccomp a strace

Každý pozná užitočnosť strace je nepostrádateľným nástrojom na štúdium správania procesov v systéme Linux. Mnohí však počuli aj o problémy s výkonom pri používaní tejto pomôcky. Faktom je, že strace implementované pomocou ptrace(2)a v tomto mechanizme nemôžeme špecifikovať, pri akých systémových volaniach potrebujeme zastaviť proces, t.j. napr.

$ 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

sú spracované približne za rovnaký čas, aj keď v druhom prípade chceme sledovať iba jedno systémové volanie.

Nová možnosť --seccomp-bpf, pridané do strace verzia 5.3, umožňuje mnohonásobné zrýchlenie procesu a čas spustenia pod stopou jedného systémového volania je už porovnateľný s časom bežného spustenia:

$ 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

(Tu je samozrejme mierny podvod v tom, že nesledujeme hlavné systémové volanie tohto príkazu. Ak by sme sledovali napr. newfsstat, Potom strace brzdilo by rovnako silno ako bez neho --seccomp-bpf.)

Ako táto možnosť funguje? Bez nej strace sa pripojí k procesu a začne ho používať PTRACE_SYSCALL. Keď riadený proces vydá (akékoľvek) systémové volanie, riadenie sa prenesie na strace, ktorý sa pozrie na argumenty systémového volania a spustí ho pomocou PTRACE_SYSCALL. Po určitom čase proces ukončí systémové volanie a pri jeho ukončení sa riadenie opäť prenesie strace, ktorý sa pozrie na návratové hodnoty a spustí proces pomocou PTRACE_SYSCALL, a tak ďalej.

BPF pre najmenších, časť nula: klasický BPF

Pomocou seccompu však možno tento proces optimalizovať presne tak, ako by sme chceli. Totiž, ak sa chceme pozrieť len na systémové volanie X, potom môžeme napísať BPF filter, ktorý pre X vráti hodnotu SECCOMP_RET_TRACEa pre hovory, ktoré nás nezaujímajú - SECCOMP_RET_ALLOW:

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

V tomto prípade strace na začiatku spustí proces ako PTRACE_CONT, náš filter je spracovaný pre každé systémové volanie, ak systémové volanie nie je X, potom proces pokračuje, ale ak toto X, potom sekcomp prevezme kontrolu stracektorý sa pozrie na argumenty a spustí proces ako PTRACE_SYSCALL (keďže seccomp nemá možnosť spustiť program pri ukončení systémového volania). Keď sa systémové volanie vráti, strace reštartuje proces pomocou PTRACE_CONT a bude čakať na nové správy od seccomp.

BPF pre najmenších, časť nula: klasický BPF

Pri použití opcie --seccomp-bpf existujú dve obmedzenia. Po prvé, nebude možné pripojiť sa k už existujúcemu procesu (možnosť -p relácie strace), pretože to nepodporuje seccomp. Po druhé, neexistuje žiadna možnosť nie pozrite sa na podradené procesy, pretože filtre seccomp dedia všetky podriadené procesy bez možnosti to zakázať.

Trochu podrobnejšie o tom, ako presne strace pracuje s seccomp možno nájsť z nedávna správa. Pre nás je najzaujímavejší fakt, že klasický BPF reprezentovaný seccomp sa používa dodnes.

xt_bpf

Vráťme sa teraz do sveta sietí.

Pozadie: už dávno, v roku 2007, jadro bolo dodal modul xt_u32 pre netfilter. Bol napísaný analogicky s ešte starodávnejším klasifikátorom dopravy cls_u32 a umožnil vám písať ľubovoľné binárne pravidlá pre iptables pomocou nasledujúcich jednoduchých operácií: načítať 32 bitov z balíka a vykonať na nich sadu aritmetických operácií. Napríklad,

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

Načíta 32 bitov hlavičky IP, počnúc výplňou 6, a aplikuje na ne masku 0xFF (vezmite nízky bajt). Toto pole protocol IP hlavičku a porovnáme ju s 1 (ICMP). V jednom pravidle môžete kombinovať veľa kontrol a môžete tiež spustiť operátor @ — posuňte X bajtov doprava. Napríklad pravidlo

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

skontroluje, či sa poradové číslo TCP nerovná 0x29. Nebudem ďalej zachádzať do podrobností, pretože už je jasné, že písanie takýchto pravidiel ručne nie je príliš pohodlné. V článku BPF - zabudnutý bajtový kód, existuje niekoľko odkazov s príkladmi použitia a generovania pravidiel pre xt_u32. Pozrite si aj odkazy na konci tohto článku.

Od roku 2013 modul namiesto modulu xt_u32 môžete použiť modul založený na BPF xt_bpf. Každému, kto sa dočítal až sem, by už mal byť jasný princíp jeho fungovania: spustiť BPF bytecode podľa pravidiel iptables. Môžete vytvoriť nové pravidlo, napríklad takto:

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

tu <байткод> - toto je kód vo výstupnom formáte assembleru bpf_asm štandardne napr.

$ 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

V tomto príklade filtrujeme všetky pakety UDP. Kontext pre program BPF v module xt_bpf, samozrejme, ukazuje na paketové dáta, v prípade iptables na začiatok hlavičky IPv4. Návratová hodnota z programu BPF boolovská hodnotaKde false znamená, že paket sa nezhoduje.

Je jasné, že modul xt_bpf podporuje zložitejšie filtre ako vyššie uvedený príklad. Pozrime sa na reálne príklady z Cloudfare. Až donedávna používali modul xt_bpf na ochranu pred DDoS útokmi. V článku Predstavujeme BPF Tools vysvetľujú, ako (a prečo) generujú filtre BPF a zverejňujú odkazy na sadu nástrojov na vytváranie takýchto filtrov. Napríklad pomocou utility bpfgen môžete vytvoriť program BPF, ktorý zodpovedá dopytu DNS na meno 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

V programe najprv načítame do registra X adresa začiatku riadku x04habrx03comx00 vnútri datagramu UDP a potom skontrolujte požiadavku: 0x04686162 <-> "x04hab" atď

O niečo neskôr Cloudfare zverejnil kód kompilátora p0f -> BPF. V článku Predstavujeme kompilátor p0f BPF hovoria o tom, čo je p0f a ako previesť 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,
...

Cloudfare momentálne už nepoužívate xt_bpf, keďže prešli na XDP - jedna z možností využitia novej verzie BPF, viď. L4Drop: XDP DDoS zmiernenie.

cls_bpf

Posledným príkladom použitia klasického BPF v jadre je klasifikátor cls_bpf pre subsystém riadenia dopravy v Linuxe, pridaný do Linuxu koncom roka 2013 a koncepčne nahrádzajúci starodávny cls_u32.

Dielo však teraz popisovať nebudeme cls_bpf, keďže z pohľadu vedomostí o klasickom BPF nám to nič nedá - so všetkými funkciami sme sa už oboznámili. Navyše v nasledujúcich článkoch o Extended BPF sa s týmto klasifikátorom stretneme viackrát.

Ďalší dôvod, prečo nehovoriť o použití klasického BPF c cls_bpf Problém je v tom, že oproti Extended BPF je v tomto prípade rozsah použiteľnosti radikálne zúžený: klasické programy nedokážu meniť obsah balíkov a nedokážu ukladať stav medzi volaniami.

Je teda čas rozlúčiť sa s klasickým BPF a pozrieť sa do budúcnosti.

Rozlúčka s klasickým BPF

Pozreli sme sa na to, ako technológia BPF, vyvinutá na začiatku deväťdesiatych rokov, úspešne žila štvrťstoročie a až do konca našla nové uplatnenie. Avšak podobne ako pri prechode zo zásobníkových strojov na RISC, ktorý poslúžil ako impulz pre vývoj klasických BPF, došlo v roku 32 k prechodu z 64-bitových na XNUMX-bitové stroje a klasické BPF začali zastarávať. Možnosti klasického BPF sú navyše veľmi obmedzené a navyše k zastaranej architektúre – nemáme možnosť ukladania stavu medzi volaniami do BPF programov, nie je možnosť priamej interakcie používateľa, nie je možnosť interakcie s jadrom, s výnimkou čítania obmedzeného počtu štruktúrnych polí sk_buff a spustením najjednoduchších pomocných funkcií nemôžete meniť obsah paketov a presmerovať ich.

V skutočnosti v súčasnosti z klasického BPF v Linuxe zostáva iba rozhranie API a vo vnútri jadra sú všetky klasické programy, či už sú to soketové filtre alebo filtre seccomp, automaticky preložené do nového formátu, Extended BPF. (O tom, ako presne sa to deje, si povieme v nasledujúcom článku.)

Prechod na novú architektúru sa začal v roku 2013, keď Alexey Starovoitov navrhol schému aktualizácie BPF. V roku 2014 príslušné záplaty sa začali objavovať v jadre. Pokiaľ som pochopil, pôvodný plán bol iba optimalizovať architektúru a kompilátor JIT, aby fungovali efektívnejšie na 64-bitových počítačoch, ale namiesto toho tieto optimalizácie znamenali začiatok novej kapitoly vo vývoji Linuxu.

Ďalšie články v tejto sérii sa budú zaoberať architektúrou a aplikáciami novej technológie, pôvodne známej ako interný BPF, potom rozšírený BPF a teraz jednoducho BPF.

referencie

  1. Steven McCanne a Van Jacobson, „BSD Packet Filter: A New Architecture for User-level Packet Capture“, https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: Architektúra a metodika optimalizácie pre zachytávanie paketov", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. Návod na zápas IPtable U32.
  5. BPF - zabudnutý bajtový kód: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Predstavujeme nástroj BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Sekcomp prehľad: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Kontajnery a zabezpečenie: seccomp
  11. habr: Izolácia démonov pomocou systemd alebo „na toto nepotrebujete Docker!“
  12. Paul Chaignon, "strace --seccomp-bpf: pohľad pod kapotu", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Zdroj: hab.com

Pridať komentár