BPF per i più piccoli, parte zero: classic BPF

I Berkeley Packet Filters (BPF) sono una tecnologia del kernel Linux che continua a comparire sulle prime pagine delle pubblicazioni tecniche anglofone da diversi anni. Le conferenze sono piene di presentazioni sull'uso e lo sviluppo di BPF. David Miller, il mantenitore del sottosistema di rete Linux, intitola la sua presentazione al Linux Plumbers 2018 «Questo intervento non riguarda XDP» (XDP è una delle molteplici applicazioni di BPF). Brendan Gregg tiene presentazioni intitolate Linux BPF Superpoteri. Toke Høiland-Jørgensen ride, che ora il kernel è un microkernel. Thomas Graf promuove l'idea che BPF è javascript per il kernel.

Su Habr non esiste ancora una descrizione sistematica di BPF, quindi in una serie di articoli cercherò di raccontare la storia della tecnologia, descrivere l'architettura e gli strumenti di sviluppo, delineare le aree di applicazione e le pratiche di utilizzo di BPF. In questo primo articolo della serie si racconta la storia e l'architettura del BPF classico, rivelando anche i segreti dei suoi principi di funzionamento tcpdump, seccomp, strace, e molto altro.

Lo sviluppo di BPF è controllato dalla comunità di rete Linux; i principali utilizzi esistenti di BPF sono legati alle reti e quindi, con il permesso di @eucariot, ho intitolato la serie "BPF per i più piccoli", in onore della grande serie "Reti per i più piccoli".

Breve corso di storia di BPF (c)

La moderna tecnologia BPF è una versione migliorata e ampliata della vecchia tecnologia con lo stesso nome, chiamata oggi, per evitare confusione, classic BPF. Sulla base del BPF classico sono state create l'utilità ben nota tcpdump, il meccanismo seccomp, così come i moduli meno conosciuti xt_bpf per iptables e il classificatore cls_bpf. Nel moderno Linux, i programmi BPF classici vengono automaticamente traslati in una nuova forma; tuttavia, dal punto di vista dell'utente, l'API è rimasta la stessa e nuove applicazioni del BPF classico, come vedremo in questo articolo, esistono ancora. Per questo motivo, e anche perché seguendo la storia dello sviluppo del classico BPF in Linux diventa chiaro come e perché sia evoluto nella forma moderna, ho deciso di iniziare proprio con un articolo sul BPF classico.

Alla fine degli anni Ottanta, gli ingegneri del famoso Lawrence Berkeley Laboratory si sono interessati alla questione di come filtrare correttamente i pacchetti di rete su hardware moderno per l'epoca. L'idea base della filtrazione, inizialmente realizzata nella tecnologia CSPF (CMU/Stanford Packet Filter), consisteva nel filtrare i pacchetti non necessari il prima possibile, ossia nello spazio del kernel, poiché ciò evita di copiare dati inutili nello spazio utente. Per garantire la sicurezza dell'esecuzione del codice utente nello spazio del kernel, è stata utilizzata una macchina virtuale — una sandbox.

Tuttavia, le macchine virtuali per i filtri esistenti erano progettate per funzionare su macchine con architettura stack e su nuove macchine RISC non operavano in modo altrettanto efficiente. Di conseguenza, grazie agli sforzi degli ingegneri dei Berkeley Labs, è stata sviluppata una nuova tecnologia BPF (Berkeley Packet Filters), la cui architettura della macchina virtuale è stata progettata sulla base del processore Motorola 6502 — il cavallo di battaglia di prodotti così noti come Apple II o NES. La nuova macchina virtuale aumentava le prestazioni dei filtri di decine di volte rispetto alle soluzioni esistenti.

Architettura della macchina BPF

Ci familiarizzeremo con l'architettura a livello operativo, esaminando esempi. Tuttavia, per iniziare, diciamo che la macchina aveva due registri a 32 bit disponibili per l'utente: l'accumulatore A e il registro indice X, 64 byte di memoria (16 parole), disponibili per la scrittura e la lettura successiva, e un piccolo set di istruzioni per lavorare con questi oggetti. Nei programmi erano disponibili anche istruzioni di salto per implementare espressioni condizionali, ma per garantire il termine tempestivo dell'esecuzione del programma, si poteva saltare solo in avanti, il che, in particolare, escludeva la creazione di cicli.

Lo schema generale di esecuzione della macchina è il seguente. L'utente crea un programma per l'architettura BPF e, mediante qualche meccanismo del kernel (ad esempio, una chiamata di sistema), carica e collega il programma a qualche generatore di eventi nel kernel (ad esempio, un evento è l'arrivo di un pacchetto sulla scheda di rete). Quando si verifica un evento, il kernel avvia il programma (ad esempio, nell'interprete), mentre la memoria della macchina corrisponde qualche regione della memoria del kernel (ad esempio, i dati del pacchetto ricevuto).

