BPF voor de kleintjes, deel nul: klassieke BPF

Berkeley Packet Filters (BPF) is een Linux-kerneltechnologie die al enkele jaren op de voorpagina's van Engelstalige technische publicaties staat. Conferenties zijn gevuld met rapporten over het gebruik en de ontwikkeling van BPF. David Miller, beheerder van het Linux-netwerksubsysteem, houdt zijn toespraak op Linux Plumbers 2018 “Dit gesprek gaat niet over XDP” (XDP is een gebruiksscenario voor BPF). Brendan Gregg houdt lezingen getiteld Linux BPF-superkrachten. Toke Høiland-Jørgensen lachtdat de kernel nu een microkernel is. Thomas Graf promoot het idee dat BPF is javascript voor de kernel.

Er is nog steeds geen systematische beschrijving van BPF op Habré, en daarom zal ik in een reeks artikelen proberen te praten over de geschiedenis van de technologie, de architectuur en ontwikkelingstools beschrijven, en de toepassingsgebieden en de praktijk van het gebruik van BPF schetsen. Dit artikel, nul, in de serie, vertelt de geschiedenis en architectuur van de klassieke BPF, en onthult ook de geheimen van de werkingsprincipes ervan. tcpdump, seccomp, strace, en огое ое.

De ontwikkeling van BPF wordt gecontroleerd door de Linux-netwerkgemeenschap; de belangrijkste bestaande toepassingen van BPF zijn gerelateerd aan netwerken en daarom, met toestemming @eucariotIk noemde de serie “BPF voor de kleintjes”, ter ere van de grote serie "Netwerken voor de kleintjes".

Een korte cursus in de geschiedenis van BPF(c)

Moderne BPF-technologie is een verbeterde en uitgebreide versie van de oude technologie met dezelfde naam, nu klassieke BPF genoemd om verwarring te voorkomen. Er is een bekend hulpprogramma gemaakt op basis van de klassieke BPF tcpdump, mechanisme seccomp, evenals minder bekende modules xt_bpf voor iptables en classificator cls_bpf. In moderne Linux worden klassieke BPF-programma's automatisch vertaald naar de nieuwe vorm, maar vanuit gebruikersoogpunt is de API op zijn plaats gebleven en worden er nog steeds nieuwe toepassingen voor klassieke BPF gevonden, zoals we in dit artikel zullen zien. Om deze reden, en ook omdat het na de geschiedenis van de ontwikkeling van klassieke BPF in Linux duidelijker zal worden hoe en waarom het naar zijn moderne vorm evolueerde, besloot ik te beginnen met een artikel over klassieke BPF.

Eind jaren tachtig van de vorige eeuw raakten ingenieurs van het beroemde Lawrence Berkeley Laboratory geïnteresseerd in de vraag hoe je netwerkpakketten goed kunt filteren op hardware die eind jaren tachtig van de vorige eeuw modern was. Het basisidee van filteren, oorspronkelijk geïmplementeerd in CSPF-technologie (CMU/Stanford Packet Filter), was om onnodige pakketten zo vroeg mogelijk te filteren, d.w.z. in de kernelruimte, omdat hierdoor wordt voorkomen dat onnodige gegevens naar de gebruikersruimte worden gekopieerd. Om runtime-beveiliging te bieden voor het uitvoeren van gebruikerscode in de kernelruimte, werd een virtuele machine in een sandbox gebruikt.

De virtuele machines voor bestaande filters waren echter ontworpen om op stapelgebaseerde machines te draaien en werkten niet zo efficiënt op nieuwere RISC-machines. Als gevolg hiervan werd door de inspanningen van ingenieurs van Berkeley Labs een nieuwe BPF-technologie (Berkeley Packet Filters) ontwikkeld, waarvan de virtuele machine-architectuur werd ontworpen op basis van de Motorola 6502-processor - het werkpaard van bekende producten als Apple II of NES. De nieuwe virtuele machine verhoogde de filterprestaties tientallen keren vergeleken met bestaande oplossingen.

BPF-machinearchitectuur

We maken op een werkende manier kennis met architectuur, waarbij we voorbeelden analyseren. Laten we om te beginnen echter zeggen dat de machine twee 32-bits registers had die toegankelijk waren voor de gebruiker, een accumulator A en indexregister X, 64 bytes geheugen (16 woorden), beschikbaar voor schrijven en vervolgens lezen, en een klein commandosysteem voor het werken met deze objecten. Spronginstructies voor het implementeren van voorwaardelijke expressies waren ook beschikbaar in de programma's, maar om de tijdige voltooiing van het programma te garanderen, konden sprongen alleen vooruit worden gemaakt, dat wil zeggen dat het in het bijzonder verboden was om lussen te maken.

Het algemene schema voor het starten van de machine is als volgt. De gebruiker maakt een programma voor de BPF-architectuur en gebruikt sommige kernelmechanisme (zoals een systeemaanroep), laadt het programma en verbindt het ermee voor sommigen naar de gebeurtenisgenerator in de kernel (een gebeurtenis is bijvoorbeeld de aankomst van het volgende pakket op de netwerkkaart). Wanneer er een gebeurtenis plaatsvindt, voert de kernel het programma uit (bijvoorbeeld in een tolk) en komt het machinegeheugen overeen voor sommigen kernelgeheugengebied (bijvoorbeeld gegevens van een binnenkomend pakket).

Het bovenstaande is voldoende om naar voorbeelden te gaan kijken: we zullen indien nodig kennis maken met het systeem en het opdrachtformaat. Als je onmiddellijk het commandosysteem van een virtuele machine wilt bestuderen en alle mogelijkheden ervan wilt leren kennen, dan kun je het originele artikel lezen Het BSD-pakketfilter en/of de eerste helft van het bestand Documentatie/netwerken/filter.txt uit de kerneldocumentatie. Daarnaast kun je de presentatie bestuderen libpcap: Een architectuur- en optimalisatiemethodologie voor pakketopname, waarin McCanne, een van de auteurs van BPF, vertelt over de geschiedenis van de schepping libpcap.

We gaan verder met het overwegen van alle belangrijke voorbeelden van het gebruik van klassieke BPF op Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

De ontwikkeling van BPF vond parallel plaats met de ontwikkeling van de frontend voor pakketfiltering - een bekend hulpprogramma tcpdump. En aangezien dit het oudste en bekendste voorbeeld is van het gebruik van klassieke BPF, beschikbaar op veel besturingssystemen, zullen we onze studie van de technologie ermee beginnen.

(Ik heb alle voorbeelden in dit artikel op Linux uitgevoerd 5.6.0-rc6. De uitvoer van sommige opdrachten is bewerkt voor een betere leesbaarheid.)

Voorbeeld: IPv6-pakketten observeren

Laten we ons voorstellen dat we alle IPv6-pakketten op een interface willen bekijken eth0. Om dit te doen kunnen we het programma uitvoeren tcpdump met een eenvoudig filter ip6:

$ sudo tcpdump -i eth0 ip6

In dit geval tcpdump stelt het filter samen ip6 in de BPF-architectuurbytecode en stuur deze naar de kernel (zie details in de sectie Tcpdump: laden). Het geladen filter wordt uitgevoerd voor elk pakket dat door de interface gaat eth0. Als het filter een waarde retourneert die niet nul is n, dan tot n bytes van het pakket worden naar de gebruikersruimte gekopieerd en we zullen het in de uitvoer zien tcpdump.

BPF voor de kleintjes, deel nul: klassieke BPF

Het blijkt dat we gemakkelijk kunnen achterhalen welke bytecode naar de kernel is gestuurd tcpdump met behulp van de tcpdump, als we het uitvoeren met de optie -d:

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

Op regel nul voeren we de opdracht uit ldh [12], wat staat voor “laden in register A een half woord (16 bits) op adres 12” en de enige vraag is welk soort geheugen we adresseren? Het antwoord is dat bij x begint (x+1)e byte van het geanalyseerde netwerkpakket. We lezen pakketten van de Ethernet-interface eth0, en dit middelendat het pakket er zo uitziet (voor de eenvoud gaan we ervan uit dat er geen VLAN-tags in het pakket zitten):

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

Dus na het uitvoeren van de opdracht ldh [12] in het register A er zal een veld zijn Ether Type — het type pakket dat in dit Ethernet-frame wordt verzonden. Op regel 1 vergelijken we de inhoud van het register A (pakkettype) c 0x86dd, en dit en eet Het type waarin wij geïnteresseerd zijn is IPv6. Op regel 1 zijn er naast het vergelijkingscommando nog twee kolommen: jt 2 и jf 3 — punten waarnaar u moet gaan als de vergelijking succesvol is (A == 0x86dd) en mislukt. Dus in een succesvol geval (IPv6) gaan we naar regel 2, en in een niet succesvol geval naar regel 3. Op regel 3 eindigt het programma met code 0 (kopieer het pakket niet), op regel 2 eindigt het programma met code 262144 (kopieer mij een pakket van maximaal 256 kilobytes).

Een ingewikkelder voorbeeld: we bekijken TCP-pakketten per bestemmingspoort

Laten we eens kijken hoe een filter eruit ziet dat alle TCP-pakketten met bestemmingspoort 666 kopieert. We zullen het IPv4-geval bekijken, omdat het IPv6-geval eenvoudiger is. Na het bestuderen van dit voorbeeld kun je als oefening zelf het IPv6-filter verkennen (ip6 and tcp dst port 666) en een filter voor het algemene geval (tcp dst port 666). Het filter waarin we geïnteresseerd zijn, ziet er dus als volgt uit:

$ 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

