BPF para os mais pequenos, parte zero: BPF clássico

Berkeley Packet Filters (BPF) é uma tecnologia de kernel Linux que está nas primeiras páginas de publicações de tecnologia em inglês há vários anos. As conferências estão repletas de relatórios sobre o uso e desenvolvimento do BPF. David Miller, mantenedor do subsistema de rede Linux, dá sua palestra no Linux Plumbers 2018 “Esta conversa não é sobre XDP” (XDP é um caso de uso para BPF). Brendan Gregg dá palestras intituladas Superpoderes do Linux BPF. Toke Høiland-Jørgensen risosque o kernel agora é um microkernel. Thomas Graf promove a ideia de que BPF é javascript para o kernel.

Ainda não existe uma descrição sistemática do BPF no Habré e, portanto, em uma série de artigos tentarei falar sobre a história da tecnologia, descrever a arquitetura e as ferramentas de desenvolvimento e delinear as áreas de aplicação e prática do uso do BPF. Este artigo, zero, da série, conta a história e a arquitetura do BPF clássico, e também revela os segredos de seus princípios de funcionamento. tcpdump, seccomp, strace, e muito mais.

O desenvolvimento do BPF é controlado pela comunidade de redes Linux, as principais aplicações existentes do BPF estão relacionadas a redes e portanto, com permissão @eucariotas, chamei a série de “BPF para os pequenos”, em homenagem à grande série “Redes para os mais pequenos”.

Um breve curso sobre a história do BPF(c)

A tecnologia BPF moderna é uma versão melhorada e ampliada da tecnologia antiga com o mesmo nome, agora chamada de BPF clássico para evitar confusão. Um conhecido utilitário foi criado baseado no clássico BPF tcpdump, mecanismo seccomp, bem como módulos menos conhecidos xt_bpf para iptables e classificador cls_bpf. No Linux moderno, os programas BPF clássicos são automaticamente traduzidos para o novo formato, porém, do ponto de vista do usuário, a API permaneceu em vigor e novos usos para o BPF clássico, como veremos neste artigo, ainda estão sendo encontrados. Por esta razão, e também porque acompanhando a história do desenvolvimento do BPF clássico no Linux, ficará mais claro como e por que ele evoluiu para sua forma moderna, decidi começar com um artigo sobre o BPF clássico.

No final da década de oitenta do século passado, engenheiros do famoso Laboratório Lawrence Berkeley se interessaram pela questão de como filtrar adequadamente os pacotes de rede em hardware que era moderno no final dos anos oitenta do século passado. A ideia básica de filtragem, originalmente implementada na tecnologia CSPF (CMU/Stanford Packet Filter), era filtrar pacotes desnecessários o mais cedo possível, ou seja, no espaço do kernel, pois evita a cópia de dados desnecessários no espaço do usuário. Para fornecer segurança de tempo de execução para a execução do código do usuário no espaço do kernel, foi usada uma máquina virtual em área restrita.

No entanto, as máquinas virtuais dos filtros existentes foram projetadas para serem executadas em máquinas baseadas em pilha e não funcionaram com tanta eficiência em máquinas RISC mais recentes. Como resultado, através dos esforços de engenheiros do Berkeley Labs, foi desenvolvida uma nova tecnologia BPF (Berkeley Packet Filters), cuja arquitetura de máquina virtual foi projetada com base no processador Motorola 6502 - o carro-chefe de produtos tão conhecidos como Apple II ou NES. A nova máquina virtual aumentou o desempenho do filtro dezenas de vezes em comparação com as soluções existentes.

Arquitetura da máquina BPF

Conheceremos a arquitetura de forma prática, analisando exemplos. Porém, para começar, digamos que a máquina tivesse dois registradores de 32 bits acessíveis ao usuário, um acumulador A e registro de índice X, 64 bytes de memória (16 palavras), disponíveis para escrita e posterior leitura, e um pequeno sistema de comandos para trabalhar com esses objetos. Instruções de salto para implementação de expressões condicionais também estavam disponíveis nos programas, mas para garantir a conclusão oportuna do programa, os saltos só podiam ser feitos para frente, ou seja, em particular, era proibido criar loops.

O esquema geral para iniciar a máquina é o seguinte. O usuário cria um programa para a arquitetura BPF e, utilizando alguns mecanismo do kernel (como uma chamada de sistema), carrega e conecta o programa ao para alguns ao gerador de eventos no kernel (por exemplo, um evento é a chegada do próximo pacote na placa de rede). Quando ocorre um evento, o kernel executa o programa (por exemplo, em um interpretador) e a memória da máquina corresponde a para alguns região de memória do kernel (por exemplo, dados de um pacote recebido).

O que foi dito acima será suficiente para começarmos a olhar os exemplos: conheceremos o sistema e o formato dos comandos conforme necessário. Se você deseja estudar imediatamente o sistema de comando de uma máquina virtual e aprender sobre todos os seus recursos, leia o artigo original O filtro de pacotes BSD e/ou a primeira metade do arquivo Documentação/rede/filter.txt da documentação do kernel. Além disso, você pode estudar a apresentação libpcap: Uma metodologia de arquitetura e otimização para captura de pacotes, em que McCanne, um dos autores do BPF, fala sobre a história da criação libpcap.

Passamos agora a considerar todos os exemplos significativos do uso do BPF clássico no Linux: tcpdump (libpcap), secomp, xt_bpf, cls_bpf.

tcpdump

O desenvolvimento do BPF foi realizado paralelamente ao desenvolvimento do frontend para filtragem de pacotes - um conhecido utilitário tcpdump. E, como este é o exemplo mais antigo e famoso de utilização do BPF clássico, disponível em diversos sistemas operacionais, iniciaremos nosso estudo da tecnologia com ele.

(executei todos os exemplos deste artigo no Linux 5.6.0-rc6. A saída de alguns comandos foi editada para melhor legibilidade.)

Exemplo: observando pacotes IPv6

Vamos imaginar que queremos ver todos os pacotes IPv6 em uma interface eth0. Para fazer isso podemos executar o programa tcpdump com um filtro simples ip6:

$ sudo tcpdump -i eth0 ip6

Neste caso, tcpdump compila o filtro ip6 no bytecode da arquitetura BPF e enviá-lo ao kernel (veja detalhes na seção Tcpdump: carregando). O filtro carregado será executado para cada pacote que passar pela interface eth0. Se o filtro retornar um valor diferente de zero n, então até n bytes do pacote serão copiados para o espaço do usuário e veremos isso na saída tcpdump.

BPF para os mais pequenos, parte zero: BPF clássico

Acontece que podemos descobrir facilmente qual bytecode foi enviado ao kernel tcpdump com a ajuda do tcpdump, se executarmos com a opção -d:

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

Na linha zero, executamos o comando ldh [12], que significa “carregar no registro A meia palavra (16 bits) localizada no endereço 12” e a única questão é que tipo de memória estamos endereçando? A resposta é que em x começa (x+1)o byte do pacote de rede analisado. Lemos pacotes da interface Ethernet eth0e isso meiosque o pacote tenha esta aparência (para simplificar, assumimos que não há tags VLAN no pacote):

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

Então, depois de executar o comando ldh [12] no registro A haverá um campo Ether Type — o tipo de pacote transmitido neste quadro Ethernet. Na linha 1 comparamos o conteúdo do registro A (tipo de pacote) c 0x86dde isso e há O tipo que nos interessa é o IPv6. Na linha 1, além do comando de comparação, existem mais duas colunas - jt 2 и jf 3 — marcas que você precisa atingir se a comparação for bem-sucedida (A == 0x86dd) e sem sucesso. Assim, em caso de sucesso (IPv6) vamos para a linha 2, e em caso de fracasso - para a linha 3. Na linha 3 o programa termina com o código 0 (não copie o pacote), na linha 2 o programa termina com o código 262144 (copie-me um pacote máximo de 256 kilobytes).

Um exemplo mais complicado: analisamos os pacotes TCP por porta de destino

Vamos ver como fica um filtro que copia todos os pacotes TCP com porta de destino 666. Consideraremos o caso IPv4, já que o caso IPv6 é mais simples. Depois de estudar este exemplo, você mesmo pode explorar o filtro IPv6 como um exercício (ip6 and tcp dst port 666) e um filtro para o caso geral (tcp dst port 666). Então, o filtro que nos interessa é assim:

$ 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

Já sabemos o que as linhas 0 e 1 fazem. Na linha 2 já verificamos que se trata de um pacote IPv4 (Ether Type = 0x800) e carregue-o no registro A 24º byte do pacote. Nosso pacote parece

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

o que significa que carregamos no registro A o campo Protocolo do cabeçalho IP, que é lógico, pois queremos copiar apenas pacotes TCP. Comparamos Protocolo com 0x6 (IPPROTO_TCP) na linha 3.

Nas linhas 4 e 5 carregamos as meias palavras localizadas no endereço 20 e utilizamos o comando jset verifique se um dos três está definido bandeiras - usando a máscara emitida jset os três bits mais significativos são apagados. Dois dos três bits nos dizem se o pacote faz parte de um pacote IP fragmentado e, em caso afirmativo, se é o último fragmento. O terceiro bit é reservado e deve ser zero. Não queremos verificar pacotes incompletos ou quebrados, então verificamos todos os três bits.

A linha 6 é a mais interessante desta listagem. Expressão ldxb 4*([14]&0xf) significa que carregamos no registro X os quatro bits menos significativos do décimo quinto byte do pacote multiplicados por 4. Os quatro bits menos significativos do décimo quinto byte são o campo Comprimento do cabeçalho da Internet Cabeçalho IPv4, que armazena o comprimento do cabeçalho em palavras, então você precisa multiplicar por 4. Curiosamente, a expressão 4*([14]&0xf) é uma designação para um esquema de endereçamento especial que só pode ser usado nesta forma e apenas para um registro X, ou seja também não podemos dizer ldb 4*([14]&0xf) nem ldxb 5*([14]&0xf) (só podemos especificar um deslocamento diferente, por exemplo, ldxb 4*([16]&0xf)). É claro que este esquema de endereçamento foi adicionado ao BPF justamente para receber X (registro de índice) Comprimento do cabeçalho IPv4.

Então, na linha 7, tentamos carregar meia palavra em (X+16). Lembrando que 14 bytes são ocupados pelo cabeçalho Ethernet, e X contém o comprimento do cabeçalho IPv4, entendemos que em A A porta de destino TCP está carregada:

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

Por fim, na linha 8 comparamos a porta de destino com o valor desejado e nas linhas 9 ou 10 retornamos o resultado – se deve copiar o pacote ou não.

Tcpdump: carregando

Nos exemplos anteriores, especificamente não detalhamos exatamente como carregamos o bytecode BPF no kernel para filtragem de pacotes. De um modo geral, tcpdump portado para muitos sistemas e para trabalhar com filtros tcpdump usa a biblioteca libpcap. Resumidamente, para colocar um filtro em uma interface usando libpcap, você precisa fazer o seguinte:

Para ver como a função pcap_setfilter implementado em Linux, usamos strace (algumas linhas foram removidas):

$ 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 duas primeiras linhas de saída criamos soquete bruto para ler todos os quadros Ethernet e vinculá-los à interface eth0. De nosso primeiro exemplo sabemos que o filtro ip consistirá em quatro instruções BPF, e na terceira linha vemos como usar a opção SO_ATTACH_FILTER chamada de sistema setsockopt carregamos e conectamos um filtro de comprimento 4. Este é o nosso filtro.

Vale ressaltar que no BPF clássico o carregamento e a conexão de um filtro sempre ocorrem como uma operação atômica, e na nova versão do BPF o carregamento do programa e sua vinculação ao gerador de eventos são separados no tempo.

Verdade escondida

Uma versão um pouco mais completa da saída é assim:

$ 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 mencionado acima, carregamos e conectamos nosso filtro ao soquete da linha 5, mas o que acontece nas linhas 3 e 4? Acontece que isso libpcap cuida de nós - para que a saída do nosso filtro não inclua pacotes que não o satisfaçam, a biblioteca conecta filtro fictício ret #0 (descartar todos os pacotes), muda o soquete para o modo sem bloqueio e tenta subtrair todos os pacotes que possam permanecer dos filtros anteriores.

No total, para filtrar pacotes no Linux usando BPF clássico, você precisa ter um filtro na forma de uma estrutura como struct sock_fprog e um soquete aberto, após o qual o filtro pode ser conectado ao soquete usando uma chamada de sistema setsockopt.

Curiosamente, o filtro pode ser conectado a qualquer tomada, não apenas crua. Aqui exemplo um programa que corta todos os datagramas UDP recebidos, exceto os dois primeiros bytes. (Adicionei comentários no código para não sobrecarregar o artigo.)

Mais detalhes sobre o uso setsockopt para conectar filtros, consulte soquete (7), mas sobre escrever seus próprios filtros como struct sock_fprog sem ajuda tcpdump falaremos na seção Programando BPF com nossas próprias mãos.

BPF clássico e o século XNUMX

O BPF foi incluído no Linux em 1997 e permaneceu como um burro de carga por muito tempo libpcap sem quaisquer alterações especiais (mudanças específicas do Linux, é claro, foram, mas não mudaram o quadro global). Os primeiros sinais sérios de que o BPF iria evoluir surgiram em 2011, quando Eric Dumazet propôs remendo, que adiciona o Just In Time Compiler ao kernel - um tradutor para converter bytecode BPF em nativo x86_64 código.

O compilador JIT foi o primeiro na cadeia de mudanças: em 2012 apareceu capacidade de escrever filtros para segundo, utilizando o BPF, em janeiro de 2013 houve adicionado módulo xt_bpf, que permite escrever regras para iptables com a ajuda do BPF, e em outubro de 2013 foi adicionado também um módulo cls_bpf, que permite escrever classificadores de tráfego usando BPF.

Veremos todos esses exemplos com mais detalhes em breve, mas primeiro será útil aprender como escrever e compilar programas arbitrários para BPF, uma vez que os recursos fornecidos pela biblioteca libpcap limitado (exemplo simples: filtro gerado libpcap podem retornar apenas dois valores - 0 ou 0x40000) ou geralmente, como no caso de seccomp, não são aplicáveis.

Programando BPF com nossas próprias mãos

Vamos conhecer o formato binário das instruções BPF, é muito simples:

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

Cada instrução ocupa 64 bits, em que os primeiros 16 bits são o código da instrução, depois há dois recuos de oito bits, jt и jfe 32 bits para o argumento K, cuja finalidade varia de comando para comando. Por exemplo, o comando ret, que finaliza o programa tem o código 6, e o valor de retorno é obtido da constante K. Em C, uma única instrução BPF é representada como uma estrutura

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

e todo o programa tem a forma de uma estrutura

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

Assim, já podemos escrever programas (por exemplo, conhecemos os códigos de instrução de [1]). Esta é a aparência do filtro ip6 de nosso 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,
};

O programa prog podemos usar legalmente em uma chamada

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

Escrever programas na forma de códigos de máquina não é muito conveniente, mas às vezes é necessário (por exemplo, para depuração, criação de testes unitários, escrita de artigos sobre Habré, etc.). Por conveniência, no arquivo <linux/filter.h> macros auxiliares são definidas - o mesmo exemplo acima poderia ser reescrito 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),
}

No entanto, esta opção não é muito conveniente. Isto é o que os programadores do kernel Linux raciocinaram e, portanto, no diretório tools/bpf kernels você pode encontrar um montador e um depurador para trabalhar com o BPF clássico.

A linguagem assembly é muito semelhante à saída de depuração tcpdump, mas além disso podemos especificar rótulos simbólicos. Por exemplo, aqui está um programa que descarta todos os pacotes, exceto TCP/IPv4:

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

Por padrão, o assembler gera código no formato <количество инструкций>,<code1> <jt1> <jf1> <k1>,..., para nosso exemplo com 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 conveniência dos programadores C, um formato de saída diferente pode ser usado:

$ 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 pode ser copiado na definição da estrutura de tipo struct sock_filter, como fizemos no início desta seção.

Extensões Linux e netsniff-ng

Além do BPF padrão, Linux e tools/bpf/bpf_asm suporte e conjunto fora do padrão. Basicamente, as instruções são utilizadas para acessar os campos de uma estrutura struct sk_buff, que descreve um pacote de rede no kernel. No entanto, também existem outros tipos de instruções auxiliares, por exemplo ldw cpu irá carregar no registro A resultado da execução de uma função do kernel raw_smp_processor_id(). (Na nova versão do BPF, essas extensões não padrão foram estendidas para fornecer aos programas um conjunto de auxiliares de kernel para acessar memória, estruturas e gerar eventos.) Aqui está um exemplo interessante de filtro no qual copiamos apenas o cabeçalhos de pacotes no espaço do usuário usando a extensão poff, compensação de carga útil:

ld poff
ret a

Extensões BPF não podem ser usadas em tcpdump, mas este é um bom motivo para se familiarizar com o pacote de utilitários netsniff-ng, que, entre outras coisas, contém um programa avançado netsniff-ng, que, além de filtrar por BPF, também contém um gerador de tráfego eficaz e mais avançado que tools/bpf/bpf_asm, um montador do BPF chamado bpfc. O pacote contém documentação bastante detalhada, veja também os links no final do artigo.

segundo

Assim, já sabemos escrever programas BPF de complexidade arbitrária e estamos prontos para olhar novos exemplos, o primeiro dos quais é a tecnologia seccomp, que permite, através de filtros BPF, gerenciar o conjunto e conjunto de argumentos de chamada de sistema disponíveis para um determinado processo e seus descendentes.

A primeira versão do seccomp foi adicionada ao kernel em 2005 e não era muito popular, pois fornecia apenas uma única opção - limitar o conjunto de chamadas de sistema disponíveis para um processo ao seguinte: read, write, exit и sigreturn, e o processo que violou as regras foi eliminado usando SIGKILL. Porém, em 2012, o seccomp adicionou a capacidade de usar filtros BPF, permitindo definir um conjunto de chamadas de sistema permitidas e até mesmo realizar verificações em seus argumentos. (Curiosamente, o Chrome foi um dos primeiros usuários desta funcionalidade, e o pessoal do Chrome está atualmente desenvolvendo um mecanismo KRSI baseado em uma nova versão do BPF e permitindo a personalização dos módulos de segurança do Linux.) Links para documentação adicional podem ser encontrados no final. do artigo.

Observe que já existem artigos no hub sobre o uso do seccomp, talvez alguém queira lê-los antes (ou em vez de) ler as subseções a seguir. No artigo Contêineres e segurança: seccomp fornece exemplos de uso do seccomp, tanto a versão 2007 quanto a versão usando BPF (os filtros são gerados usando libseccomp), fala sobre a conexão do seccomp com o Docker e também fornece muitos links úteis. No artigo Isolar daemons com systemd ou “você não precisa do Docker para isso!” Ele aborda, em particular, como adicionar listas negras ou brancas de chamadas de sistema para daemons executando o systemd.

A seguir veremos como escrever e carregar filtros para seccomp em C simples e usando a biblioteca libseccomp e quais são os prós e contras de cada opção e, por fim, vamos ver como o seccomp é utilizado pelo programa strace.

Escrevendo e carregando filtros para seccomp

Já sabemos como escrever programas BPF, então vamos primeiro dar uma olhada na interface de programação seccomp. Você pode definir um filtro no nível do processo e todos os processos filhos herdarão as restrições. Isso é feito usando uma chamada de sistema seccomp(2):

seccomp(SECCOMP_SET_MODE_FILTER, flags, &filter)

onde &filter - este é um indicador de uma estrutura já familiar para nós struct sock_fprog, ou seja Programa BPF.

Como os programas para seccomp diferem dos programas para soquetes? Contexto transmitido. No caso dos soquetes, recebemos uma área de memória contendo o pacote, e no caso do seccomp, recebemos uma estrutura como

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

é nr é o número da chamada do sistema a ser lançada, arch - arquitetura atual (mais sobre isso abaixo), args - até seis argumentos de chamada de sistema e instruction_pointer é um ponteiro para a instrução de espaço do usuário que fez a chamada do sistema. Assim, por exemplo, para carregar o número de chamada do sistema no registro A temos que dizer

ldw [0]

Existem outros recursos para programas seccomp, por exemplo, o contexto só pode ser acessado por alinhamento de 32 bits e não é possível carregar meia palavra ou um byte - ao tentar carregar um filtro ldh [0] chamada de sistema seccomp retornará EINVAL. A função verifica os filtros carregados seccomp_check_filter() grãos. (O engraçado é que no commit original que adicionou a funcionalidade seccomp, eles esqueceram de adicionar permissão para usar a instrução a esta função mod (resto da divisão) e agora está indisponível para programas seccomp BPF, desde sua adição quebrará ABI.)

Basicamente, já sabemos tudo para escrever e ler programas seccomp. Normalmente, a lógica do programa é organizada como uma lista branca ou negra de chamadas do 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

verifica uma lista negra de quatro chamadas de sistema numeradas 304, 176, 239, 279. O que são essas chamadas de sistema? Não podemos afirmar com certeza, pois não sabemos para qual arquitetura o programa foi escrito. Portanto, os autores do seccomp oferta inicie todos os programas com uma verificação de arquitetura (a arquitetura atual é indicada no contexto como um campo arch a estrutura struct seccomp_data). Com a arquitetura verificada, o início do exemplo ficaria assim:

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

e então nossos números de chamada do sistema obteriam determinados valores.

Escrevemos e carregamos filtros para seccomp usando libseccomp

Escrever filtros em código nativo ou em assembly BPF permite ter controle total sobre o resultado, mas ao mesmo tempo, às vezes é preferível ter código portátil e/ou legível. A biblioteca vai nos ajudar com isso libseccomp, que fornece uma interface padrão para escrever filtros pretos ou brancos.

Vamos, por exemplo, escrever um programa que execute um arquivo binário à escolha do usuário, tendo previamente instalado uma lista negra de chamadas de sistema de o artigo acima (o programa foi simplificado para maior legibilidade, a versão completa pode ser encontrada aqui):

#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 um array sys_numbers de mais de 40 números de chamada do sistema para bloquear. Em seguida, inicialize o contexto ctx e diga à biblioteca o que queremos permitir (SCMP_ACT_ALLOW) todas as chamadas do sistema por padrão (é mais fácil criar listas negras). Então, uma por uma, adicionamos todas as chamadas do sistema da lista negra. Em resposta a uma chamada de sistema da lista, solicitamos SCMP_ACT_TRAP, neste caso seccomp enviará um sinal para o processo SIGSYS com uma descrição de qual chamada de sistema violou as regras. Finalmente, carregamos o programa no kernel usando seccomp_load, que irá compilar o programa e anexá-lo ao processo usando uma chamada de sistema seccomp(2).

Para uma compilação bem-sucedida, o programa deve estar vinculado à 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 lançamento bem-sucedido:

$ ./seccomp_lib echo ok
ok

Exemplo de uma chamada de sistema bloqueada:

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

Nós usamos stracepara detalhes:

$ 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 encerrado devido ao uso de uma chamada de sistema ilegal mount(2).

Então, escrevemos um filtro usando a biblioteca libseccomp, ajustando o código não trivial em quatro linhas. No exemplo acima, se houver um grande número de chamadas de sistema, o tempo de execução pode ser sensivelmente reduzido, já que a verificação é apenas uma lista de comparações. Para otimização, libseccomp recentemente teve patch incluído, que adiciona suporte para o atributo filter SCMP_FLTATR_CTL_OPTIMIZE. Definir este atributo como 2 converterá o filtro em um programa de pesquisa binária.

Se você quiser ver como funcionam os filtros de pesquisa binária, dê uma olhada em roteiro simples, que gera tais programas em assembler BPF discando 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

Você não conseguirá escrever nada significativamente mais rápido, pois os programas BPF não podem realizar saltos de indentação (não podemos fazer, por exemplo, jmp A ou jmp [label+X]) e, portanto, todas as transições são estáticas.

seccomp e strace

Todo mundo conhece a utilidade strace é uma ferramenta indispensável para estudar o comportamento de processos no Linux. No entanto, muitos também já ouviram falar Problemas de desempenho ao usar este utilitário. O fato é que strace implementado usando ptrace(2), e neste mecanismo não podemos especificar em qual conjunto de chamadas do sistema precisamos parar o processo, ou seja, 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

são processados ​​aproximadamente ao mesmo tempo, embora no segundo caso desejemos rastrear apenas uma chamada do sistema.

Nova opção --seccomp-bpfAdicionado a strace versão 5.3, permite acelerar o processo muitas vezes e o tempo de inicialização sob o rastreamento de uma chamada do sistema já é comparável ao tempo de uma inicialização 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

(Aqui, é claro, há um pequeno engano: não estamos rastreando a chamada de sistema principal deste comando. Se estivéssemos rastreando, por exemplo, newfsstatem seguida strace frearia tão forte quanto sem --seccomp-bpf.)

Como funciona esta opção? Sem ela strace se conecta ao processo e o inicia usando PTRACE_SYSCALL. Quando um processo gerenciado emite (qualquer) chamada de sistema, o controle é transferido para strace, que analisa os argumentos da chamada do sistema e a executa usando PTRACE_SYSCALL. Após algum tempo, o processo completa a chamada do sistema e ao sair dele o controle é transferido novamente strace, que analisa os valores de retorno e inicia o processo usando PTRACE_SYSCALL, e assim por diante.

BPF para os mais pequenos, parte zero: BPF clássico

Com o seccomp, entretanto, esse processo pode ser otimizado exatamente como gostaríamos. Ou seja, se quisermos ver apenas a chamada do sistema X, então podemos escrever um filtro BPF que para X valor de retorno SECCOMP_RET_TRACE, e para chamadas que não são do nosso interesse - SECCOMP_RET_ALLOW:

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

Neste caso strace inicialmente inicia o processo como PTRACE_CONT, nosso filtro é processado para cada chamada do sistema, se a chamada do sistema não for X, então o processo continua em execução, mas se isso X, então seccomp transferirá o controle straceque examinará os argumentos e iniciará o processo como PTRACE_SYSCALL (já que o seccomp não tem a capacidade de executar um programa ao sair de uma chamada do sistema). Quando a chamada do sistema retornar, strace irá reiniciar o processo usando PTRACE_CONT e aguardará novas mensagens do seccomp.

BPF para os mais pequenos, parte zero: BPF clássico

Ao usar a opção --seccomp-bpf existem duas restrições. Em primeiro lugar, não será possível aderir a um processo já existente (opção -p programa strace), pois isso não é compatível com seccomp. Em segundo lugar, não há possibilidade não observe os processos filhos, uma vez que os filtros seccomp são herdados por todos os processos filhos sem a capacidade de desabilitar isso.

Um pouco mais de detalhes sobre como exatamente strace funciona com seccomp pode ser encontrado a partir relatório recente. Para nós, o mais interessante é que o BPF clássico representado pelo seccomp ainda é utilizado hoje.

xt_bpf

Voltemos agora ao mundo das redes.

Contexto: há muito tempo, em 2007, o núcleo era adicionado módulo xt_u32 para filtro de rede. Foi escrito por analogia com um classificador de tráfego ainda mais antigo cls_u32 e permitiu que você escrevesse regras binárias arbitrárias para iptables usando as seguintes operações simples: carregue 32 bits de um pacote e execute um conjunto de operações aritméticas neles. Por exemplo,

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

Carrega os 32 bits do cabeçalho IP, começando no preenchimento 6, e aplica uma máscara a eles 0xFF (pegue o byte baixo). Este campo protocol Cabeçalho IP e comparamos com 1 (ICMP). Você pode combinar muitas verificações em uma regra e também executar o operador @ - mova X bytes para a direita. Por exemplo, a regra

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

verifica se o número de sequência TCP não é igual 0x29. Não entrarei em mais detalhes, pois já está claro que escrever tais regras à mão não é muito conveniente. No artigo BPF - o bytecode esquecido, existem vários links com exemplos de uso e geração de regras para xt_u32. Veja também os links no final deste artigo.

Desde 2013 módulo em vez de módulo xt_u32 você pode usar um módulo baseado em BPF xt_bpf. Quem leu até aqui já deve ter claro o princípio de seu funcionamento: executar o bytecode BPF conforme regras do iptables. Você pode criar uma nova regra, por exemplo, assim:

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

aqui <байткод> - este é o código no formato de saída assembler bpf_asm por padrão, 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 pacotes UDP. Contexto para um programa BPF em um módulo xt_bpf, é claro, aponta para os dados do pacote, no caso do iptables, para o início do cabeçalho IPv4. Valor de retorno do programa BPF boleanoOnde false significa que o pacote não correspondeu.

É claro que o módulo xt_bpf suporta filtros mais complexos do que o exemplo acima. Vejamos exemplos reais do Cloudfare. Até recentemente eles usavam o módulo xt_bpf para proteger contra ataques DDoS. No artigo Apresentando as ferramentas BPF eles explicam como (e por que) geram filtros BPF e publicam links para um conjunto de utilitários para a criação de tais filtros. Por exemplo, usando o utilitário bpfgen você pode criar um programa BPF que corresponda a uma consulta DNS para um 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 carregamos no registro X endereço de início de linha x04habrx03comx00 dentro de um datagrama UDP e verifique a solicitação: 0x04686162 <-> "x04hab" и т.д.

Um pouco mais tarde, Cloudfare publicou o código do compilador p0f -> BPF. No artigo Apresentando o compilador p0f BPF eles falam sobre o que é p0f e como converter assinaturas p0f em 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,
...