Quanto detto sopra sarà sufficiente per iniziare a esaminare esempi: ci familiarizzeremo con il sistema e il formato dei comandi se necessario. Se desiderate invece approfondire subito il sistema dei comandi della macchina virtuale e conoscere tutte le sue possibilità, potete leggere l'articolo originale Il BSD Packet Filter e/o la prima metà del file Documentation/networking/filter.txt della documentazione del kernel. Inoltre, è possibile studiare la presentazione libpcap: Un'architettura e una metodologia di ottimizzazione per la cattura dei pacchetti, in cui McCanne, uno degli autori del BPF, racconta la storia della sua creazione libpcap.

Passiamo ora all'analisi di tutti i principali esempi di utilizzo del BPF classico in Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

Lo sviluppo del BPF è avvenuto parallelamente allo sviluppo del frontend per la filtrazione dei pacchetti — il noto strumento tcpdump. E poiché questo è l'esempio più antico e conosciuto dell'uso del BPF classico, disponibile su molteplici sistemi operativi, da qui inizieremo lo studio della tecnologia.

(Tutti gli esempi in questo articolo sono stati eseguiti su Linux 5.6.0-rc6. L'output di alcuni comandi è stato modificato per una migliore leggibilità.)

Esempio: osserviamo i pacchetti IPv6

Immaginiamo di voler esaminare tutti i pacchetti IPv6 sull'interfaccia eth0. Per fare ciò possiamo avviare un programma tcpdump con un filtro semplice ip6:

$ sudo tcpdump -i eth0 ip6

In questo tcpdump compila il filtro ip6 in byte-code dell'architettura BPF e lo invia al kernel (vedi dettagli nella sezione Tcpdump: caricamento). Il filtro caricato sarà eseguito per ogni pacchetto che attraversa l'interfaccia eth0. Se il filtro restituisce un valore diverso da zero n, allora n i byte del pacchetto verranno copiati nello spazio utente e li vedremo nell'output tcpdump.

BPF per i più piccoli, parte zero: classic BPF

Scopriamo che possiamo facilmente sapere quale bytecode è stato inviato al kernel tcpdump utilizzando il stesso tcpdump, se lo avviamo con l'opzione -d:

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

Nella riga zero avviamo il comando ldh [12], che si decodifica come «caricare nel registro A mezza parola (16 bit), situata all'indirizzo 12» e l'unica domanda è — quale memoria stiamo indirizzando? La risposta è che all'indirizzo x inizia (x+1)-byte del pacchetto di rete analizzato. Stiamo leggendo i pacchetti dall'interfaccia Ethernet eth0, il che significa significa, che il pacchetto appare come segue (per semplicità supponiamo che nel pacchetto non ci siano tag VLAN):

       6              6          2
|MAC di destinazione|MAC sorgente|Tipo Ether|...|

Quindi, dopo aver eseguito il comando ldh [12] nel registro A ci sarà il campo Tipo Ether — il tipo di pacchetto trasmesso in questo frame Ethernet. Nella riga 1 confrontiamo il contenuto del registro A (tipo di pacchetto) con 0x86dd, il che significa ed è il tipo IPv6 che ci interessa. Nella riga 1, oltre al comando di confronto, ci sono anche due colonne — jt 2 e jf 3 — etichette a cui bisogna passare in caso di confronto positivo (A == 0x86dd) e negativo. Quindi, nel caso positivo (IPv6), passiamo alla riga 2, e nel caso negativo — alla riga 3. Nella riga 3 il programma termina con codice 0 (non copiare il pacchetto), nella riga 2 il programma termina con codice 262144 (copia al massimo 256 kilobyte del pacchetto).

Esempio più complesso: guardiamo i pacchetti TCP sulla porta di destinazione

Vediamo come appare il filtro che copia tutti i pacchetti TCP con porta di destinazione 666. Esamineremo il caso IPv4, poiché il caso IPv6 è più semplice. Dopo aver studiato questo esempio, puoi come esercizio esaminare autonomamente il filtro per IPv6 (ip6 and tcp dst port 666) e il filtro per il caso generale (tcp dst port 666). Quindi, il filtro che ci interessa appare come segue:

$ 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

Cosa fanno le righe 0 e 1 già lo sappiamo. Nella riga 2 abbiamo già verificato che si tratta di un pacchetto IPv4 (Tipo Ether = 0x800) e carichiamo nel registro A il 24° byte del pacchetto. Il nostro pacchetto appare come

       14            8      1     1
|intestazione ethernet|campi ip|ttl|protocollo|...|

e quindi carichiamo nel registro A il campo Protocollo dell'intestazione IP, il che ha senso, dato che vogliamo copiare solo pacchetti TCP. Confrontiamo Protocollo con 0x6 (IPPROTO_TCP) nella riga 3.

Nelle righe 4 e 5 carichiamo metà parola presente all'indirizzo 20, e utilizzando il comando jset verifichiamo se uno dei tre flag — nella maschera fornita jset sono stati azzerati i tre bit più significativi. Due bit su tre indicano se il pacchetto è parte di un pacchetto IP frammentato, e se sì, se è l'ultimo frammento. Il terzo bit è riservato e deve essere zero. Non vogliamo controllare pacchetti corrotti o incompleti, quindi verifichiamo tutti e tre i bit.

La riga 6 è la più interessante di questo elenco. L'espressione ldxb 4*([14]&0xf) significa che stiamo caricando nel registro X i quattro bit meno significativi del quindicesimo byte del pacchetto, moltiplicati per 4. I quattro bit meno significativi del quindicesimo byte sono il campo Internet Header Length dell'intestazione IPv4, in cui è memorizzata la lunghezza dell'intestazione in parole, quindi è necessario moltiplicare per 4. È interessante notare che l'espressione 4*([14]&0xf) è un'indicazione di una particolare modalità di indirizzamento, che può essere utilizzata solo in questo modo e solo per il registro X, cioè non possiamo dire né ldb 4*([14]&0xf)ldxb 5*([14]&0xf) (possiamo solo specificare un altro offset, ad esempio, ldxb 4*([16]&0xf)). È chiaro che questo schema di indirizzamento è stato aggiunto in BPF proprio per ottenere in X (registro indice) la lunghezza dell'intestazione IPv4.

Pertanto, nella riga 7 stiamo cercando di caricare mezza parola, all'indirizzo (X+16). Ricordando che 14 byte sono occupati dall'intestazione Ethernet, e X contiene la lunghezza dell'intestazione IPv4, capiamo che in A viene caricato il porto di destinazione TCP:

       14           X           2              2
|intestazione ethernet|intestazione ip|porta sorgente|porta di destinazione|

Infine, nella riga 8 confrontiamo la porta di destinazione con il valore cercato e nelle righe 9 o 10 restituiamo il risultato: copiare il pacchetto o meno.

Tcpdump: caricamento

Negli esempi precedenti non ci siamo soffermati dettagliatamente su come stiamo caricando il bytecode BPF nel kernel per filtrare i pacchetti. In generale, tcpdump è stato portato su molti sistemi e per lavorare con i filtri tcpdump utilizza una libreria libpcap. In breve, per impostare un filtro su un'interfaccia tramite libpcap, è necessario fare quanto segue:

Per vedere come la funzione pcap_setfilter è implementata in Linux, usiamo strace (alcune righe sono state rimosse):

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

Nelle prime due righe dell'output stiamo creando un socket raw per leggere tutti i frame Ethernet e lo colleghiamo all'interfaccia eth0. Dal nostro primo esempio sappiamo che il filtro ip consisterà in quattro istruzioni BPF, e nella terza riga vediamo come, utilizzando l'opzione SO_ATTACH_FILTER della chiamata di sistema setsockopt stiamo caricando e collegando il filtro di lunghezza 4. Questo è il nostro filtro.

È interessante notare che nel BPF classico il caricamento e il collegamento del filtro avvengono sempre come un'operazione atomica, mentre nella nuova versione del BPF il caricamento del programma e il suo collegamento al generatore di eventi sono separati nel tempo.

La verità nascosta

Una versione leggermente più completa dell'output appare così:

$ 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 (Risorsa temporaneamente non disponibile)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...

Come detto sopra, stiamo caricando e collegando il nostro filtro al socket nella riga 5, ma cosa succede nelle righe 3 e 4? Si scopre che questo libpcap si preoccupa di noi: affinché l'output del nostro filtro non includa pacchetti che non soddisfano i requisiti, la libreria collega un filtro fittizio ret #0 (scartare tutti i pacchetti), mette il socket in modalità non bloccante e cerca di leggere tutti i pacchetti che potrebbero essere rimasti dai filtri precedenti.

In sintesi, per filtrare i pacchetti su Linux usando il BPF classico, è necessario avere un filtro sotto forma di struttura tipo struct sock_fprog e un socket aperto, dopodiché il filtro può essere collegato al socket tramite una chiamata di sistema. setsockopt.

È interessante notare che il filtro può essere collegato a qualsiasi socket, non solo a quello raw. Ecco un esempio un programma che rimuove tutto, tranne i primi due byte di tutti i datagrammi UDP in ingresso. (Ho aggiunto commenti nel codice per non appesantire l'articolo.)

Ulteriori informazioni sull'uso setsockopt per collegare i filtri possono essere trovate in socket(7), e riguardo la scrittura dei propri filtri di tipo struct sock_fprog senza aiuto tcpdump ne parleremo nella sezione Programmare il BPF con le proprie mani.

Il BPF classico e il XXI secolo

Il BPF è stato introdotto in Linux nel 1997 e per lungo tempo è rimasto un'utilità molto utilizzata libpcap senza particolari modifiche (cambiamenti specifici per Linux, certo, ci sono stati, ma non hanno cambiato il quadro globale). I primi segni significativi che il BPF sarebbe evoluto sono apparsi nel 2011, quando Eric Dumazet ha proposto patch, introducendo nel kernel un Just In Time Compiler — un traduttore per convertire il codice byte del BPF in codice nativo. x86_64 Il compilatore JIT è stato il primo in una serie di cambiamenti: nel 2012

è stata introdotta la possibilità di scrivere filtri per è comparso , utilizzando il BPF, a gennaio 2013 è stata seccomp, utilizzando BPF, a gennaio 2013 è stato è stata aggiunta modulo xt_bpf, che permette di scrivere regole per iptables attraverso BPF, e nell'ottobre 2013 è stato è stata aggiunta anche un modulo cls_bpf, che consente di scrivere classificatori di traffico tramite BPF.

Presto esamineremo tutti questi esempi in dettaglio, ma prima sarà utile imparare a scrivere e compilare programmi arbitrari per BPF, poiché le possibilità fornite dalla libreria libpcap sono limitate (un esempio semplice: un filtro generato libpcap può restituire solo due valori — 0 oppure 0x40000) o, come nel caso di seccomp, non applicabili affatto.

Programmare il BPF con le proprie mani

Familiarizziamo con il formato binario delle istruzioni BPF, è molto semplice:

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

Ogni istruzione occupa 64 bit, nei quali i primi 16 bit sono il codice operativo, seguiti da due offset a otto bit, jt e jf, e 32 bit per l'argomento K, il cui significato cambia a seconda del comando. Ad esempio, il comando ret, che termina l'esecuzione del programma ha codice 6, e il valore restituito proviene da una costante K. In linguaggio C una istruzione BPF è rappresentata da una struttura

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

e un intero programma — sotto forma di struttura

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

Così, siamo già in grado di scrivere programmi (i codici delle istruzioni li conosciamo già, supponiamo, da [1]). Questo sarà l'aspetto del filtro ip6 da nostro primo esempio:

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

Il programma prog può essere legalmente utilizzato nella chiamata

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

Scrivere programmi in forma di codici macchina non è molto conveniente, ma a volte è necessario (ad esempio, per il debug, la creazione di test unitari, la scrittura di articoli su Habr, ecc.). Per comodità, nel file <linux/filter.h> sono definiti i macro helper — lo stesso esempio di sopra potrebbe essere riscritto come

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

Tuttavia, anche questa opzione non è molto comoda. I programmatori del kernel Linux hanno pensato lo stesso, e quindi nella directory tools/bpf del kernel si possono trovare assembler e debugger per lavorare con BPF classico.

Il linguaggio assembly è molto simile all'output di debug tcpdump, ma in aggiunta possiamo specificare etichette simboliche. Ad esempio, ecco un programma che scarta tutti i pacchetti, tranne TCP/IPv4:

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

Per impostazione predefinita, l'assemblatore genera codice nel formato , ,..., per il nostro esempio con TCP otteniamo

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

Per comodità dei programmatori C, è possibile utilizzare un altro formato di output:

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

Questo testo può essere copiato nella definizione della struttura del tipo struct sock_filter, come abbiamo fatto all'inizio di questo capitolo.

Estensioni Linux e netsniff-ng

Oltre alle istruzioni standard BPF, Linux e tools/bpf/bpf_asm supportano anche un insieme non standard. Principalmente, le istruzioni servono ad accedere ai campi della struttura struct sk_buff, che descrive un pacchetto di rete nel kernel. Tuttavia, ci sono anche istruzioni ausiliarie di un altro tipo, come ad esempio ldw cpu caricherà nel registro A il risultato dell'esecuzione della funzione del kernel raw_smp_processor_id(). (Nella nuova versione BPF, queste estensioni non standard sono state ampliate sotto forma di fornitura di kernel helpers per accedere a memoria, strutture e generare eventi.) Ecco un esempio interessante di filtro, in cui copiamo nello spazio utente solo le intestazioni dei pacchetti, utilizzando l'estensione poff, offset del payload:

ld poff
ret a

Le estensioni BPF non possono essere utilizzate in tcpdump, ma è un buon motivo per conoscere il pacchetto di utilità netsniff-ng, che, tra le altre cose, contiene un programma avanzato netsniff-ng, che oltre alla filtrazione tramite BPF include anche un generatore di traffico efficace, e un assembler BPF più avanzato, chiamato tools/bpf/bpf_asmbpfc . Il pacchetto contiene una documentazione piuttosto dettagliata, vedere anche i link alla fine dell'articolo.Quindi, ora sappiamo scrivere programmi BPF di complessità arbitraria e siamo pronti a guardare nuovi esempi, il primo dei quali è la tecnologia seccomp, che consente di utilizzare filtri BPF per gestire un gran numero di argomenti di syscalls disponibili per questo processo e i suoi discendenti.

seccomp

La prima versione di seccomp è stata aggiunta al kernel nel 2005 e non ha goduto di grande popolarità, poiché forniva solo l'unica possibilità di limitare il numero di syscalls disponibili per il processo, ovvero:

sigreturn read, write, exit e , e il processo che violava le regole veniva terminato conSIGKILL SIGKILL. Tuttavia, nel 2012 è stata aggiunta a seccomp la possibilità di utilizzare i filtri BPF, che consentono di definire un ampio numero di chiamate di sistema consentite e persino di eseguire controlli sui loro argomenti. (È interessante notare che uno dei primi utilizzatori di questa funzionalità è stato Chrome, e attualmente i membri di Chrome stanno sviluppando un meccanismo KRSI, basato su una nuova versione di BPF che consente di personalizzare i Linux Security Modules.) I riferimenti alla documentazione aggiuntiva possono essere trovati alla fine dell'articolo.

Va notato che su Habr ci sono già articoli sull'utilizzo di seccomp, potrebbe interessare a qualcuno leggerli prima (o invece) di leggere i prossimi sottoparagrafi. Nell'articolo Contenitori e sicurezza: seccomp sono forniti esempi di utilizzo di seccomp, sia della versione del 2007 sia della versione che utilizza BPF (i filtri vengono generati tramite libseccomp), si parla del collegamento tra seccomp e Docker, e vengono forniti numerosi collegamenti utili. Nell'articolo Isoliamo i demoni con systemd o 'non hai bisogno di Docker per questo!' si parla, in particolare, di come aggiungere elenchi neri o bianchi di chiamate di sistema per i demoni gestiti da systemd.

Successivamente, vedremo come scrivere e caricare filtri per seccomp in C puro e utilizzando la libreria libseccomp e quali sono i vantaggi e gli svantaggi di ciascuna opzione; infine, daremo un'occhiata a come seccomp viene utilizzato dal programma strace.

Scriviamo e carichiamo filtri per seccomp

Sappiamo già scrivere programmi BPF e quindi inizieremo a esaminare l'interfaccia di programmazione di seccomp. È possibile impostare un filtro a livello di processo, e tutti i processi figli erediteranno queste limitazioni. Ciò avviene tramite la chiamata di sistema seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

dove &filter è un puntatore alla struttura che già conosciamo struct sock_fprog, cioè il programma BPF.

In cosa differiscono i programmi per seccomp dai programmi per i socket? Nel contesto passato. Nel caso dei socket, veniva passato un'area di memoria contenente il pacchetto, mentre nel caso di seccomp ci viene passata una struttura del tipo

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

Qui nr è il numero della chiamata di sistema in esecuzione, arch è l'architettura corrente (di questo parleremo più avanti), args sono fino a sei argomenti della chiamata di sistema, e instruction_pointer — è un riferimento alle istruzioni nello spazio utente che ha effettuato questa chiamata di sistema. Pertanto, per esempio, per caricare il numero della chiamata di sistema nel registro A dobbiamo dire

ldw [0]

Per i programmi seccomp ci sono altre peculiarità, ad esempio, l'accesso al contesto è possibile solo con un allineamento a 32 bit e non è possibile caricare una mezza parola o un byte — tentando di caricare un filtro ldh [0] la chiamata di sistema seccomp restituirà EINVAL. Il controllo dei filtri caricati viene eseguito dalla funzione seccomp_check_filter() del kernel. (Divertente, nel commit originale che aggiunge la funzionalità seccomp, è stato dimenticato di aggiungere il permesso per utilizzare l'istruzione mod (resto della divisione) e ora non è accessibile per i programmi seccomp BPF, poiché la sua aggiunta romperebbe ABI.)

In linea di principio, sappiamo già tutto ciò che serve per scrivere e leggere programmi seccomp. Di solito, la logica del programma è impostata come una lista bianca o nera di chiamate di sistema, ad esempio il programma

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

controlla una lista nera di quattro chiamate di sistema con i numeri 304, 176, 239, 279. Ma quali sono queste chiamate di sistema? Non possiamo dirlo con certezza, poiché non sappiamo per quale architettura è stato scritto il programma. Pertanto, gli autori di seccomp suggeriscono di iniziare tutti i programmi controllando l'architettura (l'architettura corrente è specificata nel contesto come campo arch struttura struct seccomp_data). Con il controllo dell'architettura, l'inizio dell'esempio apparirebbe come:

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