We weten al wat de lijnen 0 en 1 doen. Op regel 2 hebben we al gecontroleerd dat dit een IPv4-pakket is (Ether Type = 0x800) en laad het in het register A 24e byte van het pakket. Ons pakket ziet eruit

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

wat betekent dat we in het register laden A het Protocol-veld van de IP-header, wat logisch is, omdat we alleen TCP-pakketten willen kopiëren. We vergelijken Protocol met 0x6 (IPPROTO_TCP) op lijn 3.

Op regel 4 en 5 laden we de halfwoorden op adres 20 en gebruiken we het commando jset controleer of een van de drie is ingesteld vlaggen - het dragen van het afgegeven masker jset de drie meest significante bits worden gewist. Twee van de drie bits vertellen ons of het pakket deel uitmaakt van een gefragmenteerd IP-pakket, en zo ja, of dit het laatste fragment is. Het derde bit is gereserveerd en moet nul zijn. We willen geen onvolledige of kapotte pakketten controleren, dus controleren we alle drie de bits.

Lijn 6 is het meest interessant in deze aanbieding. Uitdrukking ldxb 4*([14]&0xf) betekent dat we in het register laden X de minst significante vier bits van de vijftiende byte van het pakket vermenigvuldigd met 4. De minst significante vier bits van de vijftiende byte is het veld Lengte internetheader IPv4-header, die de lengte van de header in woorden opslaat, dus je moet deze vervolgens met 4 vermenigvuldigen. Interessant is dat de uitdrukking 4*([14]&0xf) is een aanduiding voor een speciaal adresseringsschema dat alleen in deze vorm en alleen voor een register kan worden gebruikt X, d.w.z. wij kunnen het ook niet zeggen ldb 4*([14]&0xf) of ldxb 5*([14]&0xf) (we kunnen alleen een andere offset opgeven, bijvoorbeeld ldxb 4*([16]&0xf)). Het is duidelijk dat dit adresseringsschema juist aan BPF is toegevoegd om te kunnen ontvangen X (indexregister) IPv4-headerlengte.

Dus op regel 7 proberen we een half woord in te laden (X+16). Onthoud dat 14 bytes worden ingenomen door de Ethernet-header, en X bevat de lengte van de IPv4-header, dat begrijpen we in A TCP-bestemmingspoort is geladen:

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

Ten slotte vergelijken we op regel 8 de bestemmingspoort met de gewenste waarde en op regel 9 of 10 retourneren we het resultaat: of we het pakket nu moeten kopiëren of niet.

Tcpdump: laden

In de voorgaande voorbeelden hebben we specifiek niet in detail stilgestaan ​​bij hoe we BPF-bytecode precies in de kernel laden voor pakketfiltering. In het algemeen, tcpdump geschikt voor vele systemen en voor het werken met filters tcpdump maakt gebruik van de bibliotheek libpcap. Kortom, om een ​​filter op een interface te plaatsen met behulp van libpcap, moet u het volgende doen:

Om te zien hoe de functie pcap_setfilter geïmplementeerd in Linux, gebruiken we strace (enkele regels zijn verwijderd):

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

Op de eerste twee regels met uitvoer maken we rauwe stopcontact om alle Ethernet-frames te lezen en aan de interface te binden eth0. Uit ons eerste voorbeeld we weten dat het filter ip zal uit vier BPF-instructies bestaan, en op de derde regel zien we hoe we de optie gebruiken SO_ATTACH_FILTER systeem oproep setsockopt we laden en sluiten een filter van lengte 4 aan. Dit is ons filter.

Het is vermeldenswaard dat in de klassieke BPF het laden en verbinden van een filter altijd als een atomaire bewerking plaatsvindt, en dat in de nieuwe versie van BPF het laden van het programma en het binden ervan aan de gebeurtenisgenerator in de tijd gescheiden zijn.

Verborgen waarheid

Een iets completere versie van de uitvoer ziet er als volgt uit:

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

Zoals hierboven vermeld, laden we ons filter en bevestigen we het aan de aansluiting op lijn 5, maar wat gebeurt er op lijn 3 en 4? Het blijkt dat dit libpcap zorgt voor ons - zodat de uitvoer van ons filter geen pakketten bevat die er niet aan voldoen, de bibliotheek verbindt dummy-filter ret #0 (laat alle pakketten vallen), schakelt de socket naar de niet-blokkerende modus en probeert alle pakketten af ​​te trekken die van eerdere filters zouden kunnen achterblijven.

Om pakketten op Linux te filteren met behulp van klassieke BPF, heb je in totaal een filter nodig in de vorm van een structuur zoals struct sock_fprog en een open socket, waarna het filter via een systeemoproep aan de socket kan worden bevestigd setsockopt.

Interessant is dat het filter op elke socket kan worden bevestigd, niet alleen op rauwe wijze. Hier voorbeeld een programma dat alle binnenkomende UDP-datagrammen behalve de eerste twee bytes afsnijdt. (Ik heb opmerkingen in de code toegevoegd om het artikel niet onoverzichtelijk te maken.)

Meer details over gebruik setsockopt voor het aansluiten van filters, zie stopcontact(7), maar over het schrijven van je eigen filters zoals struct sock_fprog zonder hulp tcpdump we praten in de sectie BPF programmeren met onze eigen handen.

Klassieke BPF en de XNUMXe eeuw

BPF werd in 1997 opgenomen in Linux en is lange tijd een werkpaard gebleven libpcap zonder enige speciale veranderingen (Linux-specifieke veranderingen uiteraard, waren, maar ze veranderden het mondiale beeld niet). De eerste serieuze tekenen dat BPF zich zou ontwikkelen kwamen in 2011, toen Eric Dumazet een voorstel deed lapje, dat Just In Time Compiler aan de kernel toevoegt - een vertaler voor het converteren van BPF-bytecode naar native x86_64 code.

JIT-compiler was de eerste in de reeks veranderingen: in 2012 verscheen mogelijkheid om filters voor te schrijven sec, met behulp van BPF, was dat in januari 2013 wel het geval toegevoegd module xt_bpf, waarmee u regels kunt schrijven voor iptables met de hulp van BPF, en in oktober 2013 was dat zo toegevoegd ook een module cls_bpf, waarmee u verkeersclassificaties kunt schrijven met behulp van BPF.

We zullen al deze voorbeelden binnenkort in meer detail bekijken, maar eerst zal het nuttig voor ons zijn om te leren hoe we willekeurige programma's voor BPF kunnen schrijven en compileren, aangezien de mogelijkheden die door de bibliotheek worden geboden libpcap beperkt (eenvoudig voorbeeld: filter gegenereerd libpcap kan slechts twee waarden retourneren - 0 of 0x40000) of zijn in het algemeen, zoals in het geval van seccomp, niet van toepassing.

BPF programmeren met onze eigen handen

Laten we kennis maken met het binaire formaat van BPF-instructies, het is heel eenvoudig:

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

Elke instructie beslaat 64 bits, waarbij de eerste 16 bits de instructiecode zijn, daarna zijn er twee acht-bits streepjes, jt и jfen 32 bits voor het argument K, waarvan het doel van commando tot commando varieert. Het commando bijvoorbeeld ret, waarmee het programma wordt beëindigd, heeft de code 6, en de geretourneerde waarde wordt uit de constante gehaald K. In C wordt een enkele BPF-instructie weergegeven als een structuur

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

en het hele programma heeft de vorm van een structuur

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

We kunnen dus al programma's schrijven (we kennen bijvoorbeeld de instructiecodes van [1]). Zo ziet het filter eruit ip6 van ons eerste voorbeeld:

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

Het programma prog die we legaal kunnen gebruiken tijdens een gesprek

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

Het schrijven van programma's in de vorm van machinecodes is niet erg handig, maar soms wel nodig (bijvoorbeeld voor debuggen, unittests maken, artikelen schrijven over Habré, etc.). Voor het gemak, in het bestand <linux/filter.h> helpermacro's zijn gedefinieerd - hetzelfde voorbeeld als hierboven kan worden herschreven als

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

Deze optie is echter niet erg handig. Dit is wat de Linux-kernelprogrammeurs redeneerden, en daarom in de directory tools/bpf kernels kun je een assembler en debugger vinden voor het werken met klassieke BPF.

Assembleertaal lijkt sterk op debug-uitvoer tcpdump, maar daarnaast kunnen we symbolische labels specificeren. Hier is bijvoorbeeld een programma dat alle pakketten laat vallen behalve TCP/IPv4:

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

Standaard genereert de assembler code in het formaat <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., voor ons voorbeeld met TCP zal dit het geval zijn

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

Voor het gemak van C-programmeurs kan een ander uitvoerformaat worden gebruikt:

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

Deze tekst kan in de typestructuurdefinitie worden gekopieerd struct sock_filter, zoals we aan het begin van dit gedeelte deden.

Linux- en netsniff-ng-extensies

Naast standaard BPF, Linux en tools/bpf/bpf_asm ondersteuning en niet-standaard set. In principe worden instructies gebruikt om toegang te krijgen tot de velden van een structuur struct sk_buff, dat een netwerkpakket in de kernel beschrijft. Er zijn echter bijvoorbeeld ook andere soorten hulpinstructies ldw cpu wordt in het register geladen A resultaat van het uitvoeren van een kernelfunctie raw_smp_processor_id(). (In de nieuwe versie van BPF zijn deze niet-standaard extensies uitgebreid om programma's te voorzien van een set kernelhelpers voor toegang tot geheugen, structuren en het genereren van gebeurtenissen.) Hier is een interessant voorbeeld van een filter waarin we alleen de pakketheaders naar de gebruikersruimte met behulp van de extensie poff, ladingscompensatie:

ld poff
ret a

BPF-extensies kunnen niet worden gebruikt in tcpdump, maar dit is een goede reden om kennis te maken met het hulpprogrammapakket netsniff-ng, dat onder meer een geavanceerd programma bevat netsniff-ng, dat naast filteren met BPF ook een effectieve verkeersgenerator bevat, en geavanceerder dan tools/bpf/bpf_asm, genaamd een BPF-assembler bpfc. Het pakket bevat vrij gedetailleerde documentatie, zie ook de links aan het einde van het artikel.

sec

We weten dus al hoe we BPF-programma's van willekeurige complexiteit moeten schrijven en zijn klaar om naar nieuwe voorbeelden te kijken, waarvan de eerste de seccomp-technologie is, die het mogelijk maakt om met behulp van BPF-filters de set en set systeemaanroepargumenten te beheren die beschikbaar zijn voor een bepaald proces en zijn nakomelingen.

De eerste versie van seccomp werd in 2005 aan de kernel toegevoegd en was niet erg populair, omdat deze slechts één optie bood: het beperken van de reeks systeemaanroepen die beschikbaar zijn voor een proces tot het volgende: read, write, exit и sigreturn, en het proces dat de regels overtrad, werd gedood met behulp van SIGKILL. In 2012 heeft seccomp echter de mogelijkheid toegevoegd om BPF-filters te gebruiken, waardoor u een reeks toegestane systeemaanroepen kunt definiëren en zelfs hun argumenten kunt controleren. (Interessant genoeg was Chrome een van de eerste gebruikers van deze functionaliteit, en de Chrome-mensen ontwikkelen momenteel een KRSI-mechanisme gebaseerd op een nieuwe versie van BPF en maken aanpassing van Linux-beveiligingsmodules mogelijk.) Links naar aanvullende documentatie vindt u aan het einde. van het artikel.

Houd er rekening mee dat er al artikelen op de hub zijn verschenen over het gebruik van seccomp. Misschien wil iemand deze lezen voordat (of in plaats van) de volgende subsecties worden gelezen. In het artikel Containers en beveiliging: seccomp geeft voorbeelden van het gebruik van seccomp, zowel de versie uit 2007 als de versie die BPF gebruikt (filters worden gegenereerd met libseccomp), vertelt over de verbinding van seccomp met Docker, en biedt ook veel nuttige links. In het artikel Daemons isoleren met systemd of “hier heb je Docker niet voor nodig!” Het behandelt in het bijzonder hoe u zwarte lijsten of witte lijsten kunt toevoegen met systeemoproepen voor daemons waarop systemd draait.

Vervolgens zullen we zien hoe u filters schrijft en laadt seccomp in kale C en met behulp van de bibliotheek libseccomp en wat zijn de voor- en nadelen van elke optie, en laten we tot slot eens kijken hoe seccomp door het programma wordt gebruikt strace.

Filters schrijven en laden voor seccomp

We weten al hoe we BPF-programma's moeten schrijven, dus laten we eerst eens kijken naar de seccomp-programmeerinterface. U kunt een filter instellen op procesniveau, waarna alle onderliggende processen de beperkingen overnemen. Dit gebeurt via een systeemoproep seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

waar &filter - dit is een verwijzing naar een structuur die ons al bekend is struct sock_fprog, d.w.z. BPF-programma.

Waarin verschillen programma's voor seccomp van programma's voor sockets? Overgedragen context. In het geval van sockets kregen we een geheugengebied dat het pakket bevatte, en in het geval van seccomp kregen we een structuur zoals

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

Hier nr is het nummer van de systeemoproep die moet worden gelanceerd, arch - huidige architectuur (meer hierover hieronder), args - maximaal zes systeemaanroepargumenten, en instruction_pointer is een verwijzing naar de gebruikersruimte-instructie die de systeemaanroep heeft gedaan. Zo kunt u bijvoorbeeld het systeemoproepnummer in het register laden A wij moeten zeggen

ldw [0]

Er zijn nog andere functies voor seccomp-programma's, de context is bijvoorbeeld alleen toegankelijk via 32-bits uitlijning en u kunt geen half woord of een byte laden - wanneer u een filter probeert te laden ldh [0] systeem oproep seccomp zal terugkeren EINVAL. De functie controleert de geladen filters seccomp_check_filter() kernels. (Het grappige is dat ze in de oorspronkelijke commit die de seccomp-functionaliteit toevoegde, vergaten toestemming toe te voegen om de instructie aan deze functie te gebruiken mod (verdeling rest) en is sinds de toevoeging ervan nu niet meer beschikbaar voor seccomp BPF-programma's zal breken ABI.)

Kortom, we weten al alles om seccomp-programma's te schrijven en te lezen. Meestal is de programmalogica gerangschikt als een witte of zwarte lijst met systeemoproepen, bijvoorbeeld het programma

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

controleert een zwarte lijst met vier systeemoproepen, genummerd 304, 176, 239, 279. Wat zijn deze systeemoproepen? We kunnen het niet met zekerheid zeggen, omdat we niet weten voor welke architectuur het programma is geschreven. Daarom hebben de auteurs van seccomp aanbod start alle programma's met een architectuurcontrole (de huidige architectuur wordt in de context aangegeven als een veld arch de structuur struct seccomp_data). Als de architectuur is aangevinkt, zou het begin van het voorbeeld er als volgt uitzien:

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

en dan zouden onze systeemoproepnummers bepaalde waarden krijgen.

We schrijven en laden filters voor seccomp met behulp van libseccomp

Door filters in native code of in BPF-assembly te schrijven, heeft u volledige controle over het resultaat, maar tegelijkertijd verdient het soms de voorkeur om over draagbare en/of leesbare code te beschikken. De bibliotheek gaat ons hierbij helpen libseccomp, dat een standaardinterface biedt voor het schrijven van zwarte of witte filters.

Laten we bijvoorbeeld een programma schrijven dat een binair bestand naar keuze van de gebruiker uitvoert, nadat we eerder een zwarte lijst met systeemaanroepen van het bovenstaande artikel (het programma is vereenvoudigd voor een grotere leesbaarheid, de volledige versie is te vinden hier):

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

Eerst definiëren we een array sys_numbers van meer dan 40 systeemoproepnummers om te blokkeren. Initialiseer vervolgens de context ctx en vertel de bibliotheek wat we willen toestaan ​​(SCMP_ACT_ALLOW) standaard alle systeemaanroepen (het is eenvoudiger om zwarte lijsten samen te stellen). Vervolgens voegen we één voor één alle systeemoproepen uit de zwarte lijst toe. Naar aanleiding van een systeemoproep uit de lijst vragen wij SCMP_ACT_TRAP, in dit geval zal seccomp een signaal naar het proces sturen SIGSYS met een beschrijving van welke systeemoproep de regels overtrad. Ten slotte laden we het programma in de kernel met behulp van seccomp_load, waarmee het programma wordt gecompileerd en aan het proces wordt gekoppeld met behulp van een systeemaanroep seccomp(2).

Voor een succesvolle compilatie moet het programma aan de bibliotheek zijn gekoppeld libseccomp, bijvoorbeeld:

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

Voorbeeld van een succesvolle lancering:

$ ./seccomp_lib echo ok
ok

Voorbeeld van een geblokkeerde systeemoproep:

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

We gebruiken stracevoor details:

$ 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

Hoe kunnen we weten dat het programma is beëindigd vanwege het gebruik van een illegale systeemoproep? mount(2).

Daarom hebben we een filter geschreven met behulp van de bibliotheek libseccomp, waarbij niet-triviale code in vier regels wordt geplaatst. Als er in het bovenstaande voorbeeld een groot aantal systeemaanroepen is, kan de uitvoeringstijd merkbaar worden verkort, omdat de controle slechts een lijst met vergelijkingen is. Voor optimalisatie heeft libseccomp onlangs een oplossing gevonden pleister inbegrepen, waarmee ondersteuning wordt toegevoegd voor het filterattribuut SCMP_FLTATR_CTL_OPTIMIZE. Als u dit attribuut op 2 zet, wordt het filter omgezet in een binair zoekprogramma.

Als je wilt zien hoe binaire zoekfilters werken, kijk dan eens naar eenvoudig script, dat dergelijke programma's genereert in de BPF-assembler door systeemoproepnummers te kiezen, bijvoorbeeld:

$ 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

Het is onmogelijk om iets aanzienlijk sneller te schrijven, omdat BPF-programma's geen inspringingssprongen kunnen uitvoeren (we kunnen bijvoorbeeld niet jmp A of jmp [label+X]) en daarom zijn alle overgangen statisch.

seccomp en strace

Iedereen kent het nut strace is een onmisbaar hulpmiddel voor het bestuderen van het gedrag van processen op Linux. Velen hebben er echter ook van gehoord prestatieproblemen wanneer u dit hulpprogramma gebruikt. Het feit is dat strace geïmplementeerd met behulp van ptrace(2), en in dit mechanisme kunnen we niet specificeren bij welke set systeemaanroepen we het proces moeten stoppen, dat wil zeggen bijvoorbeeld opdrachten

$ 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

worden in ongeveer dezelfde tijd verwerkt, hoewel we in het tweede geval slechts één systeemoproep willen traceren.

Nieuwe optie --seccomp-bpftoegevoegd aan strace Met versie 5.3 kunt u het proces vele malen versnellen en is de opstarttijd na één systeemoproep al vergelijkbaar met de tijd van een normale 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

(Hier is er natuurlijk sprake van een lichte misleiding doordat we de hoofdsysteemaanroep van dit commando niet traceren. Als we bijvoorbeeld zouden traceren: newfsstatdan strace zou net zo hard remmen als zonder --seccomp-bpf.)

Hoe werkt deze optie? Zonder haar strace maakt verbinding met het proces en start het in gebruik PTRACE_SYSCALL. Wanneer een beheerd proces een (willekeurige) systeemaanroep doet, wordt de controle overgedragen aan strace, dat naar de argumenten van de systeemaanroep kijkt en deze uitvoert met behulp van PTRACE_SYSCALL. Na enige tijd voltooit het proces de systeemoproep en bij het verlaten ervan wordt de besturing opnieuw overgedragen strace, die naar de retourwaarden kijkt en het proces start met behulp van PTRACE_SYSCALL, enzovoort.

BPF voor de kleintjes, deel nul: klassieke BPF

Met seccomp kan dit proces echter precies zo worden geoptimaliseerd als we zouden willen. Namelijk als we alleen naar de systeemoproep willen kijken X, dan kunnen we daar een BPF-filter voor schrijven X geeft een waarde terug SECCOMP_RET_TRACE, en voor oproepen die voor ons niet interessant zijn - SECCOMP_RET_ALLOW:

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

In dit geval strace start het proces aanvankelijk als PTRACE_CONT, wordt ons filter verwerkt voor elke systeemoproep, als de systeemoproep dat niet is X, dan blijft het proces doorgaan, maar als dit X, waarna seccomp de controle overdraagt stracedie naar de argumenten zal kijken en het proces zal starten PTRACE_SYSCALL (aangezien seccomp niet de mogelijkheid heeft om een ​​programma uit te voeren bij het afsluiten van een systeemoproep). Wanneer de systeemoproep terugkeert, strace zal het proces opnieuw starten met behulp van PTRACE_CONT en wacht op nieuwe berichten van seccomp.

BPF voor de kleintjes, deel nul: klassieke BPF

Wanneer u de optie gebruikt --seccomp-bpf Er zijn twee beperkingen. Ten eerste is het niet mogelijk om deel te nemen aan een reeds bestaand proces (optie -p programma's strace), aangezien dit niet wordt ondersteund door seccomp. Ten tweede is er geen mogelijkheid geen kijk naar onderliggende processen, aangezien seccomp-filters worden overgenomen door alle onderliggende processen zonder de mogelijkheid om dit uit te schakelen.

Iets meer details over hoe precies strace werkt met seccomp kan worden gevonden van recent verslag. Voor ons is het meest interessante feit dat de klassieke BPF, vertegenwoordigd door seccomp, nog steeds wordt gebruikt.

xt_bpf

Laten we nu teruggaan naar de wereld van netwerken.

Achtergrond: lang geleden, in 2007, was de kern toegevoegd module xt_u32 voor netfilter. Het is geschreven naar analogie van een nog oudere verkeersclassificator cls_u32 en stond je toe willekeurige binaire regels voor iptables te schrijven met behulp van de volgende eenvoudige bewerkingen: laad 32 bits uit een pakket en voer er een reeks rekenkundige bewerkingen op uit. Bijvoorbeeld,

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

Laadt de 32 bits van de IP-header, beginnend bij opvulling 6, en past er een masker op toe 0xFF (neem de lage byte). Dit veld protocol IP-header en we vergelijken deze met 1 (ICMP). U kunt meerdere controles in één regel combineren en u kunt ook de operator uitvoeren @ — verplaats X bytes naar rechts. De regel bijvoorbeeld

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

controleert of het TCP-volgnummer niet gelijk is 0x29. Ik zal niet verder op details ingaan, omdat het al duidelijk is dat het niet erg handig is om dergelijke regels met de hand te schrijven. In het artikel BPF - de vergeten bytecode, er zijn verschillende links met voorbeelden van gebruik en het genereren van regels voor xt_u32. Zie ook de links aan het einde van dit artikel.

Sinds 2013 module in plaats van module xt_u32 u kunt een op BPF gebaseerde module gebruiken xt_bpf. Iedereen die tot nu toe heeft gelezen, zou al duidelijk moeten zijn over het principe van de werking ervan: voer BPF bytecode uit als iptables-regels. U kunt bijvoorbeeld als volgt een nieuwe regel maken:

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

hier <байткод> - dit is de code in assembler-uitvoerformaat bpf_asm standaard, bijvoorbeeld

$ 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

In dit voorbeeld filteren we alle UDP-pakketten. Context voor een BPF-programma in een module xt_bpfverwijst uiteraard naar de pakketgegevens, in het geval van iptables, naar het begin van de IPv4-header. Retourwaarde uit het BPF-programma BooleaansWaar false betekent dat het pakket niet overeenkwam.

Het is duidelijk dat de module xt_bpf ondersteunt complexere filters dan het bovenstaande voorbeeld. Laten we eens kijken naar echte voorbeelden van Cloudfare. Tot voor kort gebruikten ze de module xt_bpf ter bescherming tegen DDoS-aanvallen. In het artikel Maak kennis met de BPF-tools ze leggen uit hoe (en waarom) ze BPF-filters genereren en publiceren links naar een reeks hulpprogramma's voor het maken van dergelijke filters. Gebruik bijvoorbeeld het hulpprogramma bpfgen u kunt een BPF-programma maken dat overeenkomt met een DNS-query voor een naam 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

In het programma laden we eerst het register in X begin van het regeladres x04habrx03comx00 in een UDP-datagram en controleer vervolgens het verzoek: 0x04686162 <-> "x04hab" etc.

Even later publiceerde Cloudfare de p0f -> BPF-compilercode. In het artikel Introductie van de p0f BPF-compiler ze praten over wat p0f is en hoe je p0f-handtekeningen naar BPF kunt converteren:

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

Momenteel wordt Cloudfare niet meer gebruikt xt_bpf, sinds ze naar XDP zijn verhuisd - een van de opties voor het gebruik van de nieuwe versie van BPF, zie. L4Drop: XDP DDoS-beperkingen.

cls_bpf

Het laatste voorbeeld van het gebruik van klassieke BPF in de kernel is de classifier cls_bpf voor het verkeerscontrolesubsysteem in Linux, eind 2013 aan Linux toegevoegd en conceptueel het oude vervangen cls_u32.

We zullen het werk nu echter niet beschrijven cls_bpf, aangezien dit ons vanuit het oogpunt van kennis over klassieke BPF niets oplevert - we zijn al bekend geraakt met alle functionaliteit. Bovendien zullen we in volgende artikelen over Extended BPF deze classificator meer dan eens tegenkomen.

Nog een reden om niet te praten over het gebruik van klassieke BPF c cls_bpf Het probleem is dat, vergeleken met Extended BPF, de reikwijdte van de toepasbaarheid in dit geval radicaal beperkt is: klassieke programma's kunnen de inhoud van pakketten niet wijzigen en kunnen de status tussen oproepen niet opslaan.

Het is dus tijd om afscheid te nemen van de klassieke BPF en naar de toekomst te kijken.

Afscheid van de klassieke BPF

We keken naar hoe de BPF-technologie, ontwikkeld in het begin van de jaren negentig, een kwart eeuw met succes heeft geleefd en tot het einde nieuwe toepassingen heeft gevonden. Echter, vergelijkbaar met de overgang van stapelmachines naar RISC, die als aanzet diende voor de ontwikkeling van klassieke BPF, vond er in de jaren 32 een overgang plaats van 64-bits naar XNUMX-bits machines en begon klassieke BPF verouderd te raken. Bovendien zijn de mogelijkheden van klassieke BPF zeer beperkt, en naast de verouderde architectuur - we hebben niet de mogelijkheid om de status op te slaan tussen oproepen naar BPF-programma's, er is geen mogelijkheid tot directe gebruikersinteractie, er is geen mogelijkheid tot interactie met de kernel, behalve voor het lezen van een beperkt aantal structuurvelden sk_buff en als u de eenvoudigste helperfuncties start, kunt u de inhoud van pakketten niet wijzigen of omleiden.

In feite is momenteel het enige dat overblijft van de klassieke BPF in Linux de API-interface, en binnen de kernel worden alle klassieke programma's, of het nu socketfilters of seccomp-filters zijn, automatisch vertaald naar een nieuw formaat, Extended BPF. (Hoe dit precies gebeurt, zullen we in het volgende artikel bespreken.)

De overgang naar een nieuwe architectuur begon in 2013, toen Alexey Starovoitov een BPF-updateschema voorstelde. In 2014 de bijbehorende patches begon te verschijnen in de kern. Voor zover ik begrijp was het oorspronkelijke plan alleen het optimaliseren van de architectuur en de JIT-compiler om efficiënter te werken op 64-bit machines, maar in plaats daarvan markeerden deze optimalisaties het begin van een nieuw hoofdstuk in de Linux-ontwikkeling.

Verdere artikelen in deze serie zullen betrekking hebben op de architectuur en toepassingen van de nieuwe technologie, aanvankelijk bekend als interne BPF, daarna uitgebreide BPF, en nu eenvoudigweg BPF.

referenties

  1. Steven McCanne en Van Jacobson, "Het BSD-pakketfilter: een nieuwe architectuur voor pakketopname op gebruikersniveau", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: een architectuur- en optimalisatiemethodologie voor pakketopname", 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 - de vergeten bytecode: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Introductie van de BPF-tool: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Een seccomp-overzicht: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Containers en beveiliging: seccomp
  11. habr: Daemons isoleren met systemd of "hier heb je Docker niet voor nodig!"
  12. Paul Chaignon, "strace --seccomp-bpf: een kijkje onder de motorkap", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Bron: www.habr.com

Voeg een reactie