I Berkeley Packet Filters (BPF) sono una tecnologia del kernel Linux che occupa da alcuni anni le prime pagine delle pubblicazioni tecniche di lingua inglese. Le conferenze sono piene di relazioni sull'uso e lo sviluppo di BPF. David Miller, il manutentore del sottosistema di rete di Linux, ha intitolato la sua presentazione al Linux Plumbers 2018 (XDP è uno dei modi di utilizzo di BPF). Brendan Gregg presenta relazioni intitolate . Toke Høiland-Jørgensen , affermando che il kernel ora è un microkernel. Thomas Graf promuove l'idea che .
Su Habr non esiste ancora una descrizione sistematica di BPF e quindi, in questa 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, articolo zero del ciclo, viene raccontata la storia e l'architettura del BPF classico, svelando inoltre i segreti dei principi di funzionamento tcpdump, seccomp, strace, e molto altro ancora.
Lo sviluppo di BPF è controllato dalla comunità di rete di Linux, le principali applicazioni esistenti di BPF sono legate alle reti e quindi, con il permesso di , ho intitolato la serie "BPF per i più piccoli", in onore della grande serie .
Breve corso di storia del BPF (c)
La tecnologia BPF moderna è una versione migliorata e ampliata della vecchia tecnologia con lo stesso nome, ora chiamata, per evitare confusione, classic BPF. Sulla base del classic BPF sono state create l'utility ben nota tcpdump, il meccanismo seccomp, così come il meno noto modulo xt_bpf per iptables e il classificatore cls_bpf. Nel Linux moderno, i programmi BPF classici vengono automaticamente tradotti in una nuova forma, tuttavia, dal punto di vista dell'utente, l'API è rimasta invariata e i nuovi utilizzi del classic BPF, come vedremo in questo articolo, esistono ancora. Per questa ragione, e anche perché seguire la storia dello sviluppo del classic BPF in Linux renderà più chiaro come e perché è evoluto nella forma moderna, ho deciso di iniziare proprio con un articolo sul classic BPF.
Alla fine degli anni '80, ingegneri del famoso Lawrence Berkeley Laboratory si sono interessati alla questione di come filtrare correttamente i pacchetti di rete su hardware moderno per quell'epoca. L'idea di base del filtraggio, originariamente implementata nella tecnologia CSPF (CMU/Stanford Packet Filter), consisteva nel filtrare i pacchetti superflui il prima possibile, ovvero nello spazio del kernel, poiché ciò consente di non copiare dati inutili nello spazio utente. Per garantire la sicurezza del tempo di esecuzione per l'esecuzione di codice utente nello spazio del kernel, è stata utilizzata una macchina virtuale — un sandbox.
Tuttavia, le macchine virtuali per i filtri esistenti erano progettate per essere eseguite su macchine con architettura stack e non funzionavano altrettanto efficacemente su nuove macchine RISC. Di conseguenza, grazie agli sforzi degli ingegneri dei Berkeley Labs, è stata sviluppata una nuova tecnologia BPF (Berkeley Packet Filters), la cui architettura di macchina virtuale è stata progettata sulla base del processore Motorola 6502 — il cavallo da lavoro di prodotti così noti come o . La nuova macchina virtuale aumentava la performance dei filtri di decine di volte rispetto alle soluzioni esistenti.
Architettura della macchina BPF
Esamineremo l'architettura in modo pratico, analizzando esempi. Tuttavia, per iniziare, diciamo che la macchina aveva due registri a 32 bit disponibili per l'utente: un registratore A e un registro indice X, 64 byte di memoria (16 parole) accessibile per scrittura e lettura, e un piccolo set di istruzioni per lavorare con questi oggetti. Nei programmi erano disponibili anche istruzioni di salto per implementare espressioni condizionali, tuttavia, per garantire il completamento tempestivo dell'esecuzione del programma, i salti potevano essere fatti solo in avanti, cioè, era vietato creare cicli.
Lo schema generale di avvio della macchina è il seguente. L'utente crea un programma per l'architettura BPF e, tramite un qualche meccanismo del kernel (ad esempio, una chiamata di sistema), carica e collega il programma a un qualche al 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 un programma (ad esempio, in un interprete), mentre la memoria della macchina corrisponde un qualche alla regione di memoria del kernel (ad esempio, ai dati del pacchetto ricevuto).
Quanto appena detto è sufficiente per iniziare a esaminare esempi: ci familiarizzeremo con il sistema e il formato dei comandi se necessario. Tuttavia, se desideri immediatamente esplorare il sistema dei comandi della macchina virtuale e scoprire tutte le sue funzionalità, puoi leggere l'articolo originale e/o la prima metà del file della documentazione del kernel. Inoltre, puoi esaminare la presentazione , 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 applicazione del BPF classico in Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.
tcpdump
Lo sviluppo del BPF è avvenuto in parallelo allo sviluppo del frontend per il filtraggio dei pacchetti, l'utility ben nota. tcpdump. E poiché questo è il più antico e il più conosciuto esempio di utilizzo del classico BPF, disponibile su numerosi sistemi operativi, cominceremo l'esplorazione della tecnologia da qui.
(Tutti gli esempi in questo articolo sono stati eseguiti su Linux 5.6.0-rc6. L'output di alcuni comandi è stato modificato per una maggiore leggibilità.)
Esempio: osserviamo pacchetti IPv6
Immaginiamo di voler esaminare tutti i pacchetti IPv6 sull'interfaccia eth0. Per farlo, possiamo avviare il programma tcpdump con un filtro semplice ip6:
$ sudo tcpdump -i eth0 ip6In questo modo tcpdump compilerà il filtro ip6 in bytecode per l'architettura BPF e lo invierà al kernel (vedi dettagli nella sezione ). Il filtro caricato sarà eseguito per ogni pacchetto che passa attraverso l'interfaccia eth0. Se il filtro restituisce un valore diverso da zero n, allora fino a n byte del pacchetto verrà copiato nello spazio utente e lo vedremo nell'output tcpdump.

Si scopre che possiamo facilmente scoprire quale bytecode è stato inviato al kernel tcpdump utilizzando lo stesso tcpdump, se lo eseguiamo 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 #0Nella riga zero avviamo il comando ldh [12], che si decifra come 'caricare nel registro A mezzo-parola (16 bit) si trova all'indirizzo 12» e l'unica domanda è quale tipo di memoria stiamo indirizzando? La risposta è che all'indirizzo x inizia (x+1)il byte n-esimo del pacchetto di rete in analisi. Leggiamo i pacchetti dall'interfaccia Ethernet eth0, e questo è , quindi il pacchetto appare come segue (per semplicità supponiamo che il pacchetto non abbia tag VLAN):
6 6 2
|MAC di destinazione|MAC di origine|Tipo Ethernet|...|Quindi, dopo l'esecuzione del comando ldh [12] nel registro A si troverà il campo Tipo Ethernet — il tipo del pacchetto trasmesso in questo frame Ethernet. Nella riga 1 confrontiamo il contenuto del registro A (tipo di pacchetto) con 0x86dd, e questo è il tipo IPv6 che ci interessa. Nella riga 1 oltre al comando di confronto ci sono altre due colonne — jt 2 e jf 3 — etichette a cui passare in caso di confronto riuscito (A == 0x86dd) e non riuscito. Quindi, in caso di successo (IPv6) passiamo alla riga 2, in caso contrario 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 massimo 256 kilobyte del pacchetto).
Esempio più complesso: osserviamo i pacchetti TCP sulla porta di destinazione
Vediamo come appare il filtro che copia tutti i pacchetti TCP con la porta di destinazione 666. Considereremo il caso IPv4, poiché il caso IPv6 è più semplice. Dopo aver esaminato questo esempio, puoi esercitarti a studiare autonomamente il filtro per IPv6 (ip6 e 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 #0Cosa fanno le righe 0 e 1 già lo sappiamo. Nella riga 2 abbiamo già verificato che si tratta di un pacchetto IPv4 (Ether Type = 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|...|quindi carichiamo nel registro A il campo Protocollo dell'intestazione IP, il che è logico, dato che vogliamo copiare solo i pacchetti TCP. Confrontiamo il Protocollo con 0x6 () nella riga 3.
Nelle righe 4 e 5 carichiamo una parola, situata all'indirizzo 20, e tramite il comando jset controlliamo se uno dei tre è impostato nella maschera fornita. jset i tre bit più significativi sono stati azzerati. Due dei tre bit ci indicano se il pacchetto fa parte di un pacchetto IP frammentato e, in caso affermativo, se si tratta dell'ultimo frammento. Il terzo bit è riservato e deve essere pari a zero. Non vogliamo controllare pacchetti incompleti o corrotti, quindi controlliamo 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 dell'intestazione IPv4, dove è memorizzata la lunghezza dell'intestazione in parole, quindi è necessario moltiplicare per 4. È interessante notare che l'espressione 4*([14]&0xf) è una rappresentazione di una speciale schema 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) né ldxb 5*([14]&0xf) (possiamo solo specificare un altro offset, per esempio, ldxb 4*([16]&0xf)). È chiaro che questo schema di indirizzamento è stato aggiunto a BPF proprio per ottenere in X (registro indice) la lunghezza dell'intestazione IPv4.
Pertanto, nella riga 7 stiamo cercando di caricare mezzo parola, all'indirizzo (X+16). Ricordando che l'intestazione Ethernet occupa 14 byte, e che X contiene la lunghezza dell'intestazione IPv4, comprendiamo che A viene caricato il porto di destinazione TCP:
14 X 2 2
|intestazione ethernet|intestazione ip|porto sorgente|porto di destinazione|Infine, alla riga 8 confrontiamo il porto di destinazione con il valore cercato e alle righe 9 o 10 restituiamo il risultato: copiare il pacchetto o meno.
Tcpdump: caricamento
Negli esempi precedenti non ci siamo soffermati in dettaglio su come carichiamo il bytecode BPF nel kernel per filtrare i pacchetti. In generale, tcpdump è portabile su molteplici sistemi e per lavorare con i filtri utilizza la libreria . In breve, per installare un filtro sull'interfaccia tramite libpcap, è necessario eseguire quanto segue:
- creare un descrittore di tipo
pcap_tdal nome dell'interfaccia: , - attivare l'interfaccia: ,
- compilare il filtro: ,
- collegare il filtro: .
Per vedere come la funzione pcap_setfilter è implementata in Linux, utilizziamo 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, creiamo per leggere tutti i frame Ethernet e lo colleghiamo all'interfaccia eth0. Tra sappiamo che il filtro ip sarà composto da quattro istruzioni BPF, e nella terza riga vediamo come, utilizzando l'opzione chiamata di sistema setsockopt carichiamo e colleghiamo un filtro di lunghezza 4. Questo è il nostro filtro.
È importante notare che nel BPF classico il caricamento e il collegamento del filtro avviene sempre come un'operazione atomica, mentre nella nuova versione del BPF il caricamento del programma e il suo legame 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 (Resource temporarily unavailable)
setsockopt(3, SOL_SOCKET, SO_ATTACH_FILTER, {len=4, filter=0xb00bb00bb00b}, 16) = 0
...Come accennato prima, carichiamo e colleghiamo al socket il nostro filtro alla riga 5, ma cosa sta succedendo alle righe 3 e 4? Si scopre che questo libpcap si prende cura di noi affinché i pacchetti che non soddisfano le condizioni del nostro filtro non vengano emessi, biblioteca filtro fittizio ret #0 (scartare tutti i pacchetti), imposta il socket in modalità non bloccante e cerca di leggere tutti i pacchetti che potrebbero essere rimasti dai filtri precedenti.
Pertanto, per filtrare i pacchetti su Linux utilizzando il classico BPF, è necessario avere un filtro sotto forma di struttura di 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 raw. Ecco 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.)
Per ulteriori informazioni sull'uso setsockopt per collegare i filtri, vedi , mentre parleremo della scrittura dei propri filtri di tipo struct sock_fprog senza aiuto tcpdump nella sezione .
BPF classico e XXI secolo
BPF è stato integrato in Linux nel 1997 e per molto tempo è rimasto un cavallo da lavoro libpcap senza particolari cambiamenti (cambiamenti specifici di Linux, ovviamente, , ma non hanno cambiato il quadro globale). I primi segnali significativi che il BPF avrebbe evoluto sono emersi nel 2011, quando Eric Dumazet propose , aggiungendo al kernel un Just In Time Compiler — un traduttore per convertire il bytecode BPF in codice nativo x86_64 .
Il JIT compiler è stato il primo nella catena di cambiamenti: nel 2012 la possibilità di scrivere filtri per , utilizzando il BPF, a gennaio 2013 è stato modulo xt_bpf, che consente di scrivere regole per iptables con l'aiuto del BPF, e nell'ottobre 2013 c'è stato anche un modulo cls_bpf, che permette di scrivere classificatori di traffico con l'aiuto del BPF.
Presto esamineremo tutti questi esempi in dettaglio, ma prima sarà utile imparare a scrivere e compilare programmi arbitrari per il BPF, poiché le capacità fornite dalla libreria libpcap sono limitate (un esempio semplice: un filtro generato libpcap può restituire solo due valori — 0 o 0x40000) o, come nel caso del seccomp, non sono applicabili.
Programmazione BPF con le proprie mani
Familiarizziamoci con il formato binario delle istruzioni BPF, che è molto semplice:
16 8 8 32
| codice | jt | jf | k |Ogni istruzione occupa 64 bit, nei quali i primi 16 bit rappresentano il codice dell'operazione, seguiti da due offset a otto bit, jt e jf, e 32 bit per l'argomento K, il cui scopo varia da comando a comando. Ad esempio, il comando ret, che termina l'esecuzione del programma ha codice 6, mentre il valore restituito proviene da una costante K. Nel linguaggio C, un'istruzione BPF è rappresentata come una struttura
struct sock_filter {
__u16 code;
__u8 jt;
__u8 jf;
__u32 k;
}e un'intera programma è rappresentato come una struttura
struct sock_fprog {
unsigned short len;
struct sock_filter *filter;
}Così, possiamo già scrivere programmi (i codici delle istruzioni li conosciamo da ). Ecco come apparirebbe il filtro ip6 di :
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 possiamo utilizzarlo legalmente nella chiamata
setsockopt(sk, SOL_SOCKET, SO_ATTACH_FILTER, &prog, sizeof(prog))Scrivere programmi in forma di codici macchina non è molto comodo, ma a volte è necessario (ad esempio, per il debugging, la creazione di unit test, la scrittura di articoli su Habra e così via). Per comodità nel file <linux/filter.h> sono definiti macro di supporto — lo stesso esempio di sopra potrebbe essere riscritto come
codice struct sock_filter[] = {
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. Così hanno pensato gli sviluppatori del kernel Linux e quindi nella directory del kernel si possono trovare un assemblatore e un debugger per lavorare con il 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 #0Per impostazione predefinita, l'assemblatore genera codice nel formato , ,..., per il nostro esempio con TCP otterremo
$ 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 già fatto all'inizio di questa sezione.
Estensioni di Linux e netsniff-ng
Oltre alle istruzioni standard BPF, Linux e tools/bpf/bpf_asm supportano anche . In generale, le istruzioni servono per accedere ai campi della struttura struct sk_buff, che descrive un pacchetto di rete nel kernel. Tuttavia, ci sono anche istruzioni helper 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 di BPF, queste estensioni non standard sono state ampliate fornendo ai programmi un set di kernel helpers per l'accesso alla memoria, alle strutture e per la generazione di eventi.) Ecco un interessante esempio di filtro, in cui copiamo nello spazio utente solo gli header dei pacchetti, utilizzando l'estensione poff, offset del payload:
ld poff
ret aLe estensioni BPF non possono essere utilizzate in tcpdump, ma è una buona occasione per conoscere il pacchetto di strumenti , 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.. Пакет содержит довольно подробную документацию, см. также ссылки в конце статьи.
seccomp
Quindi, siamo già in grado di scrivere programmi BPF di complessità variabile e siamo pronti a esplorare nuovi esempi, il primo dei quali è la tecnologia seccomp, che consente di gestire diversi e variabili argomenti delle chiamate di sistema disponibili per questo processo e i suoi discendenti tramite filtri BPF.
La prima versione di seccomp è stata aggiunta al kernel nel 2005 e non ha riscosso grande successo, poiché offriva solo un'opzione: limitare il numero delle chiamate di sistema disponibili per il processo, con il seguente: read, write, exit e sigreturn, e il processo che violava le regole veniva terminato tramite SIGKILL. Tuttavia, nel 2012 è stata introdotta la possibilità di utilizzare filtri BPF in seccomp, consentendo di definire un insieme di chiamate di sistema autorizzate e persino di eseguire controlli sui loro argomenti. (È interessante notare che uno dei primi utilizzatori di questa funzionalità è stato Chrome, e attualmente gli sviluppatori di Chrome stanno lavorando a un meccanismo KRSI, basato su una nuova versione di BPF, che consente di personalizzare i moduli di sicurezza di Linux.) I riferimenti alla documentazione aggiuntiva possono essere trovati alla fine dell'articolo.
Da notare che su Habr ci sono già stati articoli sull'uso di seccomp, quindi potrebbe interessare a qualcuno leggerli prima (o al posto) dei prossimi paragrafi. Nell'articolo sono forniti esempi di utilizzo di seccomp, sia della versione del 2007 che di quella che utilizza BPF (i filtri sono generati tramite libseccomp), si discute del legame tra seccomp e Docker, e sono fornite molte risorse utili. Nell'articolo si parla, in particolare, di come aggiungere liste nere o liste bianche di chiamate di sistema per i demoni gestiti da systemd.
Successivamente vedremo come scrivere e caricare filtri per seccomp in puro C e utilizzando la libreria libseccomp e quali sono i pro e i contro di ciascuna opzione, per concludere vedremo come seccomp è utilizzato dal programma strace.
Scriviamo e carichiamo filtri per seccomp
Ormai sappiamo scrivere programmi BPF e quindi iniziamo a esaminare l'interfaccia programmabile di seccomp. È possibile impostare un filtro a livello di processo, e tutti i processi figlio erediteranno le limitazioni. Questo viene fatto tramite la chiamata di sistema :
seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)dove &filter — è un puntatore alla struttura già conosciuta struct sock_fprog, ovvero il programma BPF.
Qual è la differenza tra i programmi per seccomp e i programmi per socket? Il contesto passato. Nel caso dei socket, ci veniva passata un'area di memoria contenente un pacchetto, mentre nel caso di seccomp ci viene passata una struttura di 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 (ne parleremo più avanti), args — fino a sei argomenti della chiamata di sistema, e instruction_pointer — è un puntatore all'istruzione nello spazio utente che ha effettuato questa chiamata di sistema. Pertanto, ad esempio, per caricare il numero della chiamata di sistema nel registro A dobbiamo dire
ldw [0]Per i programmi seccomp ci sono anche altre peculiarità, ad esempio, l'accesso al contesto è possibile solo con allineamento a 32 bit e non è possibile caricare mezze parole o byte — nel tentativo di caricare il filtro ldh [0] chiamata di sistema seccomp restituirà EINVAL. Il controllo dei filtri caricati è effettuato dalla funzione caratteristiche. (In modo ironico, nel commit originale che aggiunge la funzionalità seccomp, è stata dimenticata l'autorizzazione all'uso dell'istruzione mod (modulo) e ora non è disponibile per i programmi seccomp BPF, poiché la sua aggiunta ABI.)
Fondamentalmente, sappiamo già tutto ciò che serve per scrivere e leggere programmi seccomp. Di solito, la logica del programma è strutturata 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 #0verifica la lista nera di quattro chiamate di sistema con i numeri 304, 176, 239, 279. 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 inizia tutte le applicazioni con il controllo dell'architettura (l'architettura attuale è indicata nel contesto come campo arch della struttura struct seccomp_data). Con il controllo dell'architettura, l'inizio dell'esempio apparirebbe così:
ld [4]
jne #0xc000003e, bad_arch ; SCMP_ARCH_X86_64e quindi i nostri numeri delle chiamate di sistema ricevono valori specifici.
Scriviamo e carichiamo filtri per seccomp utilizzando libseccomp
Scrivere filtri in codice macchina o per assembler BPF consente di avere il pieno controllo sul risultato, ma a volte è preferibile avere un codice portabile e/o leggibile. A questo ci aiuterà la libreria , che fornisce un'interfaccia standard per la scrittura di filtri neri o bianchi.
Prendiamo ad esempio il programma che esegue un file binario a scelta dell'utente, stabilendo, in precedenza, una lista nera di chiamate di sistema da (il programma è semplificato per maggiore leggibilità, la versione completa può essere trovata ):
#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]);
}Prima di tutto definiamo un array sys_numbers di oltre 40 numeri di chiamate di sistema da bloccare. Poi, inizializziamo il contesto ctx e diciamo alla libreria che vogliamo consentire (SCMP_ACT_ALLOW) tutte le chiamate di sistema per default (creare liste nere è più semplice). Poi, una dopo l'altra, 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à un segnale al processo SIGSYS con una descrizione del sistema di chiamata che ha violato le regole. Infine, carichiamo il programma nel kernel tramite seccomp_load, che compilerà il programma e lo collegherà al processo tramite la chiamata di sistema seccomp(2).
Per una compilazione riuscita, è necessario collegare il programma con la libreria libseccomp, ad esempio:
cc -std=c17 -Wall -Wextra -c -o seccomp_lib.o seccomp_lib.c
cc -o seccomp_lib seccomp_lib.o -lseccompEsempio di esecuzione riuscita:
$ ./seccomp_lib echo ok
okEsempio di chiamata di sistema bloccata:
$ sudo ./seccomp_lib mount -t bpf bpf /tmp
Chiamata di sistema non validaUtilizziamo strace, per scoprire i 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 dump) +++
Chiamata di sistema non validada cui possiamo apprendere che il programma è stato terminato a causa di una chiamata di sistema non autorizzata mount(2).
In conclusione, abbiamo scritto un filtro usando 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, è stato recentemente , aggiungendo il supporto per l'attributo filtro SCMP_FLTATR_CTL_OPTIMIZE. Se si imposta questo attributo su 2, il filtro verrà trasformato in un programma di ricerca binaria.
Se desideri vedere come sono strutturati i filtri con ricerca binaria, dai un'occhiata a , che genera tali programmi in assemblatore BPF in base a un insieme di numeri di chiamata 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 #0Niente di sostanzialmente più veloce può essere scritto, poiché i programmi BPF non possono effettuare salti condizionali (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 l'analisi del comportamento dei processi su Linux. Tuttavia, molti sono anche a conoscenza dei associati all'uso di questo strumento. Il punto è che strace è implementato tramite ptrace(2), e in questo meccanismo non possiamo specificare su quale insieme di chiamate di sistema dobbiamo fermare il processo, cioè, ad esempio, i comandi
$ time strace du /usr/share/ >/dev/null 2>&1
real 0m3.081s
user 0m0.531s
sys 0m2.073se
$ time strace -e open du /usr/share/ >/dev/null 2>&1
real 0m2.404s
user 0m0.193s
sys 0m1.800ssi comportano in circa lo stesso tempo, anche se nel secondo caso vogliamo tracciare solo una chiamata di sistema.
Nuova opzione --seccomp-bpf, introdotto in strace versione 5.3, consente di accelerare notevolmente il processo e il tempo di avvio sotto tracciamento di una singola chiamata di sistema è ora paragonabile al tempo 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, ovviamente, 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 si fermerebbe così come 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 trasferito strace, che esamina gli argomenti della chiamata di sistema e la esegue tramite PTRACE_SYSCALL. Dopo un po', il processo completa la chiamata di sistema e al termine il controllo viene nuovamente trasferito strace, che esamina i valori restituiti e avvia il processo tramite PTRACE_SYSCALL, e così via.

Utilizzando seccomp, tuttavia, questo processo può essere ottimizzato proprio come desideriamo. Infatti, se desideriamo monitorare solo le chiamate 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 #0x7fff0000In questo caso strace il processo viene avviato inizialmente come PTRACE_CONT, ad 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é in seccomp non è possibile avviare un programma al termine della chiamata di sistema). Quando la chiamata di sistema ritorna, strace riavvierà il processo utilizzando PTRACE_CONT e attenderà nuovi messaggi da seccomp.

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 è supportata da seccomp. In secondo luogo, non c'è modo di non monitorare i processi figli, poiché i filtri seccomp sono ereditati da tutti i processi figli senza possibilità di disattivarli.
Un po' più di dettagli su come esattamente strace lavora con seccomp può essere appreso da . L'aspetto più interessante per noi è che il classico BPF, sotto forma di seccomp, è ancora in uso.
xt_bpf
Torniamo ora nel mondo delle reti.
Contesto: tanto tempo fa, nel 2007, nel kernel è stato modulo xt_u32 per netfilter. È stato scritto seguendo l'esempio di un perfino più antico classificatore di traffico cls_u32 e permetteva di scrivere regole binarie arbitrarie per iptables usando una serie di semplici operazioni: caricare 32 bit dal pacchetto e applicarvi 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 dell'intestazione IP, iniziando con un offset di 6, e applica una maschera 0xFF (prendere il byte meno significativo). Questo è il campo protocol dell'intestazione IP e lo confrontiamo con 1 (ICMP). In una regola si possono combinare molte verifiche 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"controlla se il TCP Sequence Number 0x29. Non entrerò oltre nei dettagli, poiché è già chiaro che scrivere a mano tali regole non è molto comodo. Nell'articolo , ci sono diversi link con esempi di utilizzo e generazione di regole per xt_u32. Vedi anche i link alla fine di questo articolo.
A partire dal 2013, il modulo invece del modulo xt_u32 può utilizzare un modulo basato su BPF xt_bpf. A tutti coloro che sono arrivati fin qui dovrebbe essere chiaro il principio di funzionamento: eseguire il bytecode BPF come regole iptables. È possibile creare una nuova regola in questo modo, ad esempio:
iptables -A INPUT -m bpf --bytecode -j LOGqui <байткод> — è il codice in formato di output dell'assemblatore bpf_asm per impostazione predefinita, 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 LOGIn questo esempio, filtriamo tutti i pacchetti UDP. Il contesto per il programma BPF nel modulo xt_bpf, ovviamente, punta ai dati del pacchetto, nel caso di iptables — all'inizio dell'intestazione IPv4. Il valore restituito dal programma BPF , dove false e indica che il pacchetto non corrisponde.
È chiaro che il modulo xt_bpf supporta filtri più complessi di quanto mostrato nell'esempio precedente. Diamo un'occhiata a esempi reali dalla Cloudflare. Fino a poco tempo fa, utilizzavano il modulo xt_bpf per la protezione contro attacchi DDoS. Nell'articolo spiegano come (e perché) generano filtri BPF e pubblicano collegamenti a un insieme di strumenti per creare tali filtri. Ad esempio, utilizzando lo strumento bpfgen è possibile creare un programma BPF che corrisponde a una richiesta DNS per 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 #0Nel programma carichiamo prima nel registro X l'indirizzo dell'inizio della stringa x04habrx03comx00 all'interno del datagramma UDP e poi controlliamo la richiesta: 0x04686162 "x04hab" ecc.
Poco dopo, Cloudfare ha pubblicato il codice del compilatore p0f -> BPF. Nella sua articolo parlano di cosa sia p0f e 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 lo utilizza più xt_bpf, poiché sono passati a XDP — una delle opzioni per utilizzare la nuova versione di BPF, vedi .
cls_bpf
L'ultimo esempio di utilizzo del classico BPF nel kernel è il classificatore cls_bpf per il sottosistema di controllo del traffico in Linux, aggiunto a Linux alla fine del 2013 e concettualmente ha sostituito l'antico cls_u32.
Tuttavia, non descriveremo il funzionamento adesso cls_bpf, poiché, per quanto riguarda la conoscenza del BPF classico, non ci fornisce nulla di utile: abbiamo già familiarizzato con tutta la funzionalità. Inoltre, nei prossimi articoli che parlano di Extended BPF, ci imbatteremo ripetutamente in questo classificatore.
Un ulteriore motivo per cui non parleremo dell'uso del BPF classico cls_bpf è che, rispetto all'Extended BPF, in questo caso si restringe drasticamente il campo di applicazione: i programmi classici non possono modificare il contenuto dei pacchetti e non possono mantenere lo stato tra le chiamate.
Quindi è giunto il momento di dire addio al BPF classico e dare un'occhiata al futuro.
Addio al classic BPF
Abbiamo esaminato come la tecnologia BPF, sviluppata all'inizio degli anni novanta, sia riuscita a sopravvivere per un quarto di secolo, trovando costantemente nuove applicazioni. Tuttavia, simile al passaggio dalle macchine stack alle RISC, che ha dato impulso allo 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 capacità del classico BPF sono fortemente limitate e, oltre a un'architettura superata, non abbiamo modo di mantenere lo stato tra le chiamate dei programmi BPF, non è possibile interagire direttamente con l'utente e non abbiamo la possibilità di interagire con il kernel, se non leggendo un numero limitato di campi della struttura. sk_buff e lanciare le più semplici funzioni ausiliarie, non è possibile modificare il contenuto dei pacchetti o reindirizzarli.
In realtà, attualmente dal classico BPF in Linux è rimasto solo l'interfaccia API, e all'interno del kernel tutti i programmi classici, che si tratti di filtri socket o filtri seccomp, vengono automaticamente tradotti in un nuovo formato, l'Extended BPF. (Spiegheremo come avviene esattamente questo processo nel prossimo articolo.)
La transizione verso una nuova architettura è iniziata nel 2013, quando Alexey Starovoitov ha proposto uno schema di aggiornamento del BPF. Nel 2014, i relativi patch nel kernel. Per quanto capisco, inizialmente si voleva solo ottimizzare l'architettura e il JIT-compiler per un funzionamento più efficiente sulle macchine a 64 bit, ma invece queste ottimizzazioni hanno dato inizio a un nuovo capitolo nello sviluppo di Linux.
Articoli successivi in questa serie parleranno dell'architettura e delle applicazioni della nuova tecnologia, inizialmente conosciuta come internal BPF, poi extended BPF, e ora semplicemente BPF.
Link
- 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 - Steven McCanne, "libpcap: An Architecture and Optimization Methodology for Packet Capture",
https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf tcpdump,libpcap:- .
- BPF — il bytecode dimenticato:
https://blog.cloudflare.com/bpf-the-forgotten-bytecode/ - Introduzione al BPF Tool:
https://blog.cloudflare.com/introducing-the-bpf-tools/ bpf_cls:http://man7.org/linux/man-pages/man8/tc-bpf.8.html- Un'introduzione a seccomp:
https://lwn.net/Articles/656307/ https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst- Paul Chaignon, "strace —seccomp-bpf: uno sguardo sotto il cofano"
https://fosdem.org/2020/schedule/event/debugging_strace_bpf/ netsniff-ng:http://netsniff-ng.org/
Fonte: habr.com
