BPF pro nejmenší, díl nula: klasický BPF

Berkeley Packet Filters (BPF) je technologie linuxového jádra, která je již několik let na předních stránkách anglicky psaných technických publikací. Konference jsou plné zpráv o používání a vývoji BPF. David Miller, správce linuxového síťového subsystému, přednáší svou přednášku na Linux Plumbers 2018 „Tato diskuse není o XDP“ (XDP je jeden případ použití pro BPF). Brendan Gregg vede přednášky s názvem Linux BPF Superpowers. Toke Høiland-Jørgensen Smíchže jádro je nyní mikrojádro. Thomas Graf prosazuje myšlenku, že BPF je javascript pro jádro.

Na Habré stále neexistuje systematický popis BPF, a proto se v sérii článků pokusím pohovořit o historii technologie, popsat architekturu a vývojové nástroje a nastínit oblasti aplikace a praxe používání BPF. Tento článek, nula ze série, vypráví historii a architekturu klasického BPF a také odhaluje tajemství jeho provozních principů. tcpdump, seccomp, strace, a mnohem víc.

Vývoj BPF je řízen linuxovou síťovou komunitou, hlavní existující aplikace BPF se týkají sítí, a proto se svolením @eucariot, nazval jsem sérii „BPF pro nejmenší“, na počest skvělé série "Sítě pro nejmenší".

Krátký kurz historie BPF(c)

Moderní technologie BPF je vylepšenou a rozšířenou verzí staré technologie se stejným názvem, která se nyní nazývá klasická BPF, aby nedošlo k záměně. Na základě klasického BPF vznikla známá utilita tcpdump, mechanismus seccomp, stejně jako méně známé moduly xt_bpf pro iptables a klasifikátor cls_bpf. V moderním Linuxu se klasické BPF programy automaticky překládají do nové podoby, nicméně z uživatelského hlediska zůstalo API na svém místě a stále se nacházejí nová využití klasického BPF, jak uvidíme v tomto článku. Z tohoto důvodu a také proto, že po historii vývoje klasického BPF v Linuxu bude jasnější, jak a proč se vyvinul do své moderní podoby, jsem se rozhodl začít článkem o klasickém BPF.

Koncem osmdesátých let minulého století se inženýři ze slavné Lawrence Berkeley Laboratory začali zajímat o otázku, jak správně filtrovat síťové pakety na hardwaru, který byl moderní koncem osmdesátých let minulého století. Základní myšlenkou filtrování, původně implementovaného v technologii CSPF (CMU/Stanford Packet Filter), bylo odfiltrovat nepotřebné pakety co nejdříve, tzn. v prostoru jádra, protože se tak zabrání kopírování zbytečných dat do uživatelského prostoru. Pro zajištění běhového zabezpečení pro spouštění uživatelského kódu v prostoru jádra byl použit virtuální stroj v izolovaném prostoru.

Virtuální stroje pro stávající filtry však byly navrženy tak, aby běžely na strojích založených na zásobníku a na novějších strojích RISC nefungovaly tak efektivně. Výsledkem je, že díky úsilí inženýrů z Berkeley Labs byla vyvinuta nová technologie BPF (Berkeley Packet Filters), jejíž architektura virtuálního stroje byla navržena na základě procesoru Motorola 6502 – tažného koně tak známých produktů, jako je např. Apple II nebo NES. Nový virtuální stroj zvýšil výkon filtru desetkrát ve srovnání se stávajícími řešeními.

Architektura stroje BPF

Pracovním způsobem se seznámíme s architekturou na příkladech. Pro začátek však řekněme, že stroj měl dva 32bitové registry přístupné uživateli, akumulátor A a indexový registr X, 64 bajtů paměti (16 slov), dostupných pro zápis a následné čtení, a malý systém příkazů pro práci s těmito objekty. V programech byly k dispozici i skokové instrukce pro implementaci podmíněných výrazů, ale aby bylo zaručeno včasné dokončení programu, bylo možné skoky provádět pouze dopředu, tj. zejména bylo zakázáno vytvářet smyčky.

