BPF til de mindste, del nul: klassisk BPF

Berkeley Packet Filters (BPF) er en Linux-kerneteknologi, der har været på forsiden af ​​engelsksprogede tech-publikationer i flere år nu. Konferencer er fyldt med rapporter om brug og udvikling af BPF. David Miller, Linux-netværksundersystemvedligeholder, kalder sit foredrag på Linux Plumbers 2018 "Denne snak handler ikke om XDP" (XDP er en use case for BPF). Brendan Gregg holder foredrag med titlen Linux BPF Superpowers. Toke Høiland-Jørgensen grinerat kernen nu er en mikrokerne. Thomas Graf fremmer ideen om, at BPF er javascript til kernen.

Der er stadig ingen systematisk beskrivelse af BPF på Habré, og derfor vil jeg i en række artikler forsøge at tale om teknologiens historie, beskrive arkitekturen og udviklingsværktøjerne samt skitsere anvendelses- og praksisområderne ved brug af BPF. Denne artikel, nul, i serien, fortæller historien og arkitekturen bag klassisk BPF og afslører også hemmelighederne bag dets driftsprincipper. tcpdump, seccomp, strace, og meget mere.

Udviklingen af ​​BPF styres af Linux-netværksfællesskabet, de vigtigste eksisterende applikationer af BPF er relateret til netværk og derfor med tilladelse @eukariot, kaldte jeg serien “BPF for de små”, til ære for den flotte serie "Netværk for de mindste".

Et kort kursus i BPF's historie(c)

Moderne BPF-teknologi er en forbedret og udvidet version af den gamle teknologi med samme navn, nu kaldet klassisk BPF for at undgå forvirring. Et velkendt værktøj blev skabt baseret på den klassiske BPF tcpdump, mekanisme seccomp, samt mindre kendte moduler xt_bpf for iptables og klassificerer cls_bpf. I moderne Linux bliver klassiske BPF-programmer automatisk oversat til den nye form, men fra et brugersynspunkt er API'et forblevet på plads, og nye anvendelser for klassisk BPF, som vi vil se i denne artikel, er stadig ved at blive fundet. Af denne grund, og også fordi efter historien om udviklingen af ​​klassisk BPF i Linux, det vil blive tydeligere, hvordan og hvorfor det udviklede sig til sin moderne form, besluttede jeg at starte med en artikel om klassisk BPF.

I slutningen af ​​firserne af det sidste århundrede blev ingeniører fra det berømte Lawrence Berkeley Laboratory interesseret i spørgsmålet om, hvordan man korrekt filtrerede netværkspakker på hardware, der var moderne i slutningen af ​​firserne af forrige århundrede. Den grundlæggende idé med filtrering, oprindeligt implementeret i CSPF (CMU/Stanford Packet Filter) teknologi, var at filtrere unødvendige pakker så tidligt som muligt, dvs. i kernel space, da dette undgår at kopiere unødvendige data ind i brugerrummet. For at give runtime-sikkerhed til at køre brugerkode i kernerummet, blev der brugt en sandboxed virtuel maskine.

De virtuelle maskiner til eksisterende filtre blev dog designet til at køre på stack-baserede maskiner og kørte ikke så effektivt på nyere RISC-maskiner. Som et resultat blev der gennem indsatsen fra ingeniører fra Berkeley Labs udviklet en ny BPF (Berkeley Packet Filters) teknologi, hvis virtuelle maskinarkitektur blev designet baseret på Motorola 6502-processoren - arbejdshesten i så velkendte produkter som Apple II eller NES. Den nye virtuelle maskine øgede filterydelsen titusindvis af gange sammenlignet med eksisterende løsninger.

BPF maskinarkitektur

Vi vil stifte bekendtskab med arkitektur på en fungerende måde og analysere eksempler. Men til at begynde med, lad os sige, at maskinen havde to 32-bit registre, der var tilgængelige for brugeren, en akkumulator A og indeksregister X, 64 bytes hukommelse (16 ord), tilgængelig til skrivning og efterfølgende læsning, og et lille system af kommandoer til at arbejde med disse objekter. Springinstruktioner til implementering af betingede udtryk var også tilgængelige i programmerne, men for at sikre rettidig afslutning af programmet kunne der kun springes fremad, dvs. det var især forbudt at oprette loops.

Den generelle ordning for start af maskinen er som følger. Brugeren opretter et program til BPF-arkitekturen og vha nogle kernemekanisme (såsom et systemkald), indlæser og forbinder programmet til for nogen til hændelsesgeneratoren i kernen (for eksempel er en hændelse ankomsten af ​​den næste pakke på netværkskortet). Når en hændelse opstår, kører kernen programmet (f.eks. i en fortolker), og maskinens hukommelse svarer til for nogen kernehukommelsesregion (for eksempel data fra en indgående pakke).

Ovenstående vil være nok til, at vi kan begynde at se på eksempler: vi vil stifte bekendtskab med systemet og kommandoformatet efter behov. Hvis du straks vil studere kommandosystemet på en virtuel maskine og lære om alle dens muligheder, så kan du læse den originale artikel BSD-pakkefilteret og/eller den første halvdel af filen Dokumentation/netværk/filter.txt fra kernedokumentationen. Derudover kan du studere oplægget libpcap: En arkitektur- og optimeringsmetode til pakkefangst, hvor McCanne, en af ​​forfatterne til BPF, fortæller om skabelsens historie libpcap.

Vi går nu videre til at overveje alle de væsentlige eksempler på brug af klassisk BPF på Linux: tcpdump (libpcap), secomp, xt_bpf, cls_bpf.

tcpdump

Udviklingen af ​​BPF blev gennemført parallelt med udviklingen af ​​frontend til pakkefiltrering - et velkendt værktøj tcpdump. Og da dette er det ældste og mest berømte eksempel på brug af klassisk BPF, tilgængelig på mange operativsystemer, vil vi begynde vores undersøgelse af teknologien med det.

(Jeg kørte alle eksemplerne i denne artikel om Linux 5.6.0-rc6. Outputtet af nogle kommandoer er blevet redigeret for bedre læsbarhed.)

Eksempel: observation af IPv6-pakker

Lad os forestille os, at vi vil se på alle IPv6-pakker på en grænseflade eth0. For at gøre dette kan vi køre programmet tcpdump med et simpelt filter ip6:

$ sudo tcpdump -i eth0 ip6

I dette tilfælde tcpdump kompilerer filteret ip6 ind i BPF-arkitekturens bytekode og send den til kernen (se detaljer i afsnittet Tcpdump: indlæser). Det indlæste filter vil blive kørt for hver pakke, der passerer gennem grænsefladen eth0. Hvis filteret returnerer en værdi, der ikke er nul n, derefter op til n bytes af pakken vil blive kopieret til brugerrummet, og vi vil se det i outputtet tcpdump.

BPF til de mindste, del nul: klassisk BPF

Det viser sig, at vi nemt kan finde ud af, hvilken bytekode der blev sendt til kernen tcpdump ved hjælp af tcpdump, hvis vi kører det med muligheden -d:

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

På linje nul kører vi kommandoen ldh [12], som står for "indlæs i register A et halvt ord (16 bit) placeret på adresse 12” og det eneste spørgsmål er hvilken slags hukommelse vi adresserer? Svaret er, at kl x begynder (x+1)byte af den analyserede netværkspakke. Vi læser pakker fra Ethernet-grænsefladen eth0, og dette betyderat pakken ser sådan ud (for nemheds skyld antager vi, at der ikke er nogen VLAN-tags i pakken):

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

Så efter at have udført kommandoen ldh [12] i registret A der vil være et felt Ether Type — typen af ​​pakke, der transmitteres i denne Ethernet-ramme. På linje 1 sammenligner vi registrets indhold A (pakketype) c 0x86dd, og dette og spise Den type vi er interesseret i er IPv6. På linje 1 er der udover sammenligningskommandoen yderligere to kolonner - jt 2 и jf 3 — markeringer, som du skal gå til, hvis sammenligningen er vellykket (A == 0x86dd) og mislykkedes. Så i et vellykket tilfælde (IPv6) går vi til linje 2, og i et mislykket tilfælde - til linje 3. På linje 3 afsluttes programmet med kode 0 (kopier ikke pakken), på linje 2 afsluttes programmet med kode 262144 (kopier mig maksimalt 256 kilobytes pakke).

Et mere kompliceret eksempel: vi ser på TCP-pakker efter destinationsport

Lad os se, hvordan et filter ser ud, der kopierer alle TCP-pakker med destinationsport 666. Vi vil overveje IPv4-tilfældet, da IPv6-tilfældet er enklere. Efter at have studeret dette eksempel kan du selv udforske IPv6-filteret som en øvelse (ip6 and tcp dst port 666) og et filter til det generelle tilfælde (tcp dst port 666). Så det filter, vi er interesseret i, ser således ud:

$ 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 ved allerede, hvad linje 0 og 1 gør. På linje 2 har vi allerede kontrolleret, at dette er en IPv4-pakke (Ether Type = 0x800) og indlæs det i registret A 24. byte af pakken. Vores pakke ser ud

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

hvilket betyder, at vi indlæser i registret A feltet Protocol i IP-headeren, hvilket er logisk, fordi vi kun vil kopiere TCP-pakker. Vi sammenligner protokol med 0x6 (IPPROTO_TCP) på linje 3.

På linje 4 og 5 indlæser vi halvordene på adresse 20 og bruger kommandoen jset kontrollere, om en af ​​de tre er indstillet flag - iført den udstedte maske jset de tre mest signifikante bits ryddes. To af de tre bit fortæller os, om pakken er en del af en fragmenteret IP-pakke, og i så fald om det er det sidste fragment. Den tredje bit er reserveret og skal være nul. Vi ønsker ikke at kontrollere hverken heltal eller ødelagte pakker, så vi tjekker alle tre bits.

Linje 6 er den mest interessante i denne liste. Udtryk ldxb 4*([14]&0xf) betyder, at vi indlæser i registret X de mindst signifikante fire bit af den femtende byte af pakken ganget med 4. De mindst signifikante fire bit af den femtende byte er feltet Internet Header Længde IPv4 header, som gemmer længden af ​​headeren i ord, så du skal gange med 4. Interessant nok er udtrykket 4*([14]&0xf) er en betegnelse for en særlig adresseordning, der kun kan anvendes i denne form og kun til et register X, dvs. det kan vi heller ikke sige ldb 4*([14]&0xf) eller ldxb 5*([14]&0xf) (vi kan kun angive en anden offset, f.eks. ldxb 4*([16]&0xf)). Det er klart, at denne adresseordning blev tilføjet til BPF netop for at modtage X (indeksregister) IPv4 header længde.

Så på linje 7 forsøger vi at indlæse et halvt ord kl (X+16). Husk at 14 bytes er optaget af Ethernet-headeren, og X indeholder længden af ​​IPv4-headeren, forstår vi det i A TCP-destinationsporten er indlæst:

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

Til sidst sammenligner vi på linje 8 destinationsporten med den ønskede værdi, og på linje 9 eller 10 returnerer vi resultatet - uanset om pakken skal kopieres eller ej.

Tcpdump: indlæser

I de foregående eksempler dvælede vi specifikt ikke i detaljer om, hvordan vi nøjagtigt indlæser BPF-bytekode i kernen til pakkefiltrering. Generelt sagt, tcpdump porteret til mange systemer og til at arbejde med filtre tcpdump bruger biblioteket libpcap. Kort sagt, at placere et filter på en grænseflade ved hjælp af libpcap, skal du gøre følgende:

For at se hvordan funktionen pcap_setfilter implementeret i Linux, vi bruger strace (nogle linjer er blevet fjernet):

$ 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 to første outputlinjer opretter vi rå fatning at læse alle Ethernet-rammer og binde det til grænsefladen eth0. fra vores første eksempel vi ved, at filteret ip vil bestå af fire BPF-instruktioner, og på tredje linje ser vi, hvordan du bruger muligheden SO_ATTACH_FILTER systemopkald setsockopt vi indlæser og tilslutter et filter med længde 4. Dette er vores filter.

Det er værd at bemærke, at i klassisk BPF sker indlæsning og tilslutning af et filter altid som en atomoperation, og i den nye version af BPF er indlæsning af programmet og binding af det til hændelsesgeneratoren adskilt i tid.

Skjult sandhed

En lidt mere komplet version af outputtet ser sådan ud:

$ 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ævnt ovenfor indlæser og tilslutter vi vores filter til stikket på linje 5, men hvad sker der på linje 3 og 4? Det viser sig, at dette libpcap tager sig af os - så outputtet af vores filter ikke inkluderer pakker, der ikke opfylder det, biblioteket forbinder dummy filter ret #0 (slip alle pakker), skifter socket til ikke-blokerende tilstand og forsøger at trække alle pakker, der kunne forblive tilbage fra tidligere filtre.

I alt, for at filtrere pakker på Linux ved hjælp af klassisk BPF, skal du have et filter i form af en struktur som f.eks struct sock_fprog og en åben fatning, hvorefter filteret kan sættes på fatningen ved hjælp af et systemkald setsockopt.

Interessant nok kan filteret fastgøres til enhver fatning, ikke kun råt. Her eksempel et program, der afskærer alle undtagen de første to bytes fra alle indkommende UDP-datagrammer. (Jeg tilføjede kommentarer i koden for ikke at rode artiklen.)

Flere detaljer om brug setsockopt for tilslutning af filtre, se fatning (7), men om at skrive dine egne filtre som struct sock_fprog uden hjælp tcpdump vi taler i afsnittet Programmering af BPF med vores egne hænder.

Klassisk BPF og det XNUMX. århundrede

BPF blev inkluderet i Linux i 1997 og har været en arbejdshest i lang tid libpcap uden særlige ændringer (naturligvis Linux-specifikke ændringer, var, men de ændrede ikke det globale billede). De første alvorlige tegn på, at BPF ville udvikle sig, kom i 2011, da Eric Dumazet friede lappe, som tilføjer Just In Time Compiler til kernen - en oversætter til at konvertere BPF-bytekode til native x86_64 kode.

JIT compiler var den første i kæden af ​​ændringer: i 2012 optrådte mulighed for at skrive filtre til secomp, ved hjælp af BPF, i januar 2013 var der tilføjet modul xt_bpf, som giver dig mulighed for at skrive regler for iptables med hjælp fra BPF, og i oktober 2013 var tilføjet også et modul cls_bpf, som giver dig mulighed for at skrive trafikklassifikatorer ved hjælp af BPF.

Vi vil snart se på alle disse eksempler mere detaljeret, men først vil det være nyttigt for os at lære, hvordan man skriver og kompilerer vilkårlige programmer til BPF, da de muligheder, som biblioteket tilbyder libpcap begrænset (simpelt eksempel: filter genereret libpcap kan kun returnere to værdier - 0 eller 0x40000) eller generelt, som i tilfældet med seccomp, er de ikke gældende.

Programmering af BPF med vores egne hænder

Lad os stifte bekendtskab med det binære format af BPF-instruktioner, det er meget enkelt:

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

Hver instruktion optager 64 bit, hvor de første 16 bit er instruktionskoden, derefter er der to otte-bit indrykning, jt и jf, og 32 bit for argumentet K, hvis formål varierer fra kommando til kommando. For eksempel kommandoen ret, som afslutter programmet har koden 6, og returværdien tages fra konstanten K. I C er en enkelt BPF-instruktion repræsenteret som en struktur

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

og hele programmet er i form af en struktur

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

Således kan vi allerede skrive programmer (vi kender f.eks. instruktionskoderne fra [1]). Sådan kommer filteret til at se ud ip6 af vores første eksempel:

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 lovligt kan bruge i et opkald

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

At skrive programmer i form af maskinkoder er ikke særlig bekvemt, men nogle gange er det nødvendigt (for eksempel til fejlretning, oprettelse af enhedstest, skrive artikler om Habré osv.). For nemheds skyld, i filen <linux/filter.h> hjælpemakroer er defineret - samme eksempel som ovenfor kunne omskrives 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),
}

Denne mulighed er dog ikke særlig praktisk. Dette er, hvad Linux-kerneprogrammørerne ræsonnerede, og derfor i mappen tools/bpf kerner kan du finde en assembler og debugger til at arbejde med klassisk BPF.

Assembly sprog ligner meget debug output tcpdump, men derudover kan vi angive symbolske etiketter. For eksempel er her et program, der dropper alle pakker undtagen 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 genererer assembleren kode i formatet <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., for vores eksempel med TCP vil det være

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

Af hensyn til C-programmører kan et andet outputformat bruges:

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

Denne tekst kan kopieres ind i typestrukturdefinitionen struct sock_filter, som vi gjorde i begyndelsen af ​​dette afsnit.

Linux og netsniff-ng udvidelser

Ud over standard BPF, Linux og tools/bpf/bpf_asm støtte og ikke-standard sæt. Grundlæggende bruges instruktioner til at få adgang til felterne i en struktur struct sk_buff, som beskriver en netværkspakke i kernen. Der findes dog også andre former for hjælperinstruktioner, f.eks ldw cpu vil indlæses i registret A resultat af at køre en kernefunktion raw_smp_processor_id(). (I den nye version af BPF er disse ikke-standardudvidelser blevet udvidet til at give programmer et sæt kernehjælpere til at få adgang til hukommelse, strukturer og generere begivenheder.) Her er et interessant eksempel på et filter, hvor vi kun kopierer pakkeoverskrifter ind i brugerrummet ved hjælp af udvidelsen poff, nyttelast offset:

ld poff
ret a

BPF-udvidelser kan ikke bruges i tcpdump, men det er en god grund til at stifte bekendtskab med hjælpepakken netsniff-ng, som blandt andet indeholder et avanceret program netsniff-ng, som udover filtrering ved hjælp af BPF også indeholder en effektiv trafikgenerator, og mere avanceret end tools/bpf/bpf_asm, kaldet en BPF assembler bpfc. Pakken indeholder ret detaljeret dokumentation, se også links i slutningen af ​​artiklen.

secomp

Så vi ved allerede, hvordan man skriver BPF-programmer af vilkårlig kompleksitet og er klar til at se på nye eksempler, hvoraf det første er seccomp-teknologien, som tillader, ved hjælp af BPF-filtre, at administrere sæt og sæt af systemopkaldsargumenter, der er tilgængelige for en given proces og dens efterkommere.

Den første version af seccomp blev føjet til kernen i 2005 og var ikke særlig populær, da den kun gav en enkelt mulighed - at begrænse antallet af tilgængelige systemkald til en proces til følgende: read, write, exit и sigreturn, og den proces, der overtrådte reglerne, blev dræbt ved hjælp af SIGKILL. I 2012 tilføjede seccomp dog muligheden for at bruge BPF-filtre, så du kan definere et sæt tilladte systemkald og endda udføre kontrol af deres argumenter. (Interessant nok var Chrome en af ​​de første brugere af denne funktionalitet, og Chrome-folkene er i øjeblikket ved at udvikle en KRSI-mekanisme baseret på en ny version af BPF og tillader tilpasning af Linux-sikkerhedsmoduler.) Links til yderligere dokumentation kan findes i slutningen af artiklen.

Bemærk, at der allerede har været artikler på hub'en om at bruge seccomp, måske vil nogen læse dem før (eller i stedet for) at læse de følgende underafsnit. I artiklen Containere og sikkerhed: secomp giver eksempler på brug af seccomp, både 2007-versionen og versionen, der bruger BPF (filtre genereres ved hjælp af libseccomp), fortæller om forbindelsen af ​​seccomp med Docker, og giver også mange nyttige links. I artiklen Isolering af dæmoner med systemd eller "du behøver ikke Docker til dette!" Det dækker især, hvordan man tilføjer sortlister eller hvidlister over systemkald til dæmoner, der kører systemd.

Dernæst vil vi se, hvordan man skriver og indlæser filtre til seccomp i bare C og ved hjælp af biblioteket libseccomp og hvad er fordelene og ulemperne ved hver mulighed, og lad os endelig se, hvordan seccomp bruges af programmet strace.

Skrive- og indlæsningsfiltre til seccomp

Vi ved allerede, hvordan man skriver BPF-programmer, så lad os først se på seccomp-programmeringsgrænsefladen. Du kan indstille et filter på procesniveau, og alle underordnede processer vil arve begrænsningerne. Dette gøres ved hjælp af et systemkald seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

где &filter - dette er en pegepind til en struktur, vi allerede kender struct sock_fprog, dvs. BPF program.

Hvordan adskiller programmer til seccomp sig fra programmer til stikkontakter? Overført kontekst. I tilfælde af sockets fik vi et hukommelsesområde, der indeholdt pakken, og i tilfælde af seccomp fik vi en struktur som

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

Her nr er nummeret på det systemopkald, der skal startes, arch - nuværende arkitektur (mere om dette nedenfor), args - op til seks systemkaldsargumenter, og instruction_pointer er en pegepind til brugerrumsinstruktionen, der foretog systemkaldet. Således for eksempel at indlæse systemets opkaldsnummer i registeret A må vi sige

ldw [0]

Der er andre funktioner til seccomp-programmer, for eksempel kan konteksten kun tilgås ved 32-bit justering, og du kan ikke indlæse et halvt ord eller en byte - når du forsøger at indlæse et filter ldh [0] systemopkald seccomp kommer tilbage EINVAL. Funktionen kontrollerer de indlæste filtre seccomp_check_filter() kerner. (Det sjove er, at i den originale commit, der tilføjede seccomp-funktionaliteten, glemte de at tilføje tilladelse til at bruge instruktionen til denne funktion mod (division rest) og er nu ikke tilgængelig for seccomp BPF-programmer, siden dens tilføjelse vil bryde ABI.)

Grundlæggende ved vi allerede alt for at skrive og læse secomp-programmer. Normalt er programlogikken arrangeret som en hvid eller sort liste over systemkald, for eksempel programmet

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

tjekker en sortliste med fire systemopkald nummereret 304, 176, 239, 279. Hvad er disse systemopkald? Vi kan ikke sige med sikkerhed, da vi ikke ved, hvilken arkitektur programmet er skrevet til. Derfor har forfatterne af seccomp tilbud start alle programmer med et arkitekturtjek (den aktuelle arkitektur er angivet i konteksten som et felt arch Strukturen struct seccomp_data). Med arkitekturen markeret vil begyndelsen af ​​eksemplet se ud som:

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

og så ville vores systemopkaldsnumre få visse værdier.

Vi skriver og indlæser filtre til seccomp vha libseccomp

At skrive filtre i native kode eller i BPF assembly giver dig mulighed for at have fuld kontrol over resultatet, men på samme tid er det nogle gange at foretrække at have bærbar og/eller læsbar kode. Det vil biblioteket hjælpe os med libsecomp, som giver en standardgrænseflade til at skrive sorte eller hvide filtre.

Lad os for eksempel skrive et program, der kører en binær fil efter brugerens valg, efter at have installeret en sortliste over systemopkald fra ovenstående artikel (Programmet er blevet forenklet for større læsbarhed, den fulde version kan findes her):

#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 definerer vi et array sys_numbers af 40+ systemopkaldsnumre at blokere. Initialiser derefter konteksten ctx og fortæl biblioteket, hvad vi vil tillade (SCMP_ACT_ALLOW) alle systemkald som standard (det er nemmere at bygge sortlister). Derefter tilføjer vi et efter et alle systemopkald fra sortlisten. Som svar på et systemopkald fra listen anmoder vi SCMP_ACT_TRAP, i dette tilfælde vil seccomp sende et signal til processen SIGSYS med en beskrivelse af, hvilket systemkald der overtrådte reglerne. Til sidst indlæser vi programmet i kernen vha seccomp_load, som vil kompilere programmet og vedhæfte det til processen ved hjælp af et systemkald seccomp(2).

For en vellykket kompilering skal programmet være forbundet med biblioteket libseccomp, for eksempel:

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

Eksempel på en vellykket lancering:

$ ./seccomp_lib echo ok
ok

Eksempel på et blokeret systemopkald:

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

Vi bruger stracefor 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

hvordan kan vi vide, at programmet blev afsluttet på grund af brugen af ​​et ulovligt systemkald mount(2).

Så vi skrev et filter ved hjælp af biblioteket libseccomp, indpasning af ikke-triviel kode i fire linjer. I eksemplet ovenfor, hvis der er et stort antal systemkald, kan eksekveringstiden reduceres mærkbart, da kontrollen kun er en liste over sammenligninger. Til optimering havde libsecomp for nylig patch medfølger, som tilføjer understøttelse af filterattributten SCMP_FLTATR_CTL_OPTIMIZE. Indstilling af denne egenskab til 2 vil konvertere filteret til et binært søgeprogram.

Hvis du vil se, hvordan binære søgefiltre fungerer, så tag et kig på simpelt script, som genererer sådanne programmer i BPF assembler ved at ringe op til systemets opkaldsnumre, for eksempel:

$ 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 er umuligt at skrive noget væsentligt hurtigere, da BPF-programmer ikke kan udføre indrykningspring (vi kan f.eks. jmp A eller jmp [label+X]), og derfor er alle overgange statiske.

secomp og strace

Alle kender nytten strace er et uundværligt værktøj til at studere adfærden af ​​processer på Linux. Mange har dog også hørt om præstationsproblemer når du bruger dette værktøj. Faktum er, at strace implementeret vha ptrace(2), og i denne mekanisme kan vi ikke specificere, hvilket sæt systemkald vi skal bruge for at stoppe processen, dvs. for eksempel kommandoer

$ 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

behandles på nogenlunde samme tid, selvom vi i det andet tilfælde kun ønsker at spore ét systemkald.

Ny mulighed --seccomp-bpf, tilføjet til strace version 5.3, giver dig mulighed for at fremskynde processen mange gange, og starttiden under sporet af et systemkald er allerede sammenlignelig med tidspunktet for en almindelig opstart:

$ 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

(Her er der selvfølgelig et lille bedrag i, at vi ikke sporer hovedsystemkaldet for denne kommando. Hvis vi sporede f.eks. newfsstatderefter strace ville bremse lige så hårdt som uden --seccomp-bpf.)

Hvordan fungerer denne mulighed? Uden hende strace forbinder til processen og starter den med at bruge PTRACE_SYSCALL. Når en administreret proces udsteder et (et hvilket som helst) systemkald, overføres kontrollen til strace, som ser på argumenterne for systemkaldet og kører det med PTRACE_SYSCALL. Efter nogen tid afslutter processen systemkaldet, og når det afsluttes, overføres kontrollen igen strace, som ser på returværdierne og starter processen vha PTRACE_SYSCALL, og så videre.

BPF til de mindste, del nul: klassisk BPF

Med seccomp kan denne proces dog optimeres præcis som vi gerne vil. Nemlig hvis vi kun vil se på systemkaldet X, så kan vi skrive et BPF-filter det for X returnerer en værdi SECCOMP_RET_TRACE, og for opkald, der ikke er af interesse for os - SECCOMP_RET_ALLOW:

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

I dette tilfælde strace indledningsvis starter processen som PTRACE_CONT, behandles vores filter for hvert systemkald, hvis systemkaldet ikke er det X, så fortsætter processen med at køre, men hvis dette X, så vil seccomp overføre kontrollen stracesom vil se på argumenterne og starte processen som PTRACE_SYSCALL (da seccomp ikke har mulighed for at køre et program ved afslutning fra et systemkald). Når systemkaldet vender tilbage, strace vil genstarte processen vha PTRACE_CONT og vil vente på nye beskeder fra seccomp.

BPF til de mindste, del nul: klassisk BPF

Når du bruger muligheden --seccomp-bpf der er to begrænsninger. For det første vil det ikke være muligt at tilslutte sig en allerede eksisterende proces (option -p programmer strace), da dette ikke understøttes af secomp. For det andet er der ingen mulighed nej se på underordnede processer, da seccomp-filtre nedarves af alle underordnede processer uden mulighed for at deaktivere dette.

Lidt mere detaljeret hvordan strace arbejder med seccomp kan findes fra seneste rapport. For os er det mest interessante faktum, at den klassiske BPF repræsenteret af seccomp stadig bruges i dag.

xt_bpf

Lad os nu gå tilbage til verden af ​​netværk.

Baggrund: for lang tid siden, i 2007, var kernen tilføjet modul xt_u32 til netfilter. Det blev skrevet i analogi med en endnu mere gammel trafikklassificering cls_u32 og tillod dig at skrive vilkårlige binære regler for iptables ved hjælp af følgende simple operationer: indlæs 32 bit fra en pakke og udfør et sæt aritmetiske operationer på dem. For eksempel,

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

Indlæser de 32 bit af IP-headeren, startende ved padding 6, og anvender en maske på dem 0xFF (tag den lave byte). Dette felt protocol IP header, og vi sammenligner det med 1 (ICMP). Du kan kombinere mange kontroller i én regel, og du kan også udføre operatøren @ — flyt X bytes til højre. For eksempel reglen

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

kontrollerer, om TCP-sekvensnummeret ikke er det samme 0x29. Jeg vil ikke gå yderligere i detaljer, da det allerede er klart, at det ikke er særlig bekvemt at skrive sådanne regler i hånden. I artiklen BPF - den glemte bytekode, er der flere links med eksempler på brug og regelgenerering for xt_u32. Se også links i slutningen af ​​denne artikel.

Siden 2013 modul i stedet for modul xt_u32 du kan bruge et BPF baseret modul xt_bpf. Enhver, der har læst så langt, burde allerede være klar over princippet om dens funktion: kør BPF-bytekode som iptables-regler. Du kan oprette en ny regel, f.eks. sådan:

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

her <байткод> - dette er koden i assembler-outputformat bpf_asm som standard, f.eks.

$ 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 dette eksempel filtrerer vi alle UDP-pakker. Kontekst for et BPF-program i et modul xt_bpf, selvfølgelig peger på pakkedataene, i tilfælde af iptables, til begyndelsen af ​​IPv4-headeren. Returværdi fra BPF-program booleskHvor false betyder, at pakken ikke matchede.

Det er klart, at modulet xt_bpf understøtter mere komplekse filtre end eksemplet ovenfor. Lad os se på rigtige eksempler fra Cloudfare. Indtil for nylig brugte de modulet xt_bpf for at beskytte mod DDoS-angreb. I artiklen Introduktion til BPF-værktøjerne de forklarer, hvordan (og hvorfor) de genererer BPF-filtre og udgiver links til et sæt hjælpeprogrammer til at oprette sådanne filtre. For eksempel ved at bruge værktøjet bpfgen du kan oprette et BPF-program, der matcher en DNS-forespørgsel til et navn 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 indlæser vi først i registret X start af linjeadresse x04habrx03comx00 inde i et UDP-datagram, og kontroller derefter anmodningen: 0x04686162 <-> "x04hab" etc.

Lidt senere udgav Cloudfare p0f -> BPF-kompilerkoden. I artiklen Introduktion til p0f BPF-kompileren de taler om, hvad p0f er, og hvordan man konverterer p0f-signaturer til 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,
...

Bruger i øjeblikket ikke længere Cloudfare xt_bpf, siden de flyttede til XDP - en af ​​mulighederne for at bruge den nye version af BPF, se. L4Drop: XDP DDoS-reduktioner.

cls_bpf

Det sidste eksempel på at bruge klassisk BPF i kernen er klassificeringen cls_bpf til trafikstyringsundersystemet i Linux, tilføjet til Linux i slutningen af ​​2013 og konceptuelt erstattet det gamle cls_u32.

Vi vil dog ikke nu beskrive arbejdet cls_bpf, da dette fra et synspunkt om viden om klassisk BPF ikke vil give os noget - vi er allerede blevet bekendt med al funktionaliteten. Derudover vil vi i efterfølgende artikler, der taler om Extended BPF, møde denne klassificering mere end én gang.

En anden grund til ikke at tale om at bruge klassisk BPF c cls_bpf Problemet er, at sammenlignet med Extended BPF er anvendelsesområdet i dette tilfælde radikalt indsnævret: klassiske programmer kan ikke ændre indholdet af pakker og kan ikke gemme tilstand mellem opkald.

Så det er tid til at sige farvel til klassisk BPF og se på fremtiden.

Farvel til klassisk BPF

Vi så på, hvordan BPF-teknologien, der blev udviklet i begyndelsen af ​​halvfemserne, med succes levede i et kvart århundrede og indtil slutningen fandt nye applikationer. Men i lighed med overgangen fra stackmaskiner til RISC, der tjente som drivkraft for udviklingen af ​​klassisk BPF, skete der i 32'erne en overgang fra 64-bit til XNUMX-bit maskiner, og klassisk BPF begyndte at blive forældet. Derudover er mulighederne for klassisk BPF meget begrænsede, og udover den forældede arkitektur - vi har ikke mulighed for at gemme tilstand mellem opkald til BPF-programmer, der er ingen mulighed for direkte brugerinteraktion, der er ingen mulighed for at interagere med kernen, bortset fra at læse et begrænset antal strukturfelter sk_buff og starter de enkleste hjælpefunktioner, kan du ikke ændre indholdet af pakker og omdirigere dem.

Faktisk er det eneste, der i øjeblikket er tilbage af den klassiske BPF i Linux, API-grænsefladen, og inde i kernen bliver alle klassiske programmer, hvad enten det er socket-filtre eller seccomp-filtre, automatisk oversat til et nyt format, Extended BPF. (Vi vil tale om præcis, hvordan dette sker i den næste artikel.)

Overgangen til en ny arkitektur begyndte i 2013, da Alexey Starovoitov foreslog en BPF-opdateringsordning. I 2014 de tilsvarende patches begyndte at dukke op i kernen. Så vidt jeg forstår, var den oprindelige plan kun at optimere arkitekturen og JIT-kompileren til at køre mere effektivt på 64-bit maskiner, men i stedet markerede disse optimeringer begyndelsen på et nyt kapitel i Linux-udvikling.

Yderligere artikler i denne serie vil dække arkitekturen og anvendelserne af den nye teknologi, oprindeligt kendt som intern BPF, derefter udvidet BPF og nu blot BPF.

RЎSЃS <P "RєRё

  1. Steven McCanne og 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 glemte bytekode: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Introduktion til BPF-værktøjet: 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 oversigt: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Containere og sikkerhed: secomp
  11. habr: Isolering af dæmoner med systemd eller "du behøver ikke Docker til dette!"
  12. Paul Chaignon, "strace --seccomp-bpf: et kig under motorhjelmen", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Kilde: www.habr.com

Tilføj en kommentar