e così i nostri numeri delle chiamate di sistema avrebbero ricevuto valori specifici.

Scriviamo e carichiamo filtri per seccomp utilizzando libseccomp

Scrivere filtri in codici macchina o per assembler BPF consente di avere il pieno controllo sul risultato, ma a volte è preferibile avere un codice portabile e/o leggibile. In questo ci aiuterà la libreria libseccomp, che fornisce un'interfaccia standard per scrivere filtri neri o bianchi.

Scriviamo, ad esempio, un programma che esegue un file binario a scelta dell'utente, stabilendo prima una lista nera di chiamate di sistema da quanto sopra citato (il programma è semplificato per una maggiore leggibilità, è possibile trovare la versione completa qui):

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

Iniziamo definendo un array sys_numbers da 40+ numeri di chiamata di sistema per il blocco. Poi, inizializziamo il contesto ctx e diciamo alla libreria che vogliamo consentire (SCMP_ACT_ALLOW) tutte le chiamate di sistema per impostazione predefinita (costruire liste nere è più semplice). Poi, uno alla volta, aggiungiamo tutte le chiamate di sistema dalla lista nera. In risposta a una chiamata di sistema dalla lista chiediamo SCMP_ACT_TRAP, in questo caso seccomp invierà al processo un segnale SIGSYS con una descrizione di quale chiamata di sistema ha infranto le regole. Infine, carichiamo il programma nel kernel usando seccomp_load, che compilerà il programma e lo collegherà al processo tramite la chiamata di sistema seccomp(2).

Per una compilazione riuscita, il programma deve essere collegato alla libreria libseccomp, per esempio:

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

Esempio di esecuzione riuscita:

$ ./seccomp_lib echo ok
ok

Esempio di chiamata di sistema bloccata:

$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Chiamata di sistema non valida

Usa strace, per conoscere maggiori dettagli:

$ 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} ---
+++ ucciso da SIGSYS (core dumped) +++
Chiamata di sistema non valida

da cui possiamo sapere che il programma è stato terminato a causa dell'uso di una chiamata di sistema vietata mount(2).

In sintesi, abbiamo scritto un filtro utilizzando la libreria libseccomp, racchiudendo codice non banale in quattro righe. Nell'esempio sopra, con un numero elevato di chiamate di sistema, il tempo di esecuzione può diminuire notevolmente, poiché il controllo è semplicemente un elenco di confronti. Per ottimizzare, recentemente in libseccomp è stata inclusa una patch, che aggiunge supporto per l'attributo filtro SCMP_FLTATR_CTL_OPTIMIZE. Se si imposta questo attributo a 2, il filtro verrà trasformato in un programma di ricerca binaria.

Se vuoi vedere come sono strutturati i filtri con ricerca binaria, dai un'occhiata a uno script semplice, che genera tali programmi in assembler BPF basandosi su un insieme di numeri di chiamate di sistema, ad esempio:

$ 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

Non si può scrivere nulla di sostanzialmente più veloce, poiché i programmi BPF non possono effettuare salti a livello di indentazione (non possiamo fare, ad esempio, jmp A o jmp [label+X]) e quindi tutti i salti sono statici.

seccomp e strace

Tutti conoscono l'utilità strace uno strumento indispensabile per studiare il comportamento dei processi su Linux. Tuttavia, molti sono anche a conoscenza di problemi di prestazioni nell'uso di questo strumento. Il fatto è che strace è implementato tramite ptrace(2), e in questo meccanismo non possiamo specificare su quali insiemi di chiamate di sistema dobbiamo fermare il processo, ad esempio, i comandi

$ time strace du /usr/share/ >/dev/null 2>&1

real    0m3.081s
user    0m0.531s
sys     0m2.073s

e

$ time strace -e open du /usr/share/ >/dev/null 2>&1

real    0m2.404s
user    0m0.193s
sys     0m1.800s

si comportano in tempi più o meno simili, anche se nel secondo caso vogliamo tracciare solo una chiamata di sistema.

Una nuova opzione --seccomp-bpf, aggiunta nella strace versione 5.3, consente di accelerare il processo drasticamente e il tempo di avvio sotto tracciamento di una chiamata di sistema è già paragonabile a quello di un avvio normale:

$ 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

(Qui, naturalmente, c'è un piccolo inganno nel fatto che stiamo tracciando non la chiamata di sistema principale di questo comando. Se stessimo tracciando, ad esempio, newfsstat, allora strace sarebbe rallentato altrettanto tanto quanto senza --seccomp-bpf.)

Come funziona questa opzione? Senza di essa strace si collega al processo e lo avvia tramite PTRACE_SYSCALL. Quando il processo controllato esegue (qualsiasi) chiamata di sistema, il controllo viene passato strace, che esamina gli argomenti della chiamata di sistema e la avvia tramite PTRACE_SYSCALL. Dopo un po' di tempo, il processo termina la chiamata di sistema e al momento della sua uscita, il controllo viene nuovamente passato strace, che osserva i valori di ritorno e avvia il processo tramite PTRACE_SYSCALL, e così via.

BPF per i più piccoli, parte zero: classic BPF

Con seccomp, tuttavia, questo processo può essere ottimizzato proprio come desideriamo. Infatti, se vogliamo osservare solo la chiamata di sistema X, possiamo scrivere un filtro BPF che per X restituisce il valore SECCOMP_RET_TRACE, mentre per le chiamate che non ci interessano — SECCOMP_RET_ALLOW:

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

In questo caso strace inizialmente avvia il processo come PTRACE_CONT, per ogni chiamata di sistema il nostro filtro viene eseguito, se la chiamata di sistema non è X, il processo continua a funzionare, ma se è X, seccomp passerà il controllo strace, che esaminerà gli argomenti e avvierà il processo come PTRACE_SYSCALL (poiché seccomp non consente di eseguire il programma all'uscita da una chiamata di sistema). Quando la chiamata di sistema restituisce, strace riavvierà il processo utilizzando PTRACE_CONT e attenderà nuovi messaggi da seccomp.

BPF per i più piccoli, parte zero: classic BPF

Utilizzando l'opzione --seccomp-bpf ci sono due limitazioni. In primo luogo, non è possibile collegarsi a un processo già esistente (opzione -p programmi strace), poiché non è supportato da seccomp. In secondo luogo, non è possibile non guardare ai processi figli, poiché i filtri seccomp vengono ereditati da tutti i processi figli senza possibilità di disattivarlo.

Qualche dettaglio in più su come esattamente strace funziona con seccomp può essere trovato in una recente relazione. Per noi, il fatto più interessante è che il classico BPF in forma di seccomp è ancora utilizzato.

xt_bpf

Torniamo ora nel mondo delle reti.

Antefatto: tanto tempo fa, nel 2007, nel kernel è stato è stata aggiunta modulo xt_u32 per netfilter. È stato scritto in analogia con un classificatore di traffico ancora più antico, cls_u32 e consentiva di scrivere regole binarie arbitrarie per iptables utilizzando le seguenti semplici operazioni: caricare 32 bit dal pacchetto ed eseguire un insieme di operazioni aritmetiche. Ad esempio,

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

Carica 32 bit dall'intestazione IP, a partire da un offset di 6, e applica una maschera 0xFF (prendere il byte meno significativo). Questo è il campo protocollo dell'intestazione IP e lo confrontiamo con 1 (ICMP). In una regola è possibile combinare più controlli, e si può anche eseguire l'operatore @ — spostarsi di X byte a destra. Ad esempio, la regola

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

verifica se il TCP Sequence Number 0x29. Non entrerò ulteriormente nei dettagli, poiché è già chiaro che scrivere manualmente tali regole non è molto comodo. Nell'articolo BPF — the forgotten bytecode, ci sono alcuni collegamenti con esempi di utilizzo e generazione di regole per xt_u32. Vedi anche i collegamenti alla fine di questo articolo.

A partire dal 2013, invece del modulo xt_u32 è possibile utilizzare un modulo basato su BPF. xt_bpfA tutti coloro che hanno letto fino a qui dovrebbe essere chiaro il principio del suo funzionamento: eseguire il bytecode BPF come regole iptables. Una nuova regola può essere creata, ad esempio, così:

iptables -A INPUT -m bpf --bytecode <bytecode> -j LOG

qui <bytecode> è il codice nel formato di output dell'assembler bpf_asm di default, ad esempio,

$ 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 questo esempio filtriamo tutti i pacchetti UDP. Il contesto per il programma BPF nel modulo xt_bpf, naturalmente, indica i dati del pacchetto, nel caso di iptables — l'inizio dell'intestazione IPv4. Il valore restituito dal programma BPF booleano, dove false significa che il pacchetto non corrisponde.

È chiaro che il modulo xt_bpf supporta filtri più complessi di quelli dell'esempio sopra. Diamo un'occhiata a esempi reali provenienti da Cloudfare. Fino a poco tempo fa utilizzavano il modulo xt_bpf per proteggere dagli attacchi DDoS. Nell'articolo Introducing the BPF Tools parlano di come (e perché) generano filtri BPF e pubblicano collegamenti a un insieme di strumenti per creare tali filtri. Ad esempio, con l'utensile bpfgen è possibile creare un programma BPF che corrisponde a una richiesta DNS per il nome 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

Nel programma carichiamo prima in registro X l'indirizzo dell'inizio della stringa x04habrx03comx00 all'interno di un datagramma UDP e poi controlliamo la richiesta: 0x04686162 <-> "x04hab" ecc.

Poco dopo Cloudfare ha pubblicato il codice del compilatore p0f -> BPF. Nell'articolo Introducing the p0f BPF compiler parlano di cosa sia p0f e di come trasformare le firme p0f in 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,
...

Attualmente Cloudfare non utilizza più xt_bpf, poiché si sono spostati su XDP — una delle modalità di utilizzo della nuova versione di BPF, vedi. L4Drop: XDP DDoS Mitigations.

cls_bpf

L'ultimo esempio di utilizzo del BPF classico nel kernel è un classificatore cls_bpf per il sottosistema di controllo del traffico in Linux, aggiunto in Linux alla fine del 2013 e concettualmente ha sostituito il vecchio cls_u32.

Tuttavia, non descriveremo ora il funzionamento cls_bpf, poiché dal punto di vista delle conoscenze sul BPF classico non ci aiuterà a comprendere nulla — abbiamo già esplorato tutte le funzionalità. Inoltre, nei successivi articoli che trattano del BPF Esteso, ci incontreremo ripetutamente con questo classificatore.

Un altro motivo per non discutere dell'uso del BPF classico con cls_bpf è che rispetto al BPF Esteso in questo caso il campo di applicazione si restringe notevolmente: i programmi classici non possono modificare il contenuto dei pacchetti e non possono mantenere lo stato tra le chiamate.

Quindi è tempo di dire addio al BPF classico e dare uno sguardo al futuro.

Addio al BPF classico

Abbiamo esaminato come la tecnologia BPF, sviluppata all'inizio degli anni '90, abbia con successo superato un quarto di secolo trovando costantemente nuove applicazioni. Tuttavia, proprio come il passaggio dalle macchine stack a RISC, che ha spinto lo sviluppo del classico BPF, negli anni 2000 si è verificato il passaggio da macchine a 32 bit a 64 bit e il classico BPF ha cominciato a diventare obsoleto. Inoltre, le funzionalità del classico BPF sono fortemente limitate e, oltre all'architettura obsoleta, non abbiamo la possibilità di mantenere lo stato tra le chiamate ai programmi BPF, non possiamo interagire direttamente con l'utente, né possiamo interagire con il kernel, a parte la lettura di un numero limitato di campi della struttura. sk_buff e lanciare le più semplici funzioni ausiliarie, non possiamo modificare il contenuto dei pacchetti né reindirizzarli.

Attualmente, nel kernel Linux, rimane solo l'interfaccia API del classico BPF, e all'interno del kernel tutte le classiche applicazioni, siano esse filtri socket o filtri seccomp, vengono automaticamente tradotte in un nuovo formato, Extended BPF. (Racconteremo come avviene questo processo nel prossimo articolo.)

Il passaggio alla nuova architettura è iniziato nel 2013, quando Alexey Starovoitov propose uno schema di aggiornamento per il BPF. Nel 2014, i relativi patch hanno iniziato a comparire nel kernel. Da quanto capisco, inizialmente era previsto solo di ottimizzare l'architettura e il JIT-compiler per un funzionamento più efficiente su macchine a 64 bit, ma invece queste ottimizzazioni hanno dato inizio a un nuovo capitolo nello sviluppo di Linux.

Ulteriori articoli di questa serie parleranno dell'architettura e delle applicazioni della nuova tecnologia, inizialmente conosciuta come internal BPF, poi extended BPF, e ora semplicemente come BPF.

Link

  1. Steven McCanne e 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. Tutorial su IPtable U32 Match.
  5. BPF — il bytecode dimenticato: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Introduzione allo strumento BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Panoramica su seccomp: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Contenitori e sicurezza: seccomp
  11. habr: Isoliamo i demoni con systemd o "non hai bisogno di Docker per farlo!"
  12. Paul Chaignon, "strace —seccomp-bpf: uno sguardo sotto il cofano", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster