BPF para os máis pequenos, parte cero: BPF clásico

Os filtros de paquetes de Berkeley (BPF) son unha tecnoloxía do núcleo de Linux que leva xa varios anos nas primeiras páxinas das publicacións tecnolóxicas en inglés. As conferencias están cheas de informes sobre o uso e desenvolvemento de BPF. David Miller, mantedor do subsistema de rede Linux, convoca a súa charla en Linux Plumbers 2018 "Esta charla non é sobre XDP" (XDP é un caso de uso para BPF). Brendan Gregg dá charlas tituladas Superpoderes de Linux BPF. Toke Høiland-Jørgensen risasque o núcleo é agora un micronúcleo. Thomas Graf promove a idea de que BPF é javascript para o núcleo.

Aínda non hai unha descrición sistemática de BPF en Habré, polo que nunha serie de artigos intentarei falar da historia da tecnoloxía, describir a arquitectura e ferramentas de desenvolvemento e esbozar as áreas de aplicación e práctica do uso de BPF. Este artigo, cero, da serie, conta a historia e a arquitectura do BPF clásico, e tamén revela os segredos dos seus principios de funcionamento. tcpdump, seccomp, strace, e moito máis.

O desenvolvemento de BPF está controlado pola comunidade de redes Linux, as principais aplicacións existentes de BPF están relacionadas coas redes e, polo tanto, con permiso @eucariota, chamei á serie “BPF para os máis pequenos”, en homenaxe á gran serie "Redes para os máis pequenos".

Un breve curso sobre a historia de BPF(c)

A tecnoloxía BPF moderna é unha versión mellorada e ampliada da antiga tecnoloxía co mesmo nome, agora chamada BPF clásica para evitar confusións. Creouse unha utilidade coñecida baseada no clásico BPF tcpdump, mecanismo seccomp, así como módulos menos coñecidos xt_bpf para iptables e clasificador cls_bpf. No Linux moderno, os programas BPF clásicos tradúcense automaticamente ao novo formulario, non obstante, desde o punto de vista do usuario, a API permaneceu no seu lugar e aínda se están atopando novos usos para o BPF clásico, como veremos neste artigo. Por este motivo, e tamén porque seguindo a historia do desenvolvemento do BPF clásico en Linux, quedará máis claro como e por que evolucionou cara á súa forma moderna, decidín comezar cun artigo sobre o BPF clásico.

A finais dos anos oitenta do século pasado, os enxeñeiros do famoso Laboratorio Lawrence Berkeley interesáronse pola cuestión de como filtrar correctamente os paquetes de rede nun hardware que era moderno a finais dos anos oitenta do século pasado. A idea básica do filtrado, implementada orixinalmente na tecnoloxía CSPF (CMU/Stanford Packet Filter), era filtrar os paquetes innecesarios o antes posible, é dicir. no espazo do núcleo, xa que isto evita copiar datos innecesarios no espazo do usuario. Para proporcionar seguridade en tempo de execución para executar código de usuario no espazo do núcleo, utilizouse unha máquina virtual con espazo de proba.

Non obstante, as máquinas virtuais dos filtros existentes foron deseñadas para funcionar en máquinas baseadas en pilas e non funcionaban de forma tan eficiente en máquinas RISC máis novas. Como resultado, grazas aos esforzos dos enxeñeiros de Berkeley Labs, desenvolveuse unha nova tecnoloxía BPF (Berkeley Packet Filters), cuxa arquitectura de máquina virtual foi deseñada baseándose no procesador Motorola 6502, o cabalo de batalla de produtos tan coñecidos como Apple II ou NES. A nova máquina virtual aumentou o rendemento do filtro decenas de veces en comparación coas solucións existentes.

Arquitectura de máquinas BPF

Familiarizarémonos coa arquitectura dun xeito de traballo, analizando exemplos. Non obstante, para comezar, digamos que a máquina tiña dous rexistros de 32 bits accesibles para o usuario, un acumulador A e rexistro índice X, 64 bytes de memoria (16 palabras), dispoñibles para a escritura e lectura posterior, e un pequeno sistema de comandos para traballar con estes obxectos. As instrucións de salto para implementar expresións condicionais tamén estaban dispoñibles nos programas, pero para garantir a finalización oportuna do programa, só se podían realizar saltos cara adiante, é dicir, en particular, estaba prohibido crear bucles.

O esquema xeral de arranque da máquina é o seguinte. O usuario crea un programa para a arquitectura BPF e, utilizando algunhas mecanismo do núcleo (como unha chamada ao sistema), carga e conecta o programa a algúns ao xerador de eventos no núcleo (por exemplo, un evento é a chegada do seguinte paquete á tarxeta de rede). Cando ocorre un evento, o núcleo executa o programa (por exemplo, nun intérprete) e a memoria da máquina corresponde a a algúns rexión da memoria do núcleo (por exemplo, os datos dun paquete entrante).

O anterior será suficiente para que empecemos a mirar exemplos: familiarizarémonos co sistema e co formato de comando segundo sexa necesario. Se queres estudar inmediatamente o sistema de mando dunha máquina virtual e coñecer todas as súas capacidades, podes ler o artigo orixinal O filtro de paquetes BSD e/ou a primeira metade do expediente Documentación/redes/filter.txt a partir da documentación do núcleo. Ademais, podes estudar a presentación libpcap: Unha Arquitectura e Metodoloxía de Optimización para a Captura de Paquetes, na que McCanne, un dos autores de BPF, fala da historia da creación libpcap.

Agora pasamos a considerar todos os exemplos significativos de uso de BPF clásico en Linux: tcpdump (libpcap), seccomp, xt_bpf, cls_bpf.

tcpdump

O desenvolvemento de BPF levouse a cabo en paralelo ao desenvolvemento do frontend para o filtrado de paquetes, unha utilidade coñecida. tcpdump. E, dado que este é o exemplo máis antigo e famoso de uso de BPF clásico, dispoñible en moitos sistemas operativos, comezaremos o noso estudo da tecnoloxía con el.

(Expreguei todos os exemplos deste artigo sobre Linux 5.6.0-rc6. A saída dalgúns comandos editouse para unha mellor lexibilidade.)

Exemplo: observación de paquetes IPv6

Imaxinemos que queremos ver todos os paquetes IPv6 nunha interface eth0. Para iso podemos executar o programa tcpdump cun simple filtro ip6:

$ sudo tcpdump -i eth0 ip6

Neste caso, tcpdump compila o filtro ip6 no bytecode da arquitectura BPF e envíao ao núcleo (ver detalles na sección Tcpdump: cargando). O filtro cargado executarase para cada paquete que pase pola interface eth0. Se o filtro devolve un valor distinto de cero n, despois ata n os bytes do paquete copiaranse no espazo do usuario e verémolo na saída tcpdump.

BPF para os máis pequenos, parte cero: BPF clásico

Resulta que podemos descubrir facilmente que bytecode se enviou ao núcleo tcpdump coa axuda do tcpdump, se o executamos coa opción -d:

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

Na liña cero executamos o comando ldh [12], que significa "cargar no rexistro A media palabra (16 bits) situada no enderezo 12” e a única pregunta é a que tipo de memoria nos estamos dirixindo? A resposta é que en x comeza (x+1)o byte do paquete de rede analizado. Lemos paquetes da interface Ethernet eth0e este mediosque o paquete se ve así (para simplificar, supoñemos que non hai etiquetas VLAN no paquete):

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

Entón, despois de executar o comando ldh [12] no rexistro A haberá un campo Ether Type — o tipo de paquete transmitido nesta trama Ethernet. Na liña 1 comparamos o contido do rexistro A (tipo de paquete) c 0x86dde este e ten O tipo que nos interesa é o IPv6. Na liña 1, ademais do comando de comparación, hai dúas columnas máis: jt 2 и jf 3 — marcas ás que cómpre ir se a comparación é exitosa (A == 0x86dd) e sen éxito. Entón, nun caso exitoso (IPv6) imos á liña 2, e nun caso non exitoso - á liña 3. Na liña 3 o programa remata co código 0 (non copie o paquete), na liña 2 o programa termina co código. 262144 (cópieme un paquete de 256 kilobytes como máximo).

Un exemplo máis complicado: miramos os paquetes TCP por porto de destino

Vexamos como é un filtro que copia todos os paquetes TCP co porto de destino 666. Consideraremos o caso IPv4, xa que o caso IPv6 é máis sinxelo. Despois de estudar este exemplo, pode explorar vostede mesmo o filtro IPv6 como exercicio (ip6 and tcp dst port 666) e un filtro para o caso xeral (tcp dst port 666). Polo tanto, o filtro que nos interesa é o seguinte:

$ 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

Xa sabemos o que fan as liñas 0 e 1. Na liña 2 xa comprobamos que se trata dun paquete IPv4 (Tipo Ether = 0x800) e cargalo no rexistro A 24 byte do paquete. O noso paquete parece

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

o que significa que cargamos no rexistro A o campo Protocolo da cabeceira IP, que é lóxico, porque queremos copiar só paquetes TCP. Comparamos o Protocolo con 0x6 (IPPROTO_TCP) na liña 3.

Nas liñas 4 e 5 cargamos as medias palabras situadas no enderezo 20 e utilizamos o comando jset comprobar se un dos tres está configurado bandeiras - levar a máscara expedida jset límpranse os tres bits máis significativos. Dous dos tres bits dinnos se o paquete forma parte dun paquete IP fragmentado e, se é así, se é o último fragmento. O terceiro bit está reservado e debe ser cero. Non queremos comprobar os paquetes incompletos ou rotos, polo que comprobamos os tres bits.

A liña 6 é a máis interesante deste listado. Expresión ldxb 4*([14]&0xf) significa que cargamos no rexistro X os catro bits menos significativos do décimo quinto byte do paquete multiplicados por 4. Os catro bits menos significativos do décimo quinto byte é o campo Lonxitude da cabeceira de Internet Cabeceira IPv4, que almacena a lonxitude da cabeceira en palabras, polo que debes multiplicar por 4. Curiosamente, a expresión 4*([14]&0xf) é unha designación para un réxime especial de enderezos que só se pode utilizar neste formulario e só para un rexistro X, é dicir. tampouco podemos dicir ldb 4*([14]&0xf) nin ldxb 5*([14]&0xf) (só podemos especificar unha compensación diferente, por exemplo, ldxb 4*([16]&0xf)). Está claro que este esquema de direccionamento engadiuse a BPF precisamente para recibir X (rexistro de índice) lonxitude da cabeceira IPv4.

Entón, na liña 7 intentamos cargar media palabra en (X+16). Lembrando que 14 bytes están ocupados pola cabeceira Ethernet, e X contén a lonxitude da cabeceira IPv4, entendemos que en A O porto de destino TCP está cargado:

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

Finalmente, na liña 8 comparamos o porto de destino co valor desexado e nas liñas 9 ou 10 devolvemos o resultado, se copiar o paquete ou non.

Tcpdump: cargando

Nos exemplos anteriores, especificamente non nos detimos en detalle sobre como cargamos o bytecode BPF no núcleo para filtrar paquetes. En xeral, tcpdump adaptado a moitos sistemas e para traballar con filtros tcpdump usa a biblioteca libpcap. Brevemente, para colocar un filtro nunha interface usando libpcap, ten que facer o seguinte:

Para ver como funciona pcap_setfilter implementado en Linux, usamos strace (elimináronse algunhas liñas):

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

Nas dúas primeiras liñas de saída creamos enchufe en bruto para ler todas as tramas Ethernet e vinculalas á interface eth0. De o noso primeiro exemplo sabemos que o filtro ip constará de catro instrucións BPF, e na terceira liña vemos como se usa a opción SO_ATTACH_FILTER chamada do sistema setsockopt cargamos e conectamos un filtro de lonxitude 4. Este é o noso filtro.

Cabe destacar que no BPF clásico, a carga e conexión dun filtro sempre ocorre como unha operación atómica, e na nova versión de BPF, a carga do programa e a vinculación ao xerador de eventos están separados no tempo.

Verdade oculta

Unha versión un pouco máis completa da saída ten o seguinte aspecto:

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

Como se mencionou anteriormente, cargamos e conectamos o noso filtro á toma da liña 5, pero que pasa nas liñas 3 e 4? Resulta que isto libpcap coida de nós - para que a saída do noso filtro non inclúa paquetes que non o satisfagan, a biblioteca conecta filtro ficticio ret #0 (eliminar todos os paquetes), cambia o socket ao modo sen bloqueo e tenta restar todos os paquetes que puidesen quedar dos filtros anteriores.

En total, para filtrar paquetes en Linux usando BPF clásico, cómpre ter un filtro en forma de estrutura como struct sock_fprog e un enchufe aberto, despois do cal o filtro pódese conectar ao socket mediante unha chamada ao sistema setsockopt.

Curiosamente, o filtro pódese conectar a calquera toma, non só en bruto. Aquí exemplo un programa que corta todos menos os dous primeiros bytes de todos os datagramas UDP entrantes. (Engadín comentarios no código para non desordenar o artigo).

Máis detalles sobre o uso setsockopt para conectar filtros, ver enchufe (7), pero sobre escribir os teus propios filtros como struct sock_fprog sen axuda tcpdump falaremos na sección Programando BPF coas nosas propias mans.

BPF clásico e século XXI

BPF incluíuse en Linux en 1997 e foi un cabalo de batalla durante moito tempo libpcap sen ningún cambio especial (cambios específicos de Linux, por suposto, foi, pero non cambiaron a imaxe global). Os primeiros signos serios de que BPF evolucionaría chegaron en 2011, cando Eric Dumazet propuxo parche, que engade Just In Time Compiler ao núcleo - un tradutor para converter o bytecode BPF a nativo x86_64 código.

O compilador JIT foi o primeiro da cadea de cambios: en 2012 apareceu capacidade de escribir filtros para seccomp, utilizando BPF, en xaneiro de 2013 houbo engadido módulo xt_bpf, que che permite escribir regras para iptables coa axuda de BPF, e en outubro de 2013 foi engadido tamén un módulo cls_bpf, que permite escribir clasificadores de tráfico usando BPF.

Pronto veremos todos estes exemplos con máis detalle, pero antes será útil aprender a escribir e compilar programas arbitrarios para BPF, xa que as capacidades que ofrece a biblioteca libpcap limitado (exemplo sinxelo: filtro xerado libpcap pode devolver só dous valores - 0 ou 0x40000) ou xeralmente, como no caso de seccomp, non son aplicables.

Programando BPF coas nosas propias mans

Coñecemos o formato binario das instrucións BPF, é moi sinxelo:

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

Cada instrución ocupa 64 bits, nos que os primeiros 16 bits son o código da instrución, despois hai dúas sangrías de oito bits, jt и jf, e 32 bits para o argumento K, cuxo propósito varía dun comando a outro. Por exemplo, o comando ret, que remata o programa ten o código 6, e o valor de retorno tómase da constante K. En C, unha única instrución BPF represéntase como unha estrutura

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

e todo o programa ten forma de estrutura

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

Así, xa podemos escribir programas (por exemplo, coñecemos os códigos de instrucións de [1]). Así será o filtro ip6 de o noso primeiro exemplo:

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

programa prog podemos usar legalmente nunha chamada

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

Escribir programas en forma de códigos de máquina non é moi cómodo, pero ás veces é necesario (por exemplo, para depurar, crear probas unitarias, escribir artigos sobre Habré, etc.). Por comodidade, no arquivo <linux/filter.h> defínense macros auxiliares: o mesmo exemplo anterior poderíase reescribir como

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

Non obstante, esta opción non é moi conveniente. Isto é o que razoaron os programadores do núcleo de Linux, e polo tanto no directorio tools/bpf kernels podes atopar un ensamblador e un depurador para traballar con BPF clásico.

A linguaxe ensambladora é moi semellante á saída de depuración tcpdump, pero ademais podemos especificar etiquetas simbólicas. Por exemplo, aquí tes un programa que elimina todos os paquetes excepto TCP/IPv4:

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

Por defecto, o ensamblador xera código no formato <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., polo noso exemplo con TCP será

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

Para a comodidade dos programadores C, pódese usar un formato de saída diferente:

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

Este texto pódese copiar na definición da estrutura do tipo struct sock_filter, como fixemos ao comezo desta sección.

Extensións de Linux e netsniff-ng

Ademais do estándar BPF, Linux e tools/bpf/bpf_asm apoio e conxunto non estándar. Basicamente, utilízanse instrucións para acceder aos campos dunha estrutura struct sk_buff, que describe un paquete de rede no núcleo. Non obstante, tamén hai outro tipo de instrucións de axuda, por exemplo ldw cpu cargarase no rexistro A resultado de executar unha función do núcleo raw_smp_processor_id(). (Na nova versión de BPF, estas extensións non estándar estendéronse para proporcionar aos programas un conxunto de axudantes do núcleo para acceder á memoria, estruturas e xerar eventos.) Aquí tes un exemplo interesante de filtro no que copiamos só o cabeceiras de paquetes no espazo do usuario usando a extensión poff, compensación de carga útil:

ld poff
ret a

Non se poden usar extensións BPF tcpdump, pero esta é unha boa razón para familiarizarse co paquete de utilidade netsniff-ng, que, entre outras cousas, contén un programa avanzado netsniff-ng, que ademais de filtrar mediante BPF, tamén contén un xerador de tráfico eficaz, e máis avanzado que tools/bpf/bpf_asm, un ensamblador BPF chamado bpfc. O paquete contén documentación bastante detallada, consulte tamén as ligazóns ao final do artigo.

seccomp

Entón, xa sabemos escribir programas BPF de complexidade arbitraria e estamos preparados para mirar novos exemplos, o primeiro dos cales é a tecnoloxía seccomp, que permite, mediante filtros BPF, xestionar o conxunto e conxunto de argumentos de chamada de sistema dispoñibles para un proceso determinado e os seus descendentes.

A primeira versión de seccomp engadiuse ao núcleo en 2005 e non era moi popular, xa que só proporcionaba unha única opción: limitar o conxunto de chamadas ao sistema dispoñibles para un proceso ao seguinte: read, write, exit и sigreturn, e o proceso que violou as regras foi eliminado usando SIGKILL. Non obstante, en 2012, seccomp engadiu a posibilidade de usar filtros BPF, o que lle permite definir un conxunto de chamadas de sistema permitidas e mesmo realizar comprobacións dos seus argumentos. (Curiosamente, Chrome foi un dos primeiros usuarios desta funcionalidade, e a xente de Chrome está a desenvolver actualmente un mecanismo KRSI baseado nunha nova versión de BPF e que permite a personalización dos módulos de seguridade de Linux.) Pódense atopar ligazóns a documentación adicional ao final. do artigo.

Teña en conta que xa houbo artigos no hub sobre o uso de seccomp, quizais alguén queira lelos antes (ou en vez de) ler as seguintes subseccións. No artigo Contenedores e seguridade: seccomp ofrece exemplos de uso de seccomp, tanto a versión de 2007 como a que usa BPF (os filtros xéranse mediante libseccomp), fala da conexión de seccomp con Docker e tamén ofrece moitas ligazóns útiles. No artigo Illando demonios con systemd ou "non necesitas Docker para isto!" Abarca, en particular, como engadir listas negras ou listas brancas de chamadas ao sistema para os daemons que executan systemd.

A continuación veremos como escribir e cargar filtros para seccomp en C simple e usando a biblioteca libseccomp e cales son os pros e os contras de cada opción e, por último, vexamos como usa seccomp o programa strace.

Escribir e cargar filtros para seccomp

Xa sabemos escribir programas BPF, así que vexamos primeiro a interface de programación seccomp. Podes establecer un filtro a nivel de proceso e todos os procesos fillos herdarán as restricións. Isto faise mediante unha chamada ao sistema seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

onde &filter - este é un punteiro a unha estrutura que xa nos coñecemos struct sock_fprog, é dicir. Programa BPF.

En que se diferencian os programas para seccomp dos programas para sockets? Contexto transmitido. No caso dos sockets, déronnos unha área de memoria que contén o paquete, e no caso de seccomp déronnos unha estrutura como

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

Aquí nr é o número da chamada ao sistema que se vai lanzar, arch - arquitectura actual (máis sobre isto a continuación), args - ata seis argumentos de chamada de sistema, e instruction_pointer é un punteiro á instrución do espazo de usuario que fixo a chamada do sistema. Así, por exemplo, para cargar o número de chamada do sistema no rexistro A temos que dicir

ldw [0]

Hai outras funcións para os programas seccomp, por exemplo, só se pode acceder ao contexto mediante un aliñamento de 32 bits e non se pode cargar media palabra ou un byte cando se tenta cargar un filtro. ldh [0] chamada do sistema seccomp volverá EINVAL. A función comproba os filtros cargados seccomp_check_filter() núcleos. (O curioso é que no commit orixinal que engadiu a funcionalidade seccomp, esquecéronse de engadir permiso para usar a instrución para esta función mod (resto de división) e agora non está dispoñible para os programas seccomp BPF, desde a súa adición romperá ABI.)

Basicamente, xa sabemos todo para escribir e ler programas seccomp. Normalmente a lóxica do programa está disposta como unha lista branca ou negra de chamadas ao sistema, por exemplo o programa

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

comproba unha lista negra de catro chamadas de sistema numeradas 304, 176, 239, 279. Cales son estas chamadas de sistema? Non podemos dicir con certeza, xa que non sabemos para que arquitectura foi escrito o programa. Polo tanto, os autores de seccomp oferta iniciar todos os programas cunha comprobación de arquitectura (a arquitectura actual indícase no contexto como un campo arch estruturas struct seccomp_data). Coa arquitectura marcada, o comezo do exemplo sería así:

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

e entón os números de chamada do noso sistema obterían certos valores.

Escribimos e cargamos filtros para seccomp usando libseccomp

Escribir filtros en código nativo ou en ensamblaxe BPF permíteche ter un control total sobre o resultado, pero ao mesmo tempo, ás veces é preferible ter código portátil e/ou lexible. A biblioteca axudaranos nisto libseccomp, que proporciona unha interface estándar para escribir filtros en branco ou negro.

Escribamos, por exemplo, un programa que execute un ficheiro binario que escolle o usuario, instalando previamente unha lista negra de chamadas ao sistema de o artigo anterior (o programa simplificouse para unha maior lexibilidade, pódese atopar a versión completa aquí):

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

Primeiro definimos unha matriz sys_numbers de máis de 40 números de chamada do sistema para bloquear. Despois, inicializa o contexto ctx e dille á biblioteca o que queremos permitir (SCMP_ACT_ALLOW) todas as chamadas do sistema por defecto (é máis fácil crear listas negras). Despois, unha a unha, engadimos todas as chamadas do sistema da lista negra. En resposta a unha chamada do sistema da lista, solicitamos SCMP_ACT_TRAP, neste caso seccomp enviará un sinal ao proceso SIGSYS cunha descrición de que chamada ao sistema infrinxiu as regras. Finalmente, cargamos o programa no núcleo usando seccomp_load, que compilará o programa e o anexará ao proceso mediante unha chamada ao sistema seccomp(2).

Para unha compilación exitosa, o programa debe estar ligado á biblioteca libseccomp, por exemplo:

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

Exemplo de lanzamento exitoso:

$ ./seccomp_lib echo ok
ok

Exemplo de chamada ao sistema bloqueada:

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

Usamos stracepara máis detalles:

$ 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

como podemos saber que o programa foi finalizado debido ao uso dunha chamada ilegal ao sistema mount(2).

Entón, escribimos un filtro usando a biblioteca libseccomp, encaixando código non trivial en catro liñas. No exemplo anterior, se hai un gran número de chamadas ao sistema, o tempo de execución pódese reducir notablemente, xa que a comprobación é só unha lista de comparacións. Para optimización, libseccomp tivo recentemente parche incluído, que engade soporte para o atributo de filtro SCMP_FLTATR_CTL_OPTIMIZE. Establecendo este atributo en 2 converterase o filtro nun programa de busca binario.

Se queres ver como funcionan os filtros de busca binarios, bótalle unha ollada guión sinxelo, que xera tales programas no ensamblador BPF marcando os números de chamada do sistema, por exemplo:

$ 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

É imposible escribir algo significativamente máis rápido, xa que os programas BPF non poden realizar saltos de sangría (non podemos facer, por exemplo, jmp A ou jmp [label+X]) e polo tanto todas as transicións son estáticas.

seccomp e strace

Todo o mundo coñece a utilidade strace é unha ferramenta indispensable para estudar o comportamento dos procesos en Linux. Non obstante, moitos tamén oíron falar problemas de rendemento ao usar esta utilidade. O feito é que strace implementado utilizando ptrace(2), e neste mecanismo non podemos especificar en que conxunto de chamadas de sistema necesitamos parar o proceso, é dicir, por exemplo, comandos

$ 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

procesan aproximadamente no mesmo tempo, aínda que no segundo caso queremos rastrexar só unha chamada ao sistema.

Nova opción --seccomp-bpf, engadido a strace versión 5.3, permítelle acelerar o proceso moitas veces e o tempo de inicio baixo o rastro dunha chamada do sistema xa é comparable ao tempo dun inicio normal:

$ 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

(Aquí, por suposto, hai un pequeno engano en que non estamos rastrexando a chamada principal do sistema deste comando. Se estiveramos rastrexando, por exemplo, newfsstat, Entón strace frearía tan forte coma sen --seccomp-bpf.)

Como funciona esta opción? Sen ela strace conéctase ao proceso e comeza a usar PTRACE_SYSCALL. Cando un proceso xestionado emite unha (calquera) chamada ao sistema, o control transfírese a strace, que analiza os argumentos da chamada ao sistema e execútaa usando PTRACE_SYSCALL. Despois dun tempo, o proceso completa a chamada do sistema e ao saír dela, o control transfírese de novo strace, que mira os valores de retorno e inicia o proceso usando PTRACE_SYSCALL, etcétera.

BPF para os máis pequenos, parte cero: BPF clásico

Con seccomp, con todo, este proceso pódese optimizar exactamente como desexamos. É dicir, se queremos mirar só a chamada do sistema X, entón podemos escribir un filtro BPF para iso X devolve valor SECCOMP_RET_TRACE, e para chamadas que non nos interesen - SECCOMP_RET_ALLOW:

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

Neste caso strace inicialmente comeza o proceso como PTRACE_CONT, o noso filtro procédese para cada chamada ao sistema, se a chamada ao sistema non o é X, entón o proceso continúa a executarse, pero se isto X, entón seccomp transferirá o control straceque mirará os argumentos e iniciará o proceso como PTRACE_SYSCALL (xa que seccomp non ten a capacidade de executar un programa ao saír dunha chamada ao sistema). Cando volve a chamada do sistema, strace reiniciará o proceso usando PTRACE_CONT e esperará novas mensaxes de seccomp.

BPF para os máis pequenos, parte cero: BPF clásico

Cando se utiliza a opción --seccomp-bpf hai dúas restricións. En primeiro lugar, non será posible unirse a un proceso xa existente (opción -p programas strace), xa que non é compatible con seccomp. En segundo lugar, non hai posibilidade non mira os procesos fillos, xa que os filtros seccomp son herdados por todos os procesos fillos sen a posibilidade de desactivalo.

Un pouco máis de detalles sobre como exactamente strace traballa con seccomp pódese atopar desde informe recente. Para nós, o feito máis interesante é que o clásico BPF representado por seccomp aínda se usa na actualidade.

xt_bpf

Volvamos agora ao mundo das redes.

Antecedentes: hai moito tempo, en 2007, o núcleo estaba engadido módulo xt_u32 para netfilter. Foi escrito por analoxía cun clasificador de tráfico aínda máis antigo cls_u32 e permitiuche escribir regras binarias arbitrarias para iptables mediante as seguintes operacións sinxelas: cargar 32 bits dun paquete e realizar un conxunto de operacións aritméticas sobre eles. Por exemplo,

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

Carga os 32 bits da cabeceira IP, comezando polo recheo 6, e aplícalles unha máscara 0xFF (tomar o byte baixo). Este campo protocol Cabeceira IP e comparámola con 1 (ICMP). Podes combinar moitas comprobacións nunha soa regra e tamén podes executar o operador @ — move X bytes cara á dereita. Por exemplo, a regra

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

comproba se o número de secuencia TCP non é igual 0x29. Non entrarei máis en detalles, xa que xa está claro que escribir esas regras a man non é moi conveniente. No artigo BPF - o bytecode esquecido, hai varias ligazóns con exemplos de uso e xeración de regras para xt_u32. Vexa tamén as ligazóns ao final deste artigo.

Desde 2013 módulo en lugar de módulo xt_u32 pode usar un módulo baseado en BPF xt_bpf. Calquera persoa que lera ata aquí xa debería ter claro o principio do seu funcionamento: executar o bytecode BPF como regras de iptables. Podes crear unha nova regra, por exemplo, como esta:

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

aquí <байткод> - este é o código en formato de saída do ensamblador bpf_asm por defecto, por exemplo,

$ 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

Neste exemplo estamos filtrando todos os paquetes UDP. Contexto para un programa BPF nun módulo xt_bpf, por suposto, apunta aos paquetes de datos, no caso de iptables, ao comezo da cabeceira IPv4. Valor de retorno do programa BPF booleanoonde false significa que o paquete non coincidía.

Está claro que o módulo xt_bpf admite filtros máis complexos que o exemplo anterior. Vexamos exemplos reais de Cloudfare. Ata hai pouco usaban o módulo xt_bpf para protexerse contra ataques DDoS. No artigo Presentación das ferramentas BPF explican como (e por que) xeran filtros BPF e publican ligazóns a un conxunto de utilidades para crear tales filtros. Por exemplo, usando a utilidade bpfgen pode crear un programa BPF que coincida cunha consulta DNS para un 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

No programa primeiro cargamos no rexistro X enderezo de inicio da liña x04habrx03comx00 dentro dun datagrama UDP e despois verifique a solicitude: 0x04686162 <-> "x04hab" etc

Un pouco máis tarde, Cloudfare publicou o código do compilador p0f -> BPF. No artigo Presentación do compilador p0f BPF falan sobre o que é p0f e como converter sinaturas p0f en 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,
...

Actualmente xa non se usa Cloudfare xt_bpf, xa que pasaron a XDP - unha das opcións para usar a nova versión de BPF, véxase. L4Drop: XDP DDoS Mitigacións.

cls_bpf

O último exemplo de uso de BPF clásico no núcleo é o clasificador cls_bpf para o subsistema de control de tráfico en Linux, engadido a Linux a finais de 2013 e que substituíu conceptualmente o antigo cls_u32.

Non obstante, agora non imos describir o traballo cls_bpf, xa que desde o punto de vista do coñecemento sobre BPF clásico isto non nos dará nada - xa nos familiarizamos con toda a funcionalidade. Ademais, en artigos posteriores que falen sobre BPF estendido, atoparémonos con este clasificador máis dunha vez.

Outra razón para non falar de usar o BPF clásico c cls_bpf O problema é que, en comparación co BPF estendido, o ámbito de aplicabilidade neste caso é radicalmente reducido: os programas clásicos non poden cambiar o contido dos paquetes e non poden gardar o estado entre chamadas.

Así que é hora de despedir o clásico BPF e mirar cara ao futuro.

Adeus ao clásico BPF

Observamos como a tecnoloxía BPF, desenvolvida a principios dos noventa, viviu con éxito durante un cuarto de século e ata o final atopou novas aplicacións. Non obstante, de xeito similar á transición das máquinas de pila a RISC, que serviu como impulso para o desenvolvemento do BPF clásico, na década de 32 houbo unha transición de máquinas de 64 bits a XNUMX bits e o BPF clásico comezou a quedar obsoleto. Ademais, as capacidades do BPF clásico son moi limitadas e, ademais da arquitectura obsoleta, non temos a capacidade de gardar o estado entre chamadas a programas BPF, non hai posibilidade de interacción directa do usuario, non hai posibilidade de interactuar co núcleo, excepto para ler un número limitado de campos de estrutura sk_buff e ao lanzar as funcións auxiliares máis sinxelas, non pode cambiar o contido dos paquetes e redirixilos.

De feito, actualmente o único que queda do BPF clásico en Linux é a interface da API, e dentro do núcleo todos os programas clásicos, xa sexan filtros de socket ou filtros seccomp, tradúcense automaticamente a un novo formato, Extended BPF. (Falaremos exactamente como isto ocorre no seguinte artigo).

A transición a unha nova arquitectura comezou en 2013, cando Alexey Starovoitov propuxo un esquema de actualización de BPF. En 2014 os parches correspondentes comezou a aparecer no núcleo. Polo que entendo, o plan inicial era só optimizar a arquitectura e o compilador JIT para executar de forma máis eficiente en máquinas de 64 bits, pero en cambio estas optimizacións marcaron o inicio dun novo capítulo no desenvolvemento de Linux.

Outros artigos desta serie tratarán a arquitectura e as aplicacións da nova tecnoloxía, coñecida inicialmente como BPF interna, logo BPF estendida e agora simplemente BPF.

referencias

  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 de coincidencia de IPtable U32.
  5. BPF - o bytecode esquecido: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Presentación da ferramenta BPF: https://blog.cloudflare.com/introducing-the-bpf-tools/
  7. bpf_cls: http://man7.org/linux/man-pages/man8/tc-bpf.8.html
  8. Unha visión xeral de seccomp: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Contenedores e seguridade: seccomp
  11. habr: Illando demonios con systemd ou "non necesitas Docker para iso!"
  12. Paul Chaignon, "strace --seccomp-bpf: unha mirada baixo o capó", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Fonte: www.habr.com

Engadir un comentario