Atualmente não uso mais Cloudfare xt_bpf, já que mudaram para o XDP - uma das opções de utilização da nova versão do BPF, veja. L4Drop: mitigações de DDoS XDP.

cls_bpf

O último exemplo de uso do BPF clássico no kernel é o classificador cls_bpf para o subsistema de controle de tráfego em Linux, adicionado ao Linux no final de 2013 e substituindo conceitualmente o antigo cls_u32.

Contudo, não descreveremos agora o trabalho cls_bpf, pois do ponto de vista do conhecimento do BPF clássico isso não nos dará nada - já nos familiarizamos com todas as funcionalidades. Além disso, em artigos subsequentes falando sobre BPF Estendido, encontraremos esse classificador mais de uma vez.

Outro motivo para não falar sobre o uso do BPF clássico c cls_bpf O problema é que, comparado ao BPF estendido, o escopo de aplicabilidade neste caso é radicalmente reduzido: os programas clássicos não podem alterar o conteúdo dos pacotes e não podem salvar o estado entre as chamadas.

Então é hora de dizer adeus ao clássico BPF e olhar para o futuro.

Adeus ao clássico BPF

Vimos como a tecnologia BPF, desenvolvida no início dos anos noventa, sobreviveu com sucesso durante um quarto de século e até o fim encontrou novas aplicações. Porém, semelhante à transição das máquinas stack para RISC, que serviu de impulso para o desenvolvimento do BPF clássico, na década de 32 houve uma transição das máquinas de 64 bits para XNUMX bits e o BPF clássico começou a se tornar obsoleto. Além disso, as capacidades do BPF clássico são muito limitadas e, além da arquitetura desatualizada, não temos a capacidade de salvar o estado entre chamadas para programas BPF, não há possibilidade de interação direta do usuário, não há possibilidade de interação com o kernel, exceto para ler um número limitado de campos de estrutura sk_buff e iniciando as funções auxiliares mais simples, você não pode alterar o conteúdo dos pacotes e redirecioná-los.

Na verdade, atualmente tudo o que resta do BPF clássico no Linux é a interface API, e dentro do kernel todos os programas clássicos, sejam filtros de soquete ou filtros seccomp, são automaticamente traduzidos para um novo formato, BPF estendido. (Falaremos sobre exatamente como isso acontece no próximo artigo.)

A transição para uma nova arquitetura começou em 2013, quando Alexey Starovoitov propôs um esquema de atualização do BPF. Em 2014 os patches correspondentes começou a aparecer no núcleo. Pelo que entendi, o plano inicial era apenas otimizar a arquitetura e o compilador JIT para rodar com mais eficiência em máquinas de 64 bits, mas em vez disso, essas otimizações marcaram o início de um novo capítulo no desenvolvimento do Linux.

Outros artigos desta série abordarão a arquitetura e as aplicações da nova tecnologia, inicialmente conhecida como BPF interno, depois BPF estendido e agora simplesmente BPF.

referências

  1. Steven McCanne e Van Jacobson, "O filtro de pacotes BSD: uma nova arquitetura para captura de pacotes em nível de usuário", https://www.tcpdump.org/papers/bpf-usenix93.pdf
  2. Steven McCanne, "libpcap: uma metodologia de arquitetura e otimização para captura de pacotes", https://sharkfestus.wireshark.org/sharkfest.11/presentations/McCanne-Sharkfest'11_Keynote_Address.pdf
  3. tcpdump, libpcap: https://www.tcpdump.org/
  4. Tutorial de correspondência IPtable U32.
  5. BPF - o bytecode esquecido: https://blog.cloudflare.com/bpf-the-forgotten-bytecode/
  6. Apresentando a 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. Uma visão geral do secomp: https://lwn.net/Articles/656307/
  9. https://github.com/torvalds/linux/blob/master/Documentation/userspace-api/seccomp_filter.rst
  10. habr: Contêineres e segurança: seccomp
  11. habr: Isolando daemons com systemd ou “você não precisa do Docker para isso!”
  12. Paul Chaignon, "strace --seccomp-bpf: uma olhada nos bastidores", https://fosdem.org/2020/schedule/event/debugging_strace_bpf/
  13. netsniff-ng: http://netsniff-ng.org/

Fonte: habr.com

Adicionar um comentário