BPF for de minste, del null: klassisk BPF

Berkeley Packet Filters (BPF) er en Linux-kjerneteknologi som har vært på forsidene til engelskspråklige teknologipublikasjoner i flere år nå. Konferanser er fylt med rapporter om bruk og utvikling av BPF. David Miller, vedlikeholder av Linux-nettverksundersystemet, kaller foredraget sitt på Linux Plumbers 2018 "Denne samtalen handler ikke om XDP" (XDP er ett brukstilfelle for BPF). Brendan Gregg holder foredrag med tittelen Linux BPF Superpowers. Toke Høiland-Jørgensen lerat kjernen nå er en mikrokjerne. Thomas Graf fremmer ideen om at BPF er javascript for kjernen.

Det er fortsatt ingen systematisk beskrivelse av BPF på Habré, og derfor vil jeg i en artikkelserie forsøke å snakke om teknologiens historie, beskrive arkitekturen og utviklingsverktøyene, og skissere bruksområdene og praksisen ved bruk av BPF. Denne artikkelen, null, i serien, forteller historien og arkitekturen til klassisk BPF, og avslører også hemmelighetene til driftsprinsippene. tcpdump, seccomp, strace, og mye mer.

Utviklingen av BPF kontrolleres av Linux-nettverkssamfunnet, de viktigste eksisterende applikasjonene til BPF er relatert til nettverk og derfor, med tillatelse @eukariot, kalte jeg serien "BPF for de minste", til ære for den flotte serien "Nettverk for de minste".

Et kort kurs i BPFs historie(c)

Moderne BPF-teknologi er en forbedret og utvidet versjon av den gamle teknologien med samme navn, nå kalt klassisk BPF for å unngå forvirring. Et velkjent verktøy ble laget basert på den klassiske BPF tcpdump, mekanisme seccomp, samt mindre kjente moduler xt_bpf for iptables og klassifiserer cls_bpf. I moderne Linux blir klassiske BPF-programmer automatisk oversatt til den nye formen, men fra et brukersynspunkt har API-en holdt seg på plass og nye bruksområder for klassisk BPF, som vi vil se i denne artikkelen, blir fortsatt funnet. Av denne grunn, og også fordi etter historien om utviklingen av klassisk BPF i Linux, det vil bli tydeligere hvordan og hvorfor det utviklet seg til sin moderne form, bestemte jeg meg for å starte med en artikkel om klassisk BPF.

På slutten av åttitallet av forrige århundre ble ingeniører fra det berømte Lawrence Berkeley Laboratory interessert i spørsmålet om hvordan man riktig filtrerte nettverkspakker på maskinvare som var moderne på slutten av åttitallet av forrige århundre. Den grunnleggende ideen med filtrering, opprinnelig implementert i CSPF (CMU/Stanford Packet Filter) teknologi, var å filtrere unødvendige pakker så tidlig som mulig, dvs. i kjerneplass, siden dette unngår å kopiere unødvendige data inn i brukerområdet. For å gi kjøretidssikkerhet for å kjøre brukerkode i kjerneplass, ble en virtuell maskin med sandkasse brukt.

Imidlertid ble de virtuelle maskinene for eksisterende filtre designet for å kjøre på stabelbaserte maskiner og kjørte ikke like effektivt på nyere RISC-maskiner. Som et resultat, gjennom innsatsen til ingeniører fra Berkeley Labs, ble en ny BPF (Berkeley Packet Filters)-teknologi utviklet, hvis virtuelle maskinarkitektur ble designet basert på Motorola 6502-prosessoren - arbeidshesten til så velkjente produkter som Apple II eller NES. Den nye virtuelle maskinen økte filterytelsen titalls ganger sammenlignet med eksisterende løsninger.

BPF maskinarkitektur

Vi vil bli kjent med arkitektur på en fungerende måte, analysere eksempler. Men til å begynne med, la oss si at maskinen hadde to 32-bits registre tilgjengelig for brukeren, en akkumulator A og indeksregister X, 64 byte minne (16 ord), tilgjengelig for skriving og påfølgende lesing, og et lite system med kommandoer for å arbeide med disse objektene. Hoppinstruksjoner for implementering av betingede uttrykk var også tilgjengelige i programmene, men for å garantere rettidig fullføring av programmet, kunne hopp bare gjøres fremover, det vil si at det spesielt var forbudt å lage løkker.

Den generelle ordningen for å starte maskinen er som følger. Brukeren lager et program for BPF-arkitekturen og bruker noen kjernemekanisme (som et systemkall), laster og kobler programmet til til noen til hendelsesgeneratoren i kjernen (for eksempel er en hendelse ankomsten av neste pakke på nettverkskortet). Når en hendelse inntreffer, kjører kjernen programmet (for eksempel i en tolk), og maskinminnet tilsvarer til noen kjerneminneregion (for eksempel data fra en innkommende pakke).

Ovennevnte vil være nok for oss til å begynne å se på eksempler: vi vil bli kjent med systemet og kommandoformatet etter behov. Hvis du umiddelbart vil studere kommandosystemet til en virtuell maskin og lære om alle dens evner, kan du lese den originale artikkelen BSD-pakkefilteret og/eller den første halvdelen av filen Dokumentasjon/nettverk/filter.txt fra kjernedokumentasjonen. I tillegg kan du studere presentasjonen libpcap: En arkitektur- og optimaliseringsmetodikk for pakkefangst, der McCanne, en av forfatterne av BPF, snakker om skaperverkets historie libpcap.

Vi går videre til å vurdere alle de viktige eksemplene på bruk av klassisk BPF på Linux: tcpdump (libpcap), secomp, xt_bpf, cls_bpf.

tcpdump

Utviklingen av BPF ble utført parallelt med utviklingen av frontend for pakkefiltrering - et velkjent verktøy tcpdump. Og siden dette er det eldste og mest kjente eksemplet på bruk av klassisk BPF, tilgjengelig på mange operativsystemer, vil vi begynne studiet av teknologien med det.

(Jeg kjørte alle eksemplene i denne artikkelen på Linux 5.6.0-rc6. Utdataene til noen kommandoer er redigert for bedre lesbarhet.)

Eksempel: observere IPv6-pakker

La oss forestille oss at vi ønsker å se på alle IPv6-pakker på et grensesnitt eth0. For å gjøre dette kan vi kjøre programmet tcpdump med et enkelt filter ip6:

$ sudo tcpdump -i eth0 ip6

I dette tilfellet, tcpdump kompilerer filteret ip6 inn i BPF-arkitekturens bytekode og send den til kjernen (se detaljer i avsnittet Tcpdump: laster). Det lastede filteret vil bli kjørt for hver pakke som passerer gjennom grensesnittet eth0. Hvis filteret returnerer en verdi som ikke er null n, deretter opp til n byte av pakken vil bli kopiert til brukerområdet, og vi vil se det i utdataene tcpdump.

BPF for de minste, del null: klassisk BPF

Det viser seg at vi enkelt kan finne ut hvilken bytekode som ble sendt til kjernen tcpdump ved hjelp av tcpdump, hvis vi kjører den 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å linje null kjører vi kommandoen ldh [12], som står for «last inn i register A et halvt ord (16 bits) plassert på adresse 12” og det eneste spørsmålet er hva slags minne vi adresserer? Svaret er at kl x begynner (x+1)byte av den analyserte nettverkspakken. Vi leser pakker fra Ethernet-grensesnittet eth0og dette betyrat pakken ser slik ut (for enkelhets skyld antar vi at det ikke er noen VLAN-koder i pakken):

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

Så etter å ha utført kommandoen ldh [12] i registeret A det vil være et felt Ether Type — typen pakke som overføres i denne Ethernet-rammen. På linje 1 sammenligner vi innholdet i registeret A (pakketype) c 0x86ddog dette og har Typen vi er interessert i er IPv6. På linje 1, i tillegg til sammenligningskommandoen, er det ytterligere to kolonner - jt 2 и jf 3 — merker du må gå til hvis sammenligningen er vellykket (A == 0x86dd) og mislykket. Så, i et vellykket tilfelle (IPv6) går vi til linje 2, og i et mislykket tilfelle - til linje 3. På linje 3 avsluttes programmet med kode 0 (ikke kopier pakken), på linje 2 avsluttes programmet med kode 262144 (kopier meg maksimalt 256 kilobyte pakke).

Et mer komplisert eksempel: vi ser på TCP-pakker etter destinasjonsport

La oss se hvordan et filter ser ut som kopierer alle TCP-pakker med destinasjonsport 666. Vi vil vurdere IPv4-saken, siden IPv6-saken er enklere. Etter å ha studert dette eksempelet, kan du utforske IPv6-filteret selv som en øvelse (ip6 and tcp dst port 666) og et filter for den generelle saken (tcp dst port 666). Så filteret vi er interessert i ser slik ut:

$ 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 allerede hva linje 0 og 1 gjør. På linje 2 har vi allerede sjekket at dette er en IPv4-pakke (Ether Type = 0x800) og last den inn i registeret A 24. byte av pakken. Pakken vår ser ut som

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

som betyr at vi laster inn i registeret A protokollfeltet til IP-overskriften, som er logisk, fordi vi kun vil kopiere TCP-pakker. Vi sammenligner Protokoll med 0x6 (IPPROTO_TCP) på linje 3.

På linje 4 og 5 laster vi inn halvordene som ligger på adresse 20 og bruker kommandoen jset sjekk om en av de tre er satt flagg - bære den utstedte masken jset de tre mest signifikante bitene slettes. To av de tre bitene forteller oss om pakken er en del av en fragmentert IP-pakke, og i så fall om det er det siste fragmentet. Den tredje biten er reservert og må være null. Vi ønsker ikke å sjekke verken ufullstendige eller ødelagte pakker, så vi sjekker alle tre bitene.

Linje 6 er den mest interessante i denne oppføringen. Uttrykk ldxb 4*([14]&0xf) betyr at vi laster inn i registeret X de minst signifikante fire bitene av den femtende byten i pakken multiplisert med 4. De minst signifikante fire bitene av den femtende byten er feltet Lengde på internetthode IPv4-overskrift, som lagrer lengden på overskriften i ord, så du må multiplisere med 4. Interessant nok er uttrykket 4*([14]&0xf) er en betegnelse på en spesiell adresseordning som kun kan brukes i denne formen og kun for et register X, dvs. det kan vi heller ikke si ldb 4*([14]&0xf) eller ldxb 5*([14]&0xf) (vi kan bare spesifisere en annen offset, for eksempel, ldxb 4*([16]&0xf)). Det er klart at denne adresseordningen ble lagt til BPF nettopp for å motta X (indeksregister) IPv4-headerlengde.

Så på linje 7 prøver vi å laste et halvt ord kl (X+16). Husk at 14 byte er okkupert av Ethernet-overskriften, og X inneholder lengden på IPv4-hodet, forstår vi det i A TCP-destinasjonsporten er lastet inn:

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

Til slutt, på linje 8 sammenligner vi destinasjonsporten med ønsket verdi og på linje 9 eller 10 returnerer vi resultatet - enten vi skal kopiere pakken eller ikke.

Tcpdump: laster

I de forrige eksemplene har vi spesifikt ikke dvele i detalj på nøyaktig hvordan vi laster BPF-bytekode inn i kjernen for pakkefiltrering. Generelt sett, tcpdump portert til mange systemer og for arbeid med filtre tcpdump bruker biblioteket libpcap. Kort fortalt, for å plassere et filter på et grensesnitt ved hjelp av libpcap, må du gjøre følgende:

For å se hvordan funksjonen pcap_setfilter implementert i Linux, bruker vi strace (noen linjer er 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 linjene med utdata lager vi rå stikkontakt for å lese alle Ethernet-rammer og binde den til grensesnittet eth0. Fra vårt første eksempel vi vet at filteret ip vil bestå av fire BPF-instruksjoner, og på den tredje linjen ser vi hvordan du bruker alternativet SO_ATTACH_FILTER systemanrop setsockopt vi laster og kobler til et filter med lengde 4. Dette er vårt filter.

Det er verdt å merke seg at i klassisk BPF skjer lasting og tilkobling av et filter alltid som en atomoperasjon, og i den nye versjonen av BPF blir lasting av programmet og binding av det til hendelsesgeneratoren separert i tid.

Skjult sannhet

En litt mer komplett versjon av utdataene ser slik ut:

$ 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 nevnt ovenfor laster og fester vi filteret vårt til stikkontakten på linje 5, men hva skjer på linje 3 og 4? Det viser seg at dette libpcap tar vare på oss - slik at utdataene fra filteret vårt ikke inkluderer pakker som ikke tilfredsstiller det, biblioteket kobler til dummy filter ret #0 (slipp alle pakker), bytter kontakten til ikke-blokkerende modus og prøver å trekke fra alle pakker som kan forbli fra tidligere filtre.

Totalt, for å filtrere pakker på Linux ved å bruke klassisk BPF, må du ha et filter i form av en struktur som struct sock_fprog og en åpen stikkontakt, hvoretter filteret kan festes til stikkontakten ved hjelp av et systemanrop setsockopt.

Interessant nok kan filteret festes til hvilken som helst stikkontakt, ikke bare rå. Her eksempel et program som avskjærer alle unntatt de to første bytene fra alle innkommende UDP-datagrammer. (Jeg la til kommentarer i koden for ikke å rote artikkelen.)

Flere detaljer om bruk setsockopt for tilkobling av filtre, se stikkontakt (7), men om å skrive dine egne filtre som struct sock_fprog uten hjelp tcpdump vi snakkes i avsnittet Programmering av BPF med egne hender.

Klassisk BPF og det XNUMX. århundre

BPF ble inkludert i Linux i 1997 og har vært en arbeidshest i lang tid libpcap uten noen spesielle endringer (linux-spesifikke endringer, selvfølgelig, Vi var, men de endret ikke det globale bildet). De første alvorlige tegnene på at BPF ville utvikle seg kom i 2011, da Eric Dumazet fridde lapp, som legger Just In Time Compiler til kjernen - en oversetter for å konvertere BPF-bytekode til native x86_64 kode.

JIT-kompilatoren var den første i kjeden av endringer: i 2012 dukket opp evne til å skrive filtre for secomp, ved hjelp av BPF, i januar 2013 var det la til modul xt_bpf, som lar deg skrive regler for iptables ved hjelp av BPF, og i oktober 2013 ble la til også en modul cls_bpf, som lar deg skrive trafikkklassifiserere ved hjelp av BPF.

Vi vil se på alle disse eksemplene mer detaljert snart, men først vil det være nyttig for oss å lære å skrive og kompilere vilkårlige programmer for BPF, siden mulighetene som tilbys av biblioteket libpcap begrenset (enkelt eksempel: filter generert libpcap kan returnere bare to verdier - 0 eller 0x40000) eller generelt, som i tilfellet med seccomp, er ikke aktuelt.

Programmering av BPF med egne hender

La oss bli kjent med det binære formatet til BPF-instruksjoner, det er veldig enkelt:

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

Hver instruksjon opptar 64 biter, der de første 16 bitene er instruksjonskoden, deretter er det to åtte-biters innrykk, jt и jf, og 32 biter for argumentet K, hvis formål varierer fra kommando til kommando. For eksempel kommandoen ret, som avslutter programmet har koden 6, og returverdien tas fra konstanten K. I C er en enkelt BPF-instruksjon representert som en struktur

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

og hele programmet er i form av en struktur

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

Dermed kan vi allerede skrive programmer (for eksempel kjenner vi instruksjonskodene fra [1]). Slik vil filteret se ut ip6 av vårt 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 lovlig kan bruke i en samtale

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

Å skrive programmer i form av maskinkoder er ikke veldig praktisk, men noen ganger er det nødvendig (for eksempel for feilsøking, lage enhetstester, skrive artikler om Habré, etc.). For enkelhets skyld, i filen <linux/filter.h> hjelpemakroer er definert - samme eksempel som ovenfor kan skrives 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),
}

Dette alternativet er imidlertid ikke veldig praktisk. Dette er hva Linux-kjernen programmerere resonnerte, og derfor i katalogen tools/bpf kjerner kan du finne en assembler og debugger for å jobbe med klassisk BPF.

Assembly-språket er veldig likt feilsøkingsutdata tcpdump, men i tillegg kan vi spesifisere symbolske etiketter. For eksempel, her er et program som slipper alle pakker unntatt 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 vårt eksempel med TCP vil det være 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,

For enkelhets skyld for C-programmerere, kan et annet utdataformat brukes:

$ 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 teksten kan kopieres inn i typestrukturdefinisjonen struct sock_filter, som vi gjorde i begynnelsen av denne delen.

Linux og netsniff-ng utvidelser

I tillegg til standard BPF, Linux og tools/bpf/bpf_asm støtte og ikke-standard sett. I utgangspunktet brukes instruksjoner for å få tilgang til feltene til en struktur struct sk_buff, som beskriver en nettverkspakke i kjernen. Men det finnes også andre typer hjelpeinstruksjoner, for eksempel ldw cpu vil lastes inn i registeret A resultat av å kjøre en kjernefunksjon raw_smp_processor_id(). (I den nye versjonen av BPF er disse ikke-standardutvidelsene utvidet til å gi programmer et sett med kjernehjelpere for tilgang til minne, strukturer og generering av hendelser.) Her er et interessant eksempel på et filter der vi kun kopierer pakkehoder inn i brukerområdet ved hjelp av utvidelsen poff, nyttelastforskyvning:

ld poff
ret a

BPF-utvidelser kan ikke brukes i tcpdump, men dette er en god grunn til å bli kjent med hjelpepakken netsniff-ng, som blant annet inneholder et avansert program netsniff-ng, som i tillegg til filtrering ved hjelp av BPF, også inneholder en effektiv trafikkgenerator, og mer avansert enn tools/bpf/bpf_asm, kalt en BPF-montør bpfc. Pakken inneholder ganske detaljert dokumentasjon, se også lenkene på slutten av artikkelen.

secomp

Så vi vet allerede hvordan vi skriver BPF-programmer med vilkårlig kompleksitet og er klare til å se på nye eksempler, hvorav det første er seccomp-teknologien, som tillater, ved hjelp av BPF-filtre, å administrere settet og settet med systemanropsargumenter tilgjengelig for en gitt prosess og dens etterkommere.

Den første versjonen av seccomp ble lagt til kjernen i 2005 og var ikke veldig populær, siden den ga bare ett enkelt alternativ - å begrense settet med systemanrop tilgjengelig for en prosess til følgende: read, write, exit и sigreturn, og prosessen som brøt reglene ble drept med SIGKILL. I 2012 la seccomp imidlertid til muligheten til å bruke BPF-filtre, slik at du kan definere et sett med tillatte systemanrop og til og med sjekke argumentene deres. (Interessant nok var Chrome en av de første brukerne av denne funksjonaliteten, og Chrome-folket utvikler for tiden en KRSI-mekanisme basert på en ny versjon av BPF og tillater tilpasning av Linux-sikkerhetsmoduler.) Lenker til tilleggsdokumentasjon finner du på slutten av artikkelen.

Merk at det allerede har vært artikler på huben om bruk av seccomp, kanskje noen vil lese dem før (eller i stedet for) å lese de følgende underavsnittene. I artikkelen Containere og sikkerhet: secomp gir eksempler på bruk av seccomp, både 2007-versjonen og versjonen som bruker BPF (filtre genereres ved hjelp av libseccomp), snakker om koblingen av seccomp med Docker, og gir også mange nyttige lenker. I artikkelen Isoler demoner med systemd eller "du trenger ikke Docker for dette!" Den dekker spesielt hvordan du legger til svartelister eller hvitelister over systemanrop for demoner som kjører systemd.

Deretter skal vi se hvordan du skriver og laster filtre for seccomp i bare C og ved hjelp av biblioteket libseccomp og hva er fordelene og ulempene med hvert alternativ, og til slutt, la oss se hvordan seccomp brukes av programmet strace.

Skrive og laste filtre for seccomp

Vi vet allerede hvordan man skriver BPF-programmer, så la oss først se på seccomp-programmeringsgrensesnittet. Du kan angi et filter på prosessnivå, og alle underordnede prosesser vil arve begrensningene. Dette gjøres ved hjelp av et systemanrop seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

der &filter - Dette er en pekepinn til en struktur som allerede er kjent for oss struct sock_fprog, dvs. BPF-program.

Hvordan skiller programmer for seccomp seg fra programmer for stikkontakter? Overført kontekst. Når det gjelder sockets, fikk vi et minneområde som inneholdt pakken, og i tilfellet med seccomp fikk vi en struktur som

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

Her nr er nummeret til systemanropet som skal startes, arch - nåværende arkitektur (mer om dette nedenfor), args - opptil seks systemanropsargumenter, og instruction_pointer er en peker til brukerplassinstruksjonen som gjorde systemkallingen. For eksempel å laste inn systemnummeret i registeret A må vi si

ldw [0]

Det er andre funksjoner for seccomp-programmer, for eksempel kan konteksten bare nås med 32-bits justering og du kan ikke laste et halvt ord eller en byte - når du prøver å laste et filter ldh [0] systemanrop seccomp vil returnere EINVAL. Funksjonen kontrollerer de innlastede filtrene seccomp_check_filter() kjerner. (Det morsomme er at i den opprinnelige commit som la til seccomp-funksjonaliteten, glemte de å legge til tillatelse til å bruke instruksjonen til denne funksjonen mod (divisjonsrest) og er nå utilgjengelig for seccomp BPF-programmer, siden det ble lagt til vil bryte ABI.)

I utgangspunktet vet vi allerede alt for å skrive og lese secomp-programmer. Vanligvis er programlogikken ordnet som en hvit eller svart liste over systemanrop, 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

sjekker en svarteliste med fire systemanrop nummerert 304, 176, 239, 279. Hva er disse systemanropene? Vi kan ikke si noe sikkert, siden vi ikke vet hvilken arkitektur programmet er skrevet for. Derfor har forfatterne av seccomp tilbud start alle programmer med en arkitektursjekk (gjeldende arkitektur er angitt i konteksten som et felt arch strukturen struct seccomp_data). Med arkitekturen merket, vil begynnelsen av eksemplet se slik ut:

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

og da ville systemets telefonnumre få visse verdier.

Vi skriver og laster inn filtre for seccomp ved hjelp av libseccomp

Å skrive filtre i native kode eller i BPF-montering lar deg ha full kontroll over resultatet, men samtidig er det noen ganger å foretrekke å ha bærbar og/eller lesbar kode. Biblioteket vil hjelpe oss med dette libsecomp, som gir et standard grensesnitt for å skrive svarte eller hvite filtre.

La oss for eksempel skrive et program som kjører en binær fil etter brukerens valg, etter å ha installert en svarteliste over systemanrop fra tidligere artikkelen ovenfor (Programmet er forenklet for større lesbarhet, fullversjonen finnes 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 en matrise sys_numbers av 40+ systemanropsnumre å blokkere. Initialiser deretter konteksten ctx og fortell biblioteket hva vi vil tillate (SCMP_ACT_ALLOW) alle systemanrop som standard (det er lettere å bygge svartelister). Så, en etter en, legger vi til alle systemanrop fra svartelisten. Som svar på et systemanrop fra listen ber vi om SCMP_ACT_TRAP, i dette tilfellet vil seccomp sende et signal til prosessen SIGSYS med beskrivelse av hvilket systemanrop som brøt med reglene. Til slutt laster vi programmet inn i kjernen ved hjelp av seccomp_load, som vil kompilere programmet og knytte det til prosessen ved hjelp av et systemanrop seccomp(2).

For vellykket kompilering må programmet være knyttet til 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 lansering:

$ ./seccomp_lib echo ok
ok

Eksempel på et blokkert systemanrop:

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

Vi bruker 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 vite at programmet ble avsluttet på grunn av bruk av et ulovlig systemanrop mount(2).

Så vi skrev et filter ved å bruke biblioteket libseccomp, tilpasse ikke-triviell kode i fire linjer. I eksemplet ovenfor, hvis det er et stort antall systemanrop, kan utførelsestiden reduseres merkbart, siden sjekken bare er en liste over sammenligninger. For optimalisering hadde libsecomp nylig patch inkludert, som legger til støtte for filterattributtet SCMP_FLTATR_CTL_OPTIMIZE. Hvis du setter dette attributtet til 2, konverteres filteret til et binært søkeprogram.

Hvis du vil se hvordan binære søkefiltre fungerer, ta en titt på enkelt manus, som genererer slike programmer i BPF assembler ved å ringe systemets anropsnumre, 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

Du vil ikke kunne skrive noe vesentlig raskere, siden BPF-programmer ikke kan utføre innrykkshopp (vi kan for eksempel ikke gjøre jmp A eller jmp [label+X]) og derfor er alle overganger statiske.

secomp og strace

Alle kjenner nytten strace er et uunnværlig verktøy for å studere oppførselen til prosesser på Linux. Men mange har også hørt om ytelsesproblemer når du bruker dette verktøyet. Faktum er det strace implementert ved hjelp av ptrace(2), og i denne mekanismen kan vi ikke spesifisere ved hvilket sett med systemanrop vi trenger for å stoppe prosessen, 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å omtrent samme tid, selv om vi i det andre tilfellet ønsker å spore kun ett systemanrop.

Nytt alternativ --seccomp-bpf, lagt til strace versjon 5.3, lar deg fremskynde prosessen mange ganger, og oppstartstiden under sporet av ett systemanrop er allerede sammenlignbart med tidspunktet for en vanlig oppstart:

$ 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 det selvfølgelig et lite bedrag ved at vi ikke sporer hovedsystemkallet til denne kommandoen. Hvis vi sporer f.eks. newfsstatderetter strace ville bremse like hardt som uten --seccomp-bpf.)

Hvordan fungerer dette alternativet? Uten henne strace kobles til prosessen og begynner å bruke den PTRACE_SYSCALL. Når en administrert prosess sender ut et (hvilket som helst) systemkall, overføres kontrollen til strace, som ser på argumentene til systemkallet og kjører det med PTRACE_SYSCALL. Etter en tid fullfører prosessen systemanropet, og når den avsluttes, overføres kontrollen igjen strace, som ser på returverdiene og starter prosessen ved å bruke PTRACE_SYSCALL, og så videre.

BPF for de minste, del null: klassisk BPF

Med seccomp kan imidlertid denne prosessen optimaliseres akkurat slik vi ønsker. Nemlig hvis vi kun vil se på systemanropet X, så kan vi skrive et BPF-filter som for X returnerer en verdi SECCOMP_RET_TRACE, og for samtaler som ikke er av interesse for oss - SECCOMP_RET_ALLOW:

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

I dette tilfellet strace starter først prosessen som PTRACE_CONT, behandles filteret vårt for hvert systemanrop, hvis systemanropet ikke er det X, så fortsetter prosessen å kjøre, men hvis dette X, så vil seccomp overføre kontrollen stracesom vil se på argumentene og starte prosessen som PTRACE_SYSCALL (siden seccomp ikke har muligheten til å kjøre et program ved utgang fra et systemanrop). Når systemanropet kommer tilbake, strace vil starte prosessen på nytt med PTRACE_CONT og vil vente på nye meldinger fra seccomp.

BPF for de minste, del null: klassisk BPF

Når du bruker alternativet --seccomp-bpf det er to restriksjoner. For det første vil det ikke være mulig å bli med i en allerede eksisterende prosess (opsjon -p programmer strace), siden dette ikke støttes av secomp. For det andre er det ingen mulighet no se på underordnede prosesser, siden seccomp-filtre arves av alle underordnede prosesser uten muligheten til å deaktivere dette.

Litt mer detaljert hvordan strace fungerer med seccomp kan finnes fra fersk rapport. For oss er det mest interessante faktum at den klassiske BPF representert av seccomp fortsatt brukes i dag.

xt_bpf

La oss nå gå tilbake til verden av nettverk.

Bakgrunn: for lenge siden, i 2007, var kjernen la til modul xt_u32 for nettfilter. Den ble skrevet i analogi med en enda eldre trafikkklassifiserer cls_u32 og tillot deg å skrive vilkårlige binære regler for iptables ved å bruke følgende enkle operasjoner: last 32 biter fra en pakke og utfør et sett med aritmetiske operasjoner på dem. For eksempel,

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

Laster de 32 bitene av IP-overskriften, starter ved utfylling 6, og bruker en maske på dem 0xFF (ta den lave byten). Dette jordet protocol IP-header og vi sammenligner den med 1 (ICMP). Du kan kombinere mange sjekker i en regel, og du kan også utføre operatøren @ — flytt X byte til høyre. For eksempel regelen

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

sjekker om TCP-sekvensnummeret ikke er likt 0x29. Jeg vil ikke gå nærmere inn på detaljer, siden det allerede er klart at det ikke er veldig praktisk å skrive slike regler for hånd. I artikkelen BPF - den glemte bytekoden, er det flere lenker med eksempler på bruk og regelgenerering for xt_u32. Se også lenkene på slutten av denne artikkelen.

Siden 2013 modul i stedet for modul xt_u32 du kan bruke en BPF-basert modul xt_bpf. Alle som har lest så langt, bør allerede være klar over prinsippet for driften: kjør BPF-bytekode som iptables-regler. Du kan opprette en ny regel, for eksempel slik:

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

her <байткод> - dette er koden i assembler-utdataformat bpf_asm som standard, for eksempel

$ 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 eksemplet filtrerer vi alle UDP-pakker. Kontekst for et BPF-program i en modul xt_bpfpeker selvfølgelig på pakkedataene, når det gjelder iptables, til begynnelsen av IPv4-overskriften. Returverdi fra BPF-program boolskDer false betyr at pakken ikke stemte.

Det er tydelig at modulen xt_bpf støtter mer komplekse filtre enn eksemplet ovenfor. La oss se på ekte eksempler fra Cloudfare. Inntil nylig brukte de modulen xt_bpf for å beskytte mot DDoS-angrep. I artikkelen Vi introduserer BPF-verktøyene de forklarer hvordan (og hvorfor) de genererer BPF-filtre og publiserer lenker til et sett med verktøy for å lage slike filtre. For eksempel ved å bruke verktøyet bpfgen du kan lage et BPF-program som samsvarer med en DNS-spørring for 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 laster vi først inn i registeret X start på linjeadresse x04habrx03comx00 inne i et UDP-datagram og sjekk deretter forespørselen: 0x04686162 <-> "x04hab" etc.

Litt senere publiserte Cloudfare kompilatorkoden p0f -> BPF. I artikkelen Vi introduserer p0f BPF-kompilatoren de snakker om hva 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,
...

Bruker foreløpig ikke lenger Cloudfare xt_bpf, siden de flyttet til XDP - et av alternativene for å bruke den nye versjonen av BPF, se. L4Drop: XDP DDoS-begrensninger.

cls_bpf

Det siste eksemplet på bruk av klassisk BPF i kjernen er klassifikatoren cls_bpf for trafikkontrollundersystemet i Linux, lagt til Linux på slutten av 2013 og konseptuelt erstattet det eldgamle cls_u32.

Vi skal imidlertid ikke nå beskrive arbeidet cls_bpf, siden dette fra et synspunkt om kunnskap om klassisk BPF ikke vil gi oss noe - vi har allerede blitt kjent med all funksjonalitet. I tillegg, i påfølgende artikler som snakker om Extended BPF, vil vi møte denne klassifisereren mer enn én gang.

En annen grunn til å ikke snakke om å bruke klassisk BPF c cls_bpf Problemet er at, sammenlignet med Extended BPF, er omfanget av anvendelighet i dette tilfellet radikalt innsnevret: klassiske programmer kan ikke endre innholdet i pakker og kan ikke lagre tilstand mellom samtaler.

Så det er på tide å si farvel til klassiske BPF og se på fremtiden.

Farvel til klassisk BPF

Vi så på hvordan BPF-teknologien, utviklet på begynnelsen av nittitallet, levde med suksess i et kvart århundre og helt til slutten fant nye bruksområder. I likhet med overgangen fra stabelmaskiner til RISC, som fungerte som en drivkraft for utviklingen av klassisk BPF, skjedde det imidlertid på 32-tallet en overgang fra 64-biters til XNUMX-biters maskiner og klassisk BPF begynte å bli foreldet. I tillegg er funksjonene til klassisk BPF svært begrenset, og i tillegg til den utdaterte arkitekturen - vi har ikke muligheten til å lagre tilstand mellom samtaler til BPF-programmer, det er ingen mulighet for direkte brukerinteraksjon, det er ingen mulighet for samhandling med kjernen, bortsett fra å lese et begrenset antall strukturfelt sk_buff og starter de enkleste hjelpefunksjonene, kan du ikke endre innholdet i pakker og omdirigere dem.

Faktisk er alt som gjenstår av den klassiske BPF i Linux API-grensesnittet, og inne i kjernen blir alle klassiske programmer, enten det er socket-filtre eller seccomp-filtre, automatisk oversatt til et nytt format, Extended BPF. (Vi vil snakke om nøyaktig hvordan dette skjer i neste artikkel.)

Overgangen til en ny arkitektur begynte i 2013, da Alexey Starovoitov foreslo en BPF-oppdateringsordning. I 2014 de tilsvarende oppdateringene begynte å dukke opp i kjernen. Så vidt jeg forstår, var den opprinnelige planen kun å optimalisere arkitekturen og JIT-kompilatoren for å kjøre mer effektivt på 64-bits maskiner, men i stedet markerte disse optimaliseringene begynnelsen på et nytt kapittel i Linux-utvikling.

Ytterligere artikler i denne serien vil dekke arkitekturen og anvendelsene av den nye teknologien, opprinnelig kjent som intern BPF, deretter utvidet BPF, og nå ganske enkelt BPF.

referanser

  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 bytekoden: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Vi introduserer BPF-verktøyet: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. En annen oversikt: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Containere og sikkerhet: secomp
  11. habr: Isoler demoner med systemd eller "du trenger ikke Docker for dette!"
  12. Paul Chaignon, "strace --seccomp-bpf: en titt under panseret", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Kilde: www.habr.com

Legg til en kommentar