Obecné schéma pro spuštění stroje je následující. Uživatel vytvoří program pro architekturu BPF a pomocí nějaký mechanismus jádra (jako je systémové volání), načte program a připojí k němu některým do generátoru událostí v jádře (událost je například příchod dalšího paketu na síťovou kartu). Když dojde k události, jádro spustí program (například v interpretu) a paměť počítače odpovídá některým oblast paměti jádra (například data příchozího paketu).

Výše uvedené nám bude stačit k tomu, abychom se mohli začít dívat na příklady: podle potřeby se seznámíme se systémem a formátem příkazů. Pokud si chcete okamžitě prostudovat příkazový systém virtuálního stroje a dozvědět se o všech jeho schopnostech, můžete si přečíst původní článek Filtr paketů BSD a/nebo první polovina souboru Documentation/networking/filter.txt z dokumentace jádra. Navíc si můžete prostudovat prezentaci libpcap: Architektura a metodika optimalizace pro zachycování paketů, ve kterém McCanne, jeden z autorů BPF, hovoří o historii stvoření libpcap.

Nyní přejdeme ke zvážení všech významných příkladů použití klasického BPF v Linuxu: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

Vývoj BPF probíhal souběžně s vývojem frontendu pro filtrování paketů – známé utility tcpdump. A protože se jedná o nejstarší a nejslavnější příklad použití klasického BPF, dostupného na mnoha operačních systémech, zahájíme naši studii této technologie s ním.

(Spustil jsem všechny příklady v tomto článku o Linuxu 5.6.0-rc6. Výstup některých příkazů byl upraven pro lepší čitelnost.)

Příklad: pozorování paketů IPv6

Představme si, že se chceme podívat na všechny IPv6 pakety na rozhraní eth0. K tomu můžeme spustit program tcpdump s jednoduchým filtrem ip6:

$ sudo tcpdump -i eth0 ip6

V tomto případě, tcpdump zkompiluje filtr ip6 do bajtkódu architektury BPF a odešlete jej do jádra (viz podrobnosti v části Tcpdump: načítání). Načtený filtr bude spuštěn pro každý paket procházející rozhraním eth0. Pokud filtr vrátí nenulovou hodnotu n, pak až n bajtů paketu se zkopíruje do uživatelského prostoru a my to uvidíme ve výstupu tcpdump.

BPF pro nejmenší, díl nula: klasický BPF

Ukazuje se, že snadno zjistíme, který bytekód byl odeslán do jádra tcpdump s pomocí tcpdump, pokud jej spustíme s opcí -d:

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

Na řádku nula spustíme příkaz ldh [12], což znamená „načíst do registru A půl slova (16 bitů) umístěného na adrese 12” a jedinou otázkou je, jaký druh paměti řešíme? Odpověď je, že v x začíná (x+1)bajtu analyzovaného síťového paketu. Pakety čteme z rozhraní Ethernet eth0a to rozumíže paket vypadá takto (pro zjednodušení předpokládáme, že v paketu nejsou žádné značky VLAN):

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

Tedy po provedení příkazu ldh [12] v registru A bude pole Ether Type — typ paketu přenášeného v tomto ethernetovém rámci. Na řádku 1 porovnáme obsah registru A (typ balíčku) c 0x86dda to a tam je Typ, který nás zajímá, je IPv6. Na řádku 1 jsou kromě porovnávacího příkazu další dva sloupce - jt 2 и jf 3 — známky, ke kterým se musíte dostat, pokud je srovnání úspěšné (A == 0x86dd) a neúspěšně. Takže v úspěšném případě (IPv6) přejdeme na řádek 2 a v neúspěšném případě na řádek 3. Na řádku 3 program končí kódem 0 (nekopírujte paket), na řádku 2 program končí kódem 262144 (zkopírujte mi balíček maximálně 256 kB).

Složitější příklad: podíváme se na TCP pakety podle cílového portu

Podívejme se, jak vypadá filtr, který zkopíruje všechny TCP pakety s cílovým portem 666. Budeme uvažovat případ IPv4, protože případ IPv6 je jednodušší. Po prostudování tohoto příkladu si můžete sami prozkoumat filtr IPv6 jako cvičení (ip6 and tcp dst port 666) a filtr pro obecný případ (tcp dst port 666). Filtr, který nás zajímá, tedy vypadá 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ž víme, co dělají řádky 0 a 1. Na řádku 2 jsme již zkontrolovali, že se jedná o paket IPv4 (Ether Type = 0x800) a nahrajte jej do registru A 24. bajt paketu. Náš balíček vypadá

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

což znamená, že načteme do registru A pole Protokol hlavičky IP, což je logické, protože chceme kopírovat pouze pakety TCP. Porovnáváme protokol s 0x6 (IPPROTO_TCP) na řádku 3.

Na řádky 4 a 5 načteme půlslova umístěná na adrese 20 a použijeme příkaz jset zkontrolujte, zda je nastaven jeden ze tří vlajky - nosit vydanou masku jset tři nejvýznamnější bity se vymažou. Dva ze tří bitů nám říkají, zda je paket součástí fragmentovaného IP paketu, a pokud ano, zda se jedná o poslední fragment. Třetí bit je rezervovaný a musí být nulový. Nechceme kontrolovat ani neúplné, ani poškozené pakety, takže kontrolujeme všechny tři bity.

Řádek 6 je nejzajímavější v tomto seznamu. Výraz ldxb 4*([14]&0xf) znamená, že načteme do registru X nejméně významné čtyři bity patnáctého bajtu paketu vynásobené 4. Nejméně významné čtyři bity patnáctého bajtu je pole Délka internetové hlavičky IPv4 hlavička, která ukládá délku hlavičky slovy, takže je pak potřeba vynásobit 4. Zajímavostí je, že výraz 4*([14]&0xf) je označení pro speciální adresní schéma, které lze použít pouze v této podobě a pouze pro registr X, tj. taky nemůžeme říct ldb 4*([14]&0xf) nebo ldxb 5*([14]&0xf) (můžeme pouze zadat jiný offset, např. ldxb 4*([16]&0xf)). Je jasné, že toto schéma adresování bylo přidáno do BPF právě proto, aby přijímalo X (indexový registr) Délka hlavičky IPv4.

Takže na řádku 7 se snažíme načíst půl slova na (X+16). Pamatujte, že 14 bajtů je obsazeno ethernetovou hlavičkou a X obsahuje délku hlavičky IPv4, rozumíme tomu v A Cílový port TCP je načten:

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

Nakonec na řádku 8 porovnáme cílový port s požadovanou hodnotou a na řádcích 9 nebo 10 vrátíme výsledek - zda paket zkopírovat nebo ne.

Tcpdump: načítání

V předchozích příkladech jsme se konkrétně nezabývali podrobně tím, jak přesně načteme bajtkód BPF do jádra pro filtrování paketů. Obecně řečeno, tcpdump portován do mnoha systémů a pro práci s filtry tcpdump používá knihovnu libpcap. Stručně řečeno, umístit filtr na rozhraní pomocí libpcap, musíte provést následující:

Chcete-li vidět, jak funguje pcap_setfilter implementován v Linuxu, který používáme strace (některé řádky byly odstraněny):

$ 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 prvních dvou řádcích výstupu vytvoříme surová zásuvka číst všechny ethernetové rámce a svázat je s rozhraním eth0, z náš první příklad víme, že filtr ip se bude skládat ze čtyř BPF instrukcí a na třetím řádku vidíme, jak se tato volba používá SO_ATTACH_FILTER systémové volání setsockopt naložíme a připojíme filtr délky 4. Toto je náš filtr.

Za zmínku stojí, že v klasickém BPF probíhá načítání a připojení filtru vždy jako atomická operace a v nové verzi BPF je načítání programu a jeho navázání na generátor událostí časově odděleno.

Skrytá pravda

Trochu kompletnější verze výstupu vypadá 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
...

Jak je uvedeno výše, načteme a zapojíme náš filtr do zásuvky na řádku 5, ale co se stane na řádcích 3 a 4? Ukazuje se, že toto libpcap se o nás stará - aby výstup našeho filtru nezahrnoval pakety, které ho nesplňují, knihovna spojuje slepý filtr ret #0 (zahodit všechny pakety), přepne socket do neblokovacího režimu a pokusí se odečíst všechny pakety, které by mohly zůstat z předchozích filtrů.

Celkově pro filtrování balíčků na Linuxu pomocí klasického BPF musíte mít filtr ve formě struktury jako struct sock_fprog a otevřenou zásuvku, po které lze filtr připojit k zásuvce pomocí systémového volání setsockopt.

Zajímavé je, že filtr lze připevnit na jakoukoli zásuvku, nejen surovou. Tady příklad program, který odřízne všechny kromě prvních dvou bajtů ze všech příchozích datagramů UDP. (Do kódu jsem přidal komentáře, abych článek nepřeplňoval.)

Další podrobnosti o použití setsockopt připojení filtrů viz zásuvka (7), ale o psaní vlastních filtrů jako struct sock_fprog bez pomoci tcpdump budeme mluvit v sekci Programování BPF vlastníma rukama.

Klasické BPF a XNUMX. století

BPF byl zahrnut do Linuxu v roce 1997 a zůstal tahounem po dlouhou dobu libpcap bez jakýchkoliv speciálních změn (samozřejmě změny specifické pro Linux, bylo to, ale nezměnily globální obraz). První vážné známky toho, že se BPF bude vyvíjet, přišly v roce 2011, kdy Eric Dumazet navrhl patch, která do jádra přidává Just In Time Compiler – překladač pro převod BPF bajtkódu na nativní x86_64 kód.

Kompilátor JIT byl první v řetězci změn: v roce 2012 se objevil schopnost psát filtry pro seccomp, pomocí BPF, v lednu 2013 došlo přidal modulu xt_bpf, který umožňuje psát pravidla pro iptables s pomocí BPF a v říjnu 2013 byla přidal také modul cls_bpf, který umožňuje zapisovat klasifikátory provozu pomocí BPF.

Brzy se na všechny tyto příklady podíváme podrobněji, ale nejprve se nám bude hodit naučit se psát a kompilovat libovolné programy pro BPF, protože možnosti poskytované knihovnou libpcap omezené (jednoduchý příklad: vygenerován filtr libpcap může vrátit pouze dvě hodnoty - 0 nebo 0x40000) nebo obecně, jako v případě seccomp, nejsou použitelné.

Programování BPF vlastníma rukama

Pojďme se seznámit s binárním formátem instrukcí BPF, je to velmi jednoduché:

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

Každá instrukce zabírá 64 bitů, z nichž prvních 16 bitů je kód instrukce, pak jsou zde dvě osmibitové odrážky, jt и jf, a 32 bitů pro argument K, jehož účel se liší příkaz od příkazu. Například příkaz ret, který ukončí program, má kód 6a návratová hodnota je převzata z konstanty K. V C je jediná instrukce BPF reprezentována jako struktura

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

a celý program je ve formě struktury

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

Můžeme tedy již psát programy (známe například instrukční kódy z [1]). Takto bude vypadat filtr ip6 z náš první pří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álně použít v hovoru

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

Psaní programů ve formě strojových kódů není příliš pohodlné, ale někdy je to nutné (například pro ladění, vytváření unit testů, psaní článků na Habré atd.). Pro usnadnění v souboru <linux/filter.h> jsou definována pomocná makra - stejný příklad jako výše lze přepsat jako

struct sock_filter code[] = {
        BPF_STMT(BPF_LD|BPF_H|BPF_ABS, 12),
        BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, ETH_P_IPV6, 0, 1),
        BPF_STMT(BPF_RET|BPF_K, 0x00040000),
        BPF_STMT(BPF_RET|BPF_K, 0),
}

Tato možnost však není příliš pohodlná. Takto uvažovali programátoři linuxového jádra, a proto v adresáři tools/bpf kernels najdete assembler a debugger pro práci s klasickým BPF.

Jazyk symbolických instrukcí je velmi podobný výstupu ladění tcpdump, ale navíc můžeme specifikovat symbolické popisky. Zde je například program, který zahodí všechny pakety kromě TCP/IPv4:

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

Ve výchozím nastavení assembler generuje kód ve formátu <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., pro náš pří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,

Pro pohodlí programátorů C lze použít jiný 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 lze zkopírovat do definice typové struktury struct sock_filter, jak jsme to udělali na začátku této části.

Linux a rozšíření netsniff-ng

Kromě standardních BPF, Linux a tools/bpf/bpf_asm podporu a nestandardní sada. V zásadě se instrukce používají pro přístup k polím struktury struct sk_buff, který popisuje síťový paket v jádře. Existují však například i další typy pomocných návodů ldw cpu se načte do registru A výsledek spuštění funkce jádra raw_smp_processor_id(). (V nové verzi BPF byla tato nestandardní rozšíření rozšířena tak, aby poskytovala programům sadu pomocníků jádra pro přístup k paměti, strukturám a generování událostí.) Zde je zajímavý příklad filtru, ve kterém zkopírujeme pouze hlavičky paketů do uživatelského prostoru pomocí rozšíření poff, posun užitečného zatížení:

ld poff
ret a

Rozšíření BPF nelze použít v tcpdump, ale to je dobrý důvod, proč se s obslužným balíčkem seznámit netsniff-ng, která mimo jiné obsahuje pokročilý program netsniff-ng, který kromě filtrování pomocí BPF obsahuje i efektivní generátor návštěvnosti a pokročilejší než tools/bpf/bpf_asm, volal assembler BPF bpfc. Balíček obsahuje poměrně podrobnou dokumentaci, viz také odkazy na konci článku.

seccomp

Takže již víme, jak psát BPF programy libovolné složitosti a jsme připraveni podívat se na nové příklady, z nichž prvním je technologie seccomp, která umožňuje pomocí BPF filtrů spravovat sadu a sadu argumentů systémových volání dostupných pro daný proces a jeho potomci.

První verze seccomp byla přidána do jádra v roce 2005 a nebyla příliš populární, protože poskytovala pouze jedinou možnost - omezit sadu systémových volání dostupných pro proces na následující: read, write, exit и sigreturna proces, který porušoval pravidla, byl zabit pomocí SIGKILL. V roce 2012 však seccomp přidal možnost používat filtry BPF, což vám umožňuje definovat sadu povolených systémových volání a dokonce provádět kontroly jejich argumentů. (Zajímavé je, že Chrome byl jedním z prvních uživatelů této funkce a lidé z Chrome v současné době vyvíjejí mechanismus KRSI založený na nové verzi BPF a umožňující přizpůsobení linuxových bezpečnostních modulů.) Odkazy na další dokumentaci naleznete na konci článku.

Všimněte si, že na hubu již byly články o použití seccomp, možná si je někdo bude chtít přečíst před (nebo místo) čtení následujících podsekcí. V článku Kontejnery a zabezpečení: seccomp uvádí příklady použití seccompu, a to jak verze 2007, tak verze využívající BPF (filtry jsou generovány pomocí libseccomp), hovoří o spojení seccompu s Dockerem a také poskytuje mnoho užitečných odkazů. V článku Izolace démonů pomocí systemd nebo „k tomu nepotřebujete Docker!“ Zabývá se zejména tím, jak přidat blacklisty nebo whitelisty systémových volání pro démony běžící systemd.

Dále uvidíme, jak zapisovat a načítat filtry pro seccomp v holém C a pomocí knihovny libseccomp a jaké jsou výhody a nevýhody jednotlivých možností a nakonec se podívejme, jak program seccomp používá strace.

Zápis a načítání filtrů pro seccomp

Psaní programů BPF již víme, takže se nejprve podíváme na programovací rozhraní seccomp. Můžete nastavit filtr na úrovni procesu a všechny podřízené procesy zdědí omezení. To se provádí pomocí systémového volání seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

kde &filter - toto je ukazatel na nám již známou strukturu struct sock_fprog, tj. program BPF.

Jak se liší programy pro seccomp od programů pro sockety? Přenášený kontext. V případě soketů jsme dostali paměťovou oblast obsahující paket a v případě seccomp jsme dostali strukturu jako

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

Zde nr je číslo systémového volání, které má být zahájeno, arch - současná architektura (více o tom níže), args - až šest argumentů systémového volání a instruction_pointer je ukazatel na instrukci uživatelského prostoru, která provedla systémové volání. Tedy například k načtení systémového telefonního čísla do registru A musíme říct

ldw [0]

Existují další funkce pro programy seccomp, například kontext je přístupný pouze pomocí 32bitového zarovnání a nelze načíst půl slova ani bajt - při pokusu o načtení filtru ldh [0] systémové volání seccomp vrátí se EINVAL. Funkce kontroluje načtené filtry seccomp_check_filter() jádra. (Vtipné je, že v původním potvrzení, které přidalo funkci seccomp, zapomněli k této funkci přidat oprávnění k použití instrukce mod (zbytek divize) a je nyní nedostupný pro programy seccomp BPF od jeho přidání zlomí se ABI.)

V podstatě už víme všechno, co se dá psát a číst programy seccomp. Obvykle je programová logika uspořádána jako bílý nebo černý seznam systémových volání, napří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

kontroluje černou listinu čtyř systémových volání očíslovaných 304, 176, 239, 279. Co jsou tato systémová volání? Nemůžeme to s jistotou říci, protože nevíme, pro jakou architekturu byl program napsán. Proto autoři seccomp nabídka spusťte všechny programy s kontrolou architektury (aktuální architektura je v kontextu označena jako pole arch struktura struct seccomp_data). Se zaškrtnutou architekturou by začátek příkladu vypadal takto:

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

a pak by naše systémová telefonní čísla získala určité hodnoty.

Píšeme a načítáme filtry pro použití seccomp libseccomp

Zápis filtrů v nativním kódu nebo sestavě BPF vám umožňuje mít plnou kontrolu nad výsledkem, ale zároveň je někdy vhodnější mít přenosný a/nebo čitelný kód. Knihovna nám s tím pomůže libseccomp, který poskytuje standardní rozhraní pro zápis černých nebo bílých filtrů.

Pojďme například napsat program, který spustí binární soubor podle výběru uživatele, který si předtím nainstaloval černou listinu systémových volání z výše uvedený článek (Program byl zjednodušen pro větší čitelnost, plnou verzi naleznete zde):

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

Nejprve definujeme pole sys_numbers z více než 40 čísel systémových volání k blokování. Poté inicializujte kontext ctx a sdělit knihovně, co chceme povolit (SCMP_ACT_ALLOW) ve výchozím nastavení všechna systémová volání (je jednodušší vytvářet černé listiny). Poté jeden po druhém přidáme všechna systémová volání z černé listiny. V reakci na systémové volání ze seznamu žádáme SCMP_ACT_TRAP, v tomto případě seccomp vyšle signál procesu SIGSYS s popisem, které systémové volání porušilo pravidla. Nakonec program nahrajeme do jádra pomocí seccomp_load, který zkompiluje program a připojí jej k procesu pomocí systémového volání seccomp(2).

Pro úspěšnou kompilaci musí být program propojen s knihovnou libseccomp, Například:

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

Příklad úspěšného spuštění:

$ ./seccomp_lib echo ok
ok

Příklad zablokovaného systémového volání:

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

Používáme stracepro detaily:

$ 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

jak můžeme vědět, že program byl ukončen kvůli použití nelegálního systémového volání mount(2).

Napsali jsme tedy filtr pomocí knihovny libseccomp, vložení netriviálního kódu do čtyř řádků. Ve výše uvedeném příkladu, pokud existuje velký počet systémových volání, může být doba provádění znatelně zkrácena, protože kontrola je pouze seznam srovnání. Pro optimalizaci měl nedávno libseccomp náplast součástí, který přidává podporu pro atribut filter SCMP_FLTATR_CTL_OPTIMIZE. Nastavením tohoto atributu na 2 se filtr převede na binární vyhledávací program.

Pokud chcete vidět, jak fungují binární vyhledávací filtry, podívejte se na jednoduchý skript, která generuje takové programy v assembleru BPF vytáčením systémových telefonních čísel, napří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

Není možné psát nic výrazně rychleji, protože programy BPF neumí provádět odsazení skoků (neumíme např. jmp A nebo jmp [label+X]) a proto jsou všechny přechody statické.

seccomp a strace

Každý zná užitečnost strace je nepostradatelným nástrojem pro studium chování procesů na Linuxu. Mnozí však o tom také slyšeli problémy s výkonem při použití této utility. Faktem je, že strace implementováno pomocí ptrace(2)a v tomto mechanismu nemůžeme specifikovat, při jaké sadě systémových volání potřebujeme proces zastavit, tj. např.

$ 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

jsou zpracovány přibližně ve stejnou dobu, i když v druhém případě chceme sledovat pouze jedno systémové volání.

Nová možnost --seccomp-bpf, přidáno k strace verze 5.3, umožňuje mnohonásobně urychlit proces a doba spuštění po stopě jednoho systémového volání je již srovnatelná s dobou běžného spuštění:

$ 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

(Tady je samozřejmě mírný podvod v tom, že nesledujeme hlavní systémové volání tohoto příkazu. Pokud bychom trasovali např. newfsstatpak strace brzdilo by stejně silně jako bez něj --seccomp-bpf.)

Jak tato možnost funguje? Bez ní strace se připojí k procesu a začne jej používat PTRACE_SYSCALL. Když spravovaný proces vydá (jakékoli) systémové volání, řízení je přeneseno na strace, který se podívá na argumenty systémového volání a spustí je pomocí PTRACE_SYSCALL. Po nějaké době proces dokončí systémové volání a při jeho ukončení se řízení opět předá strace, který se podívá na návratové hodnoty a spustí proces pomocí PTRACE_SYSCALL, a tak dále.

BPF pro nejmenší, díl nula: klasický BPF

Se seccomp však lze tento proces optimalizovat přesně tak, jak bychom chtěli. Totiž, pokud se chceme podívat pouze na systémové volání X, pak můžeme napsat BPF filtr, který pro X vrátí hodnotu SECCOMP_RET_TRACEa pro hovory, které nás nezajímají – SECCOMP_RET_ALLOW:

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

V tomto případě strace zpočátku spustí proces jako PTRACE_CONT, náš filtr je zpracován pro každé systémové volání, pokud tomu tak není X, pak proces pokračuje v běhu, ale pokud toto X, pak seccomp převezme kontrolu stracekterý se podívá na argumenty a spustí proces jako PTRACE_SYSCALL (protože seccomp nemá možnost spustit program při ukončení systémového volání). Když se systémové volání vrátí, strace restartuje proces pomocí PTRACE_CONT a bude čekat na nové zprávy od seccomp.

BPF pro nejmenší, díl nula: klasický BPF

Při použití opce --seccomp-bpf existují dvě omezení. Za prvé, nebude možné se připojit k již existujícímu procesu (opce -p pořady strace), protože to seccomp nepodporuje. Zadruhé neexistuje žádná možnost ne podívejte se na podřízené procesy, protože filtry seccomp jsou zděděny všemi podřízenými procesy bez možnosti toto zakázat.

Trochu podrobněji jak přesně strace pracuje s seccomp lze nalézt z nedávná zpráva. Pro nás je nejzajímavější fakt, že klasický BPF reprezentovaný seccomp se používá dodnes.

xt_bpf

Vraťme se nyní do světa sítí.

Pozadí: kdysi dávno, v roce 2007, jádro bylo přidal modulu xt_u32 pro síťový filtr. Byl napsán analogicky s ještě starodávnějším klasifikátorem dopravy cls_u32 a umožnil vám napsat libovolná binární pravidla pro iptables pomocí následujících jednoduchých operací: načíst 32 bitů z balíčku a provést na nich sadu aritmetických operací. Například,

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

Načte 32 bitů hlavičky IP, počínaje odsazením 6, a aplikuje na ně masku 0xFF (vezměte spodní bajt). Toto pole protocol IP hlavičku a porovnáme ji s 1 (ICMP). V jednom pravidle můžete kombinovat mnoho kontrol a také můžete spustit operátor @ — přesunout X bajtů doprava. Například pravidlo

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

zkontroluje, zda se pořadové číslo TCP nerovná 0x29. Nebudu zacházet do podrobností, protože je již jasné, že psát taková pravidla ručně není příliš pohodlné. V článku BPF - zapomenutý bytecode, existuje několik odkazů s příklady použití a generování pravidel pro xt_u32. Viz také odkazy na konci tohoto článku.

Od roku 2013 modul místo modulu xt_u32 můžete použít modul založený na BPF xt_bpf. Každý, kdo dočetl až sem, by již měl mít jasno v principu jeho fungování: spusťte BPF bytecode jako pravidla iptables. Můžete vytvořit nové pravidlo, například takto:

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

zde <байткод> - toto je kód ve výstupním formátu assembleru bpf_asm standardně např.

$ 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 příkladu filtrujeme všechny pakety UDP. Kontext pro program BPF v modulu xt_bpf, samozřejmě ukazuje na data paketu, v případě iptables na začátek hlavičky IPv4. Návratová hodnota z programu BPF booleovskýKde false znamená, že paket se neshodoval.

Je jasné, že modul xt_bpf podporuje složitější filtry než výše uvedený příklad. Podívejme se na reálné příklady z Cloudfare. Až donedávna používali modul xt_bpf na ochranu před DDoS útoky. V článku Představujeme BPF Tools vysvětlují, jak (a proč) generují filtry BPF a zveřejňují odkazy na sadu nástrojů pro vytváření takových filtrů. Například pomocí utility bpfgen můžete vytvořit program BPF, který odpovídá DNS dotazu na jméno 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 programu nejprve načteme do registru X adresa začátku řádku x04habrx03comx00 uvnitř datagramu UDP a poté zkontrolujte požadavek: 0x04686162 <-> "x04hab" atd.

O něco později Cloudfare zveřejnilo kód kompilátoru p0f -> BPF. V článku Představujeme kompilátor p0f BPF mluví o tom, co je p0f a jak převést 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,
...

V současné době již nepoužíváte Cloudfare xt_bpf, jelikož přešli na XDP - jedna z možností použití nové verze BPF, viz. L4Drop: XDP DDoS zmírnění.

cls_bpf

Posledním příkladem použití klasického BPF v jádře je klasifikátor cls_bpf pro subsystém řízení provozu v Linuxu, přidaný do Linuxu na konci roku 2013 a koncepčně nahrazující starodávný cls_u32.

Dílo však nyní popisovat nebudeme cls_bpf, jelikož nám to z hlediska znalostí o klasickém BPF nic nedá - se všemi funkcemi jsme se již seznámili. Navíc v dalších článcích hovořících o Extended BPF se s tímto klasifikátorem setkáme nejednou.

Další důvod, proč nemluvit o použití klasického BPF c cls_bpf Problém je v tom, že oproti Extended BPF je v tomto případě rozsah použitelnosti radikálně zúžen: klasické programy neumí měnit obsah balíčků a neumí ukládat stav mezi voláními.

Je tedy čas rozloučit se s klasickým BPF a podívat se do budoucnosti.

Rozloučení s klasickým BPF

Podívali jsme se, jak technologie BPF, vyvinutá na počátku devadesátých let, úspěšně žila čtvrt století a až do konce našla nové uplatnění. Podobně jako při přechodu ze zásobníkových strojů na RISC, který posloužil jako impuls pro vývoj klasických BPF, však v roce 32 došlo k přechodu z 64bitových na XNUMXbitové stroje a klasické BPF začaly zastarávat. Možnosti klasického BPF jsou navíc velmi omezené a navíc k zastaralé architektuře - nemáme možnost ukládat stav mezi voláním programů BPF, není možnost přímé interakce uživatele, není možnost interakce s jádrem, kromě čtení omezeného počtu strukturních polí sk_buff a spouštění nejjednodušších pomocných funkcí, nemůžete měnit obsah paketů a přesměrovávat je.

Ve skutečnosti v současnosti z klasického BPF v Linuxu zbývá pouze rozhraní API a v jádře jsou všechny klasické programy, ať už jsou to socketové filtry nebo seccomp filtry, automaticky přeloženy do nového formátu, Extended BPF. (Jak přesně k tomu dojde, si povíme v dalším článku.)

Přechod na novou architekturu začal v roce 2013, kdy Alexey Starovoitov navrhl schéma aktualizace BPF. V roce 2014 odpovídající záplaty se začaly objevovat v jádru. Pokud jsem pochopil, původní plán byl pouze optimalizovat architekturu a kompilátor JIT, aby fungovaly efektivněji na 64bitových strojích, ale místo toho tyto optimalizace znamenaly začátek nové kapitoly ve vývoji Linuxu.

Další články v této sérii se budou zabývat architekturou a aplikacemi nové technologie, zpočátku známé jako interní BPF, poté rozšířené BPF a nyní jednoduše BPF.

reference

  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: Metodologie architektury a optimalizace pro zachycování paketů", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. IPtable U32 Match výukový program.
  5. BPF - zapomenutý bytecode: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Představení nástroje BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Přehled seccompu: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Kontejnery a zabezpečení: seccomp
  11. habr: Izolace démonů pomocí systemd nebo „k tomu nepotřebujete Docker!“
  12. Paul Chaignon, "strace --seccomp-bpf: pohled pod pokličku", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Zdroj: www.habr.com

Přidat komentář