BPF för de minsta, del noll: klassisk BPF

Berkeley Packet Filters (BPF) är en Linux-kärnteknologi som har funnits på framsidorna av engelskspråkiga tekniska publikationer i flera år nu. Konferenser är fyllda med rapporter om användning och utveckling av BPF. David Miller, underhållare av Linux-nätverksundersystem, kallar sitt föredrag på Linux Plumbers 2018 "Det här snacket handlar inte om XDP" (XDP är ett användningsfall för BPF). Brendan Gregg håller föredrag med titeln Linux BPF Superpowers. Toke Høiland-Jørgensen skrattaratt kärnan nu är en mikrokärna. Thomas Graf främjar idén att BPF är javascript för kärnan.

Det finns fortfarande ingen systematisk beskrivning av BPF på Habré, och därför kommer jag i en serie artiklar att försöka prata om teknikens historia, beskriva arkitekturen och utvecklingsverktygen samt skissera tillämpnings- och praktikområdena för att använda BPF. Den här artikeln, noll, i serien, berättar historien och arkitekturen för klassiska BPF, och avslöjar också hemligheterna bakom dess driftsprinciper. tcpdump, seccomp, strace, och mycket mer.

Utvecklingen av BPF kontrolleras av Linux-nätverksgemenskapen, de huvudsakliga befintliga tillämpningarna av BPF är relaterade till nätverk och därför, med tillstånd @eukariot, jag kallade serien "BPF för de små", för att hedra den stora serien "Nätverk för de minsta".

En kort kurs i BPFs historia(c)

Modern BPF-teknik är en förbättrad och utökad version av den gamla tekniken med samma namn, nu kallad klassisk BPF för att undvika förvirring. Ett välkänt verktyg skapades baserat på den klassiska BPF tcpdump, mekanism seccomp, såväl som mindre kända moduler xt_bpf för iptables och klassificerare cls_bpf. I modern Linux översätts klassiska BPF-program automatiskt till den nya formen, men ur användarsynpunkt har API:et funnits kvar och nya användningsområden för klassisk BPF, som vi kommer att se i den här artikeln, hittas fortfarande. Av denna anledning, och även för att efter historien om utvecklingen av klassisk BPF i Linux, det kommer att bli tydligare hur och varför det utvecklades till sin moderna form, bestämde jag mig för att börja med en artikel om klassisk BPF.

I slutet av åttiotalet av förra seklet blev ingenjörer från det berömda Lawrence Berkeley-laboratoriet intresserade av frågan om hur man korrekt filtrerar nätverkspaket på hårdvara som var modern i slutet av åttiotalet av förra seklet. Grundidén med filtrering, ursprungligen implementerad i CSPF (CMU/Stanford Packet Filter) teknologi, var att filtrera onödiga paket så tidigt som möjligt, d.v.s. i kärnutrymme, eftersom detta undviker att kopiera onödig data till användarutrymmet. För att tillhandahålla körtidssäkerhet för körning av användarkod i kärnutrymmet användes en virtuell maskin i sandlåde.

De virtuella maskinerna för befintliga filter var dock designade för att köras på stackbaserade maskiner och kördes inte lika effektivt på nyare RISC-maskiner. Som ett resultat, genom insatser från ingenjörer från Berkeley Labs, utvecklades en ny BPF-teknik (Berkeley Packet Filters) vars virtuella maskinarkitektur designades baserat på Motorola 6502-processorn - arbetshästen för så välkända produkter som Apple II eller NES. Den nya virtuella maskinen ökade filterprestanda tiotals gånger jämfört med befintliga lösningar.

BPF maskinarkitektur

Vi kommer att bekanta oss med arkitektur på ett fungerande sätt och analysera exempel. Men till att börja med, låt oss säga att maskinen hade två 32-bitars register tillgängliga för användaren, en ackumulator A och indexregister X, 64 byte minne (16 ord), tillgängligt för skrivning och efterföljande läsning, och ett litet system av kommandon för att arbeta med dessa objekt. Hoppinstruktioner för att implementera villkorliga uttryck fanns också tillgängliga i programmen, men för att garantera att programmet slutfördes i tid kunde hopp bara göras framåt, det vill säga i synnerhet var det förbjudet att skapa loopar.

Det allmänna schemat för att starta maskinen är som följer. Användaren skapar ett program för BPF-arkitekturen och använder några kärnmekanism (som ett systemanrop), laddar och ansluter programmet till till vissa till händelsegeneratorn i kärnan (till exempel en händelse är ankomsten av nästa paket på nätverkskortet). När en händelse inträffar kör kärnan programmet (till exempel i en tolk), och maskinminnet motsvarar till vissa kärnminnesområde (till exempel data från ett inkommande paket).

Ovanstående kommer att räcka för att vi ska börja titta på exempel: vi kommer att bekanta oss med systemet och kommandoformatet vid behov. Om du omedelbart vill studera kommandosystemet för en virtuell maskin och lära dig om alla dess funktioner, kan du läsa den ursprungliga artikeln BSD-paketfiltret och/eller den första halvan av filen Dokumentation/nätverk/filter.txt från kärndokumentationen. Dessutom kan du studera presentationen libpcap: En arkitektur- och optimeringsmetodik för paketfångning, där McCanne, en av författarna till BPF, berättar om skapelsens historia libpcap.

Vi går nu vidare för att överväga alla viktiga exempel på att använda klassisk BPF på Linux: tcpdump (libpcap), secomp, xt_bpf, cls_bpf.

tcpdump

Utvecklingen av BPF genomfördes parallellt med utvecklingen av frontend för paketfiltrering - ett välkänt verktyg tcpdump. Och eftersom detta är det äldsta och mest kända exemplet på att använda klassisk BPF, tillgänglig på många operativsystem, kommer vi att börja vår studie av tekniken med den.

(Jag körde alla exempel i den här artikeln på Linux 5.6.0-rc6. Utdata från vissa kommandon har redigerats för bättre läsbarhet.)

Exempel: observera IPv6-paket

Låt oss föreställa oss att vi vill titta på alla IPv6-paket på ett gränssnitt eth0. För att göra detta kan vi köra programmet tcpdump med ett enkelt filter ip6:

$ sudo tcpdump -i eth0 ip6

I detta fall, tcpdump kompilerar filtret ip6 in i BPF-arkitekturens bytekod och skicka den till kärnan (se detaljer i avsnittet Tcpdump: laddar). Det laddade filtret kommer att köras för varje paket som passerar genom gränssnittet eth0. Om filtret returnerar ett värde som inte är noll n, sedan upp till n byte av paketet kommer att kopieras till användarutrymmet och vi kommer att se det i utdata tcpdump.

BPF för de minsta, del noll: klassisk BPF

Det visar sig att vi enkelt kan ta reda på vilken bytekod som skickades till kärnan tcpdump med hjälp av tcpdump, om vi kör det med alternativet -d:

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

På rad noll kör vi kommandot ldh [12], som står för "load into register A ett halvt ord (16 bitar) som ligger på adress 12” och frågan är bara vilken typ av minne vi adresserar? Svaret är att kl x börjar (x+1)byten av det analyserade nätverkspaketet. Vi läser paket från Ethernet-gränssnittet eth0, och detta innebäratt paketet ser ut så här (för enkelhetens skull antar vi att det inte finns några VLAN-taggar i paketet):

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

Så efter att ha utfört kommandot ldh [12] i registret A det kommer att finnas ett fält Ether Type — typen av paket som överförs i denna Ethernet-ram. På rad 1 jämför vi innehållet i registret A (pakettyp) c 0x86dd, och detta och äta Typen vi är intresserade av är IPv6. På rad 1, förutom jämförelsekommandot, finns det ytterligare två kolumner - jt 2 и jf 3 — markeringar som du måste gå till om jämförelsen lyckas (A == 0x86dd) och misslyckas. Så i ett framgångsrikt fall (IPv6) går vi till rad 2, och i ett misslyckat fall - till rad 3. På rad 3 avslutas programmet med kod 0 (kopiera inte paketet), på rad 2 avslutas programmet med kod 262144 (kopiera mig max 256 kilobyte paket).

Ett mer komplicerat exempel: vi tittar på TCP-paket efter destinationsport

Låt oss se hur ett filter ser ut som kopierar alla TCP-paket med destinationsport 666. Vi kommer att överväga IPv4-fallet, eftersom IPv6-fallet är enklare. Efter att ha studerat det här exemplet kan du själv utforska IPv6-filtret som en övning (ip6 and tcp dst port 666) och ett filter för det allmänna fallet (tcp dst port 666). Så filtret vi är intresserade av ser ut så här:

$ 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

Vi vet redan vad rad 0 och 1 gör. På rad 2 har vi redan kontrollerat att detta är ett IPv4-paket (Ether Type = 0x800) och ladda den i registret A 24:e byte av paketet. Vårt paket ser ut

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

vilket innebär att vi laddar in i registret A protokollfältet för IP-huvudet, vilket är logiskt, eftersom vi bara vill kopiera TCP-paket. Vi jämför Protocol med 0x6 (IPPROTO_TCP) på rad 3.

På rad 4 och 5 laddar vi halvorden som finns på adress 20 och använder kommandot jset kontrollera om en av de tre är inställd flaggor - bära den utfärdade masken jset de tre mest signifikanta bitarna rensas. Två av de tre bitarna berättar om paketet är en del av ett fragmenterat IP-paket, och i så fall om det är det sista fragmentet. Den tredje biten är reserverad och måste vara noll. Vi vill inte kontrollera vare sig ofullständiga eller trasiga paket, så vi kontrollerar alla tre bitarna.

Linje 6 är den mest intressanta i denna lista. Uttryck ldxb 4*([14]&0xf) betyder att vi laddar in i registret X de minst signifikanta fyra bitarna av den femtonde byten i paketet multiplicerat med 4. De minst signifikanta fyra bitarna av den femtonde byten är fältet Internet Header Längd IPv4 header, som lagrar längden på rubriken i ord, så du måste sedan multiplicera med 4. Intressant nog är uttrycket 4*([14]&0xf) är en beteckning för ett särskilt adresseringsschema som endast kan användas i denna form och endast för ett register X, dvs. det kan vi inte heller säga ldb 4*([14]&0xf) eller ldxb 5*([14]&0xf) (vi kan bara ange en annan offset, t.ex. ldxb 4*([16]&0xf)). Det är tydligt att detta adresseringsschema lades till BPF just för att ta emot X (indexregister) IPv4-huvudlängd.

Så på rad 7 försöker vi ladda ett halvt ord kl (X+16). Kom ihåg att 14 byte upptas av Ethernet-huvudet, och X innehåller längden på IPv4-huvudet, vi förstår att i A TCP-destinationsporten laddas:

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

Slutligen, på rad 8 jämför vi destinationsporten med det önskade värdet och på rad 9 eller 10 returnerar vi resultatet - oavsett om paketet ska kopieras eller inte.

Tcpdump: laddar

I de tidigare exemplen uppehöll vi oss specifikt inte i detalj om exakt hur vi laddar BPF-bytekod i kärnan för paketfiltrering. Generellt, tcpdump portas till många system och för att arbeta med filter tcpdump använder biblioteket libpcap. Kortfattat, för att placera ett filter på ett gränssnitt med hjälp av libpcapmåste du göra följande:

För att se hur det fungerar pcap_setfilter implementerat i Linux använder vi strace (några rader har tagits bort):

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

På de två första utdataraderna skapar vi rå uttag för att läsa alla Ethernet-ramar och binda det till gränssnittet eth0. Från vårt första exempel vi vet att filtret ip kommer att bestå av fyra BPF-instruktioner, och på den tredje raden ser vi hur man använder alternativet SO_ATTACH_FILTER systemanrop setsockopt vi laddar och ansluter ett filter med längd 4. Detta är vårt filter.

Det är värt att notera att i klassisk BPF sker laddning och anslutning av ett filter alltid som en atomoperation, och i den nya versionen av BPF separeras laddning av programmet och bindning av det till händelsegeneratorn i tid.

Dold sanning

En lite mer komplett version av utgången ser ut så här:

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

Som nämnts ovan laddar vi och ansluter vårt filter till uttaget på linje 5, men vad händer på rad 3 och 4? Det visar sig att detta libpcap tar hand om oss - så att utdata från vårt filter inte inkluderar paket som inte uppfyller det, biblioteket ansluter dummy filter ret #0 (släpp alla paket), växlar socket till icke-blockerande läge och försöker subtrahera alla paket som kan finnas kvar från tidigare filter.

Totalt, för att filtrera paket på Linux med klassisk BPF, måste du ha ett filter i form av en struktur som struct sock_fprog och ett öppet uttag, varefter filtret kan fästas på uttaget med hjälp av ett systemanrop setsockopt.

Intressant nog kan filtret fästas på vilket uttag som helst, inte bara rått. Här exempel ett program som stänger av alla utom de två första byten från alla inkommande UDP-datagram. (Jag lade till kommentarer i koden för att inte röra upp artikeln.)

Mer information om användning setsockopt för anslutning av filter, se uttag (7), men om att skriva dina egna filter som struct sock_fprog utan hjälp tcpdump vi pratar i avsnittet Programmera BPF med våra egna händer.

Klassisk BPF och XNUMX-talet

BPF ingick i Linux 1997 och har varit en arbetshäst under lång tid libpcap utan några speciella ändringar (linux-specifika ändringar, naturligtvis, var, men de förändrade inte den globala bilden). De första allvarliga tecknen på att BPF skulle utvecklas kom 2011, när Eric Dumazet friade plåster, som lägger till Just In Time Compiler till kärnan - en översättare för att konvertera BPF-bytekod till native x86_64 koda.

JIT-kompilatorn var den första i kedjan av förändringar: 2012 dök förmåga att skriva filter för secomp, med hjälp av BPF, i januari 2013 fanns det Lagt till modul xt_bpf, som låter dig skriva regler för iptables med hjälp av BPF, och i oktober 2013 var Lagt till också en modul cls_bpf, som låter dig skriva trafikklassificerare med BPF.

Vi kommer att titta på alla dessa exempel mer i detalj snart, men först kommer det att vara användbart för oss att lära oss hur man skriver och kompilerar godtyckliga program för BPF, eftersom de funktioner som biblioteket tillhandahåller libpcap begränsad (enkelt exempel: filter genererat libpcap kan endast returnera två värden - 0 eller 0x40000) eller är generellt, som i fallet med seccomp, inte tillämpliga.

Programmera BPF med våra egna händer

Låt oss bekanta oss med det binära formatet för BPF-instruktioner, det är väldigt enkelt:

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

Varje instruktion upptar 64 bitar, där de första 16 bitarna är instruktionskoden, sedan finns det två åttabitars indrag, jt и jf, och 32 bitar för argumentet K, vars syfte varierar från kommando till kommando. Till exempel kommandot ret, som avslutar programmet har koden 6, och returvärdet tas från konstanten K. I C representeras en enda BPF-instruktion som en struktur

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

och hela programmet är i form av en struktur

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

Således kan vi redan skriva program (vi känner till exempel instruktionskoderna från [1]). Så här kommer filtret att se ut ip6 av vårt första exempel:

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 vi lagligt kan använda i ett samtal

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

Att skriva program i form av maskinkoder är inte särskilt bekvämt, men ibland är det nödvändigt (till exempel för att felsöka, skapa enhetstester, skriva artiklar om Habré, etc.). För enkelhetens skull, i filen <linux/filter.h> hjälpmakron definieras - samma exempel som ovan skulle kunna skrivas om som

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

Detta alternativ är dock inte särskilt bekvämt. Detta är vad Linux-kärnprogrammerarna resonerade, och därför i katalogen tools/bpf kärnor kan du hitta en assembler och debugger för att arbeta med klassiska BPF.

Assembly språk är mycket likt felsökningsutdata tcpdump, men dessutom kan vi ange symboliska etiketter. Till exempel, här är ett program som släpper alla paket utom TCP/IPv4:

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

Som standard genererar assemblern kod i formatet <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., för vårt exempel med TCP kommer det att vara det

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

För att underlätta för C-programmerare kan ett annat utdataformat användas:

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

Denna text kan kopieras till typstrukturdefinitionen struct sock_filter, som vi gjorde i början av det här avsnittet.

Linux- och netsniff-ng-tillägg

Förutom standard BPF, Linux och tools/bpf/bpf_asm stöd och icke-standarduppsättning. I grund och botten används instruktioner för att komma åt fälten i en struktur struct sk_buff, som beskriver ett nätverkspaket i kärnan. Det finns dock även andra typer av hjälpinstruktioner, till exempel ldw cpu kommer att laddas in i registret A resultatet av att köra en kärnfunktion raw_smp_processor_id(). (I den nya versionen av BPF har dessa icke-standardiserade tillägg utökats för att förse program med en uppsättning kärnhjälpare för att komma åt minne, strukturer och generera händelser.) Här är ett intressant exempel på ett filter där vi bara kopierar pakethuvuden till användarutrymmet med tillägget poff, nyttolastförskjutning:

ld poff
ret a

BPF-förlängningar kan inte användas i tcpdump, men det här är en bra anledning att bekanta sig med verktygspaketet netsniff-ng, som bland annat innehåller ett avancerat program netsniff-ng, som förutom filtrering med BPF även innehåller en effektiv trafikgenerator, och mer avancerad än tools/bpf/bpf_asm, kallade en BPF-montör bpfc. Paketet innehåller ganska detaljerad dokumentation, se även länkarna i slutet av artikeln.

secomp

Så vi vet redan hur man skriver BPF-program av godtycklig komplexitet och är redo att titta på nya exempel, varav det första är seccomp-teknologin, som gör det möjligt att, med hjälp av BPF-filter, hantera uppsättningen och uppsättningen av systemanropsargument som är tillgängliga för en given process och dess ättlingar.

Den första versionen av seccomp lades till i kärnan 2005 och var inte särskilt populär, eftersom den bara gav ett enda alternativ - att begränsa uppsättningen av systemanrop tillgängliga för en process till följande: read, write, exit и sigreturn, och processen som bröt mot reglerna dödades med hjälp av SIGKILL. Men 2012 lade seccomp till möjligheten att använda BPF-filter, så att du kan definiera en uppsättning tillåtna systemanrop och till och med utföra kontroller av deras argument. (Intressant nog var Chrome en av de första användarna av denna funktion, och Chrome-folket utvecklar för närvarande en KRSI-mekanism baserad på en ny version av BPF och tillåter anpassning av Linux-säkerhetsmoduler.) Länkar till ytterligare dokumentation finns i slutet av artikeln.

Observera att det redan har funnits artiklar på navet om att använda seccomp, kanske någon vill läsa dem innan (eller istället för) att läsa följande underavsnitt. I artikeln Behållare och säkerhet: secomp ger exempel på att använda seccomp, både 2007-versionen och versionen som använder BPF (filter genereras med libseccomp), talar om kopplingen av seccomp med Docker, och ger även många användbara länkar. I artikeln Isolera demoner med systemd eller "du behöver inte Docker för detta!" Den täcker i synnerhet hur man lägger till svarta listor eller vitlistor över systemanrop för demoner som kör systemd.

Nästa kommer vi att se hur man skriver och laddar filter för seccomp i blott C och med hjälp av biblioteket libseccomp och vilka är fördelarna och nackdelarna med varje alternativ, och slutligen, låt oss se hur seccomp används av programmet strace.

Skriva och ladda filter för seccomp

Vi vet redan hur man skriver BPF-program, så låt oss först titta på seccomp-programmeringsgränssnittet. Du kan ställa in ett filter på processnivå, och alla underordnade processer kommer att ärva begränsningarna. Detta görs med hjälp av ett systemanrop seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

där &filter - Detta är en pekare till en struktur som vi redan känner till struct sock_fprog, dvs. BPF-program.

Hur skiljer sig program för seccomp från program för sockets? Överfört sammanhang. När det gäller sockets fick vi ett minnesområde som innehöll paketet, och i fallet med seccomp fick vi en struktur som

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

Här nr är numret på systemanropet som ska startas, arch - nuvarande arkitektur (mer om detta nedan), args - upp till sex systemanropsargument och instruction_pointer är en pekare till användarutrymmesinstruktionen som gjorde systemanropet. Således, till exempel, att ladda systemanropsnumret i registret A vi måste säga

ldw [0]

Det finns andra funktioner för seccomp-program, till exempel kan sammanhanget endast nås med 32-bitars justering och du kan inte ladda ett halvt ord eller en byte - när du försöker ladda ett filter ldh [0] systemanrop seccomp kommer tillbaka EINVAL. Funktionen kontrollerar de laddade filtren seccomp_check_filter() kärnor. (Det roliga är att i den ursprungliga commit som lade till seccomp-funktionaliteten glömde de att lägga till behörighet att använda instruktionen till den här funktionen mod (division resterande) och är nu inte tillgänglig för seccomp BPF-program, sedan dess tillägg kommer att gå sönder ABI.)

I grund och botten kan vi redan allt för att skriva och läsa secomp-program. Vanligtvis är programlogiken arrangerad som en vit eller svart lista över systemanrop, till exempel programmet

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

kontrollerar en svartlista med fyra systemsamtal numrerade 304, 176, 239, 279. Vilka är dessa systemsamtal? Vi kan inte säga säkert, eftersom vi inte vet för vilken arkitektur programmet skrevs. Därför har författarna till seccomp erbjudandet starta alla program med en arkitekturkontroll (den aktuella arkitekturen anges i sammanhanget som ett fält arch strukturen struct seccomp_data). Med arkitekturen markerad skulle början av exemplet se ut så här:

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

och då skulle våra systemanropsnummer få vissa värden.

Vi skriver och laddar filter för seccomp med hjälp av libseccomp

Genom att skriva filter i inbyggd kod eller i BPF-montering kan du ha full kontroll över resultatet, men samtidigt är det ibland att föredra att ha portabel och/eller läsbar kod. Biblioteket hjälper oss med detta libsecomp, som tillhandahåller ett standardgränssnitt för att skriva svarta eller vita filter.

Låt oss till exempel skriva ett program som kör en binär fil som användaren själv väljer, efter att tidigare ha installerat en svartlista med systemanrop från ovanstående artikel (Programmet har förenklats för bättre läsbarhet, den fullständiga versionen kan hittas här):

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

Först definierar vi en array sys_numbers av 40+ systemnummer att blockera. Initiera sedan sammanhanget ctx och berätta för biblioteket vad vi vill tillåta (SCMP_ACT_ALLOW) alla systemanrop som standard (det är lättare att bygga svarta listor). Sedan, ett efter ett, lägger vi till alla systemanrop från den svarta listan. Som svar på ett systemanrop från listan ber vi om SCMP_ACT_TRAP, i detta fall kommer seccomp att skicka en signal till processen SIGSYS med en beskrivning av vilket systemanrop som bröt mot reglerna. Slutligen laddar vi in ​​programmet i kärnan med hjälp av seccomp_load, som kommer att kompilera programmet och bifoga det till processen med ett systemanrop seccomp(2).

För framgångsrik sammanställning måste programmet vara länkat till biblioteket libseccomp, till exempel:

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

Exempel på en lyckad lansering:

$ ./seccomp_lib echo ok
ok

Exempel på ett blockerat systemanrop:

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

Vi använder straceför detaljer:

$ 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

hur kan vi veta att programmet avslutades på grund av användningen av ett olagligt systemanrop mount(2).

Så vi skrev ett filter med hjälp av biblioteket libseccomp, passar icke-trivial kod i fyra rader. I exemplet ovan, om det finns ett stort antal systemanrop, kan exekveringstiden minskas märkbart, eftersom kontrollen bara är en lista över jämförelser. För optimering hade libsecomp nyligen patch ingår, som lägger till stöd för filterattributet SCMP_FLTATR_CTL_OPTIMIZE. Om du ställer in detta attribut till 2 konverteras filtret till ett binärt sökprogram.

Om du vill se hur binära sökfilter fungerar, ta en titt på enkelt manus, som genererar sådana program i BPF assembler genom att slå systemanropsnummer, till exempel:

$ 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

Det är omöjligt att skriva något betydligt snabbare, eftersom BPF-program inte kan utföra indragshopp (vi kan t.ex. jmp A eller jmp [label+X]) och därför är alla övergångar statiska.

secomp och strace

Alla känner till nyttan strace är ett oumbärligt verktyg för att studera beteendet hos processer på Linux. Men många har också hört talas om prestationsproblem när du använder det här verktyget. Faktum är att strace implementeras med hjälp av ptrace(2), och i den här mekanismen kan vi inte specificera vid vilken uppsättning systemanrop vi behöver för att stoppa processen, dvs. till exempel kommandon

$ 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

behandlas på ungefär samma tid, även om vi i det andra fallet bara vill spåra ett systemanrop.

Nytt alternativ --seccomp-bpf, lagt till strace version 5.3, låter dig snabba upp processen många gånger och starttiden under spåret av ett systemanrop är redan jämförbar med tiden för en vanlig start:

$ 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

(Här finns det naturligtvis ett litet bedrägeri i det att vi inte spårar det här kommandots huvudsystemanrop. Om vi ​​skulle spåra t.ex. newfsstat, Sedan strace skulle bromsa lika hårt som utan --seccomp-bpf.)

Hur fungerar det här alternativet? Utan henne strace ansluter till processen och börjar använda den PTRACE_SYSCALL. När en hanterad process utfärdar ett (valfritt) systemanrop överförs kontrollen till strace, som tittar på systemanropsargumenten och kör den med PTRACE_SYSCALL. Efter en tid slutför processen systemanropet och när det avslutas överförs kontrollen igen strace, som tittar på returvärdena och startar processen med PTRACE_SYSCALL, och så vidare.

BPF för de minsta, del noll: klassisk BPF

Med seccomp kan dock denna process optimeras precis som vi skulle vilja. Nämligen om vi bara vill titta på systemanropet X, då kan vi skriva ett BPF-filter för det X returnerar ett värde SECCOMP_RET_TRACE, och för samtal som inte är av intresse för oss - SECCOMP_RET_ALLOW:

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

I det här fallet strace initialt startar processen som PTRACE_CONT, bearbetas vårt filter för varje systemanrop, om systemanropet inte är det X, då fortsätter processen att köras, men om detta X, sedan överför seccomp kontrollen stracesom kommer att titta på argumenten och starta processen som PTRACE_SYSCALL (eftersom seccomp inte har möjlighet att köra ett program när ett systemanrop avslutas). När systemanropet återkommer, strace kommer att starta om processen med PTRACE_CONT och väntar på nya meddelanden från seccomp.

BPF för de minsta, del noll: klassisk BPF

När du använder alternativet --seccomp-bpf det finns två begränsningar. För det första kommer det inte att vara möjligt att gå med i en redan befintlig process (alternativ -p program strace), eftersom detta inte stöds av secomp. För det andra finns det ingen möjlighet ingen titta på underordnade processer, eftersom seccomp-filter ärvs av alla underordnade processer utan möjlighet att inaktivera detta.

Lite mer detaljerad exakt hur strace arbetar med seccomp kan hittas från färsk rapport. För oss är det mest intressanta faktumet att den klassiska BPF representerad av seccomp fortfarande används idag.

xt_bpf

Låt oss nu gå tillbaka till nätverkens värld.

Bakgrund: för länge sedan, 2007, var kärnan Lagt till modul xt_u32 för nätfilter. Den skrevs i analogi med en ännu äldre trafikklassificerare cls_u32 och tillät dig att skriva godtyckliga binära regler för iptables med följande enkla operationer: ladda 32 bitar från ett paket och utför en uppsättning aritmetiska operationer på dem. Till exempel,

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

Laddar de 32 bitarna i IP-huvudet, med början vid utfyllnad 6, och applicerar en mask på dem 0xFF (ta den låga byten). Detta fält protocol IP-header och vi jämför det med 1 (ICMP). Du kan kombinera många kontroller i en regel, och du kan också utföra operatören @ — flytta X byte åt höger. Till exempel regeln

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

kontrollerar om TCP-sekvensnumret inte är lika 0x29. Jag kommer inte att gå in på detaljer ytterligare, eftersom det redan är klart att det inte är särskilt bekvämt att skriva sådana regler för hand. I artikeln BPF - den glömda bytekoden, det finns flera länkar med exempel på användning och regelgenerering för xt_u32. Se även länkarna i slutet av denna artikel.

Sedan 2013 modul istället för modul xt_u32 du kan använda en BPF-baserad modul xt_bpf. Alla som har läst så här långt borde redan ha klart för sig principen för dess funktion: kör BPF-bytecode som iptables-regler. Du kan skapa en ny regel, till exempel så här:

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

här <байткод> - detta är koden i assembler-utdataformat bpf_asm som standard, till exempel,

$ 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

I det här exemplet filtrerar vi alla UDP-paket. Sammanhang för ett BPF-program i en modul xt_bpf, naturligtvis, pekar på paketdata, i fallet med iptables, till början av IPv4-huvudet. Returvärde från BPF-programmet boolesktvar false betyder att paketet inte matchade.

Det är tydligt att modulen xt_bpf stöder mer komplexa filter än exemplet ovan. Låt oss titta på verkliga exempel från Cloudfare. Tills nyligen använde de modulen xt_bpf för att skydda mot DDoS-attacker. I artikeln Vi presenterar BPF-verktygen de förklarar hur (och varför) de genererar BPF-filter och publicerar länkar till en uppsättning verktyg för att skapa sådana filter. Till exempel att använda verktyget bpfgen du kan skapa ett BPF-program som matchar en DNS-fråga för ett namn 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

I programmet laddar vi först in i registret X radens startadress x04habrx03comx00 inuti ett UDP-datagram och kontrollera sedan begäran: 0x04686162 <-> "x04hab" etc.

Lite senare publicerade Cloudfare kompilatorkoden p0f -> BPF. I artikeln Vi presenterar p0f BPF-kompilatorn de pratar om vad p0f är och hur man konverterar p0f-signaturer till 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,
...

Använder för närvarande inte längre Cloudfare xt_bpf, sedan de flyttade till XDP - ett av alternativen för att använda den nya versionen av BPF, se. L4Drop: XDP DDoS-begränsningar.

cls_bpf

Det sista exemplet på att använda klassisk BPF i kärnan är klassificeraren cls_bpf för trafikkontrollundersystemet i Linux, lagt till Linux i slutet av 2013 och konceptuellt ersätter det gamla cls_u32.

Vi kommer dock inte nu att beskriva arbetet cls_bpf, eftersom detta ur kunskapssynpunkt om klassisk BPF inte kommer att ge oss någonting - vi har redan blivit bekanta med all funktionalitet. Dessutom, i efterföljande artiklar som talar om Extended BPF, kommer vi att möta denna klassificerare mer än en gång.

En annan anledning att inte prata om att använda klassiska BPF c cls_bpf Problemet är att, jämfört med Extended BPF, är tillämpningsområdet i detta fall radikalt begränsat: klassiska program kan inte ändra innehållet i paket och kan inte spara tillstånd mellan samtal.

Så det är dags att säga adjö till klassiska BPF och blicka framåt.

Farväl till klassiska BPF

Vi tittade på hur BPF-tekniken, utvecklad i början av nittiotalet, framgångsrikt levde i ett kvarts sekel och fram till slutet hittade nya tillämpningar. Men i likhet med övergången från stackmaskiner till RISC, som fungerade som en drivkraft för utvecklingen av klassiska BPF, skedde på 32-talet en övergång från 64-bitars till XNUMX-bitarsmaskiner och klassiska BPF började bli föråldrade. Dessutom är kapaciteten hos klassisk BPF mycket begränsad, och förutom den föråldrade arkitekturen - vi har inte möjlighet att spara tillstånd mellan anrop till BPF-program, det finns ingen möjlighet till direkt användarinteraktion, det finns ingen möjlighet att interagera med kärnan, förutom att läsa ett begränsat antal strukturfält sk_buff och när du startar de enklaste hjälpfunktionerna kan du inte ändra innehållet i paket och omdirigera dem.

Faktum är att för närvarande är allt som återstår av den klassiska BPF i Linux API-gränssnittet, och inuti kärnan översätts alla klassiska program, vare sig det är socketfilter eller seccomp-filter, automatiskt till ett nytt format, Extended BPF. (Vi kommer att prata om exakt hur detta händer i nästa artikel.)

Övergången till en ny arkitektur började 2013, när Alexey Starovoitov föreslog ett BPF-uppdateringsschema. Under 2014 motsvarande patchar började dyka upp i kärnan. Såvitt jag förstår var den initiala planen bara att optimera arkitekturen och JIT-kompilatorn för att köras mer effektivt på 64-bitars maskiner, men istället markerade dessa optimeringar början på ett nytt kapitel i Linux-utvecklingen.

Ytterligare artiklar i den här serien kommer att täcka arkitekturen och tillämpningarna av den nya tekniken, från början känd som intern BPF, sedan utökad BPF och nu helt enkelt BPF.

referenser

  1. Steven McCanne och Van Jacobson, "The BSD Packet Filter: A New Architecture for User-level Packet Capture", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: An Architecture and Optimization Methodology for Packet Capture", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. IPtable U32 Match Tutorial.
  5. BPF - den glömda bytekoden: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Vi presenterar BPF-verktyget: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. En sekundär översikt: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Containrar och säkerhet: secomp
  11. habr: Isolera demoner med systemd eller "du behöver inte Docker för detta!"
  12. Paul Chaignon, "strace --seccomp-bpf: en titt under huven", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Källa: will.com

Lägg en kommentar