Escrevendo proteção contra ataques DDoS no XDP. Parte fundamental

A tecnologia eXpress Data Path (XDP) permite processar o tráfego em interfaces Linux antes que os pacotes cheguem à pilha de rede do kernel. A aplicação do XDP inclui proteção contra ataques DDoS (CloudFlare), filtros complexos e coleta de estatísticas (Netflix). Programas XDP são executados em uma máquina virtual eBPF, portanto, têm limitações tanto em seu código quanto nas funções do kernel disponíveis, dependendo do tipo de filtro.

Este artigo visa preencher as lacunas presentes em diversos materiais sobre o XDP. Primeiramente, eles geralmente fornecem códigos prontos que contornam as características do XDP: preparados para verificação ou excessivamente simples para causar problemas. Ao tentar escrever seu próprio código do zero, não há compreensão sobre como lidar com os erros característicos. Em segundo lugar, não são abordados os métodos de testar localmente o XDP sem VM e "hardware", embora eles tenham suas próprias "armadilhas". O texto é destinado a programadores familiarizados com redes e Linux, que estão interessados em XDP e eBPF.

Nesta parte, analisaremos detalhadamente como um filtro XDP é construído e como testá-lo, em seguida, escreveremos uma versão simples do conhecido mecanismo SYN cookies no nível de processamento de pacotes. Por enquanto, não iremos formar uma "lista branca"
de clientes verificados, contar contadores e gerenciar o filtro — logs serão suficientes.

A linguagem de programação será C — não é a mais moderna, mas é prática. Todo o código está disponível no GitHub através do link ao final, dividido em commits conforme as etapas descritas no artigo.

Aviso legal. Durante o artigo, será desenvolvido uma mini-solução para refletir ataques DDoS, pois essa é uma tarefa realista para o XDP e é de meu interesse. No entanto, o principal objetivo é entender a tecnologia, não é um guia para criar uma proteção pronta. O código educacional não está otimizado e deixa de lado alguns nuances.

Resumo do XDP

Vou expor apenas os pontos-chave para não duplicar a documentação e artigos existentes.

Assim, o código do filtro é carregado no kernel. Os pacotes de entrada são passados para o filtro. Por fim, o filtro deve tomar uma decisão: passar o pacote para o kernel (XDP_PASS), descartar o pacote (XDP_DROP) ou enviá-lo de volta (XDP_TX). O filtro pode modificar o pacote, o que é especialmente relevante para XDP_TX. Também é possível interromper a execução do programa (XDP_ABORTED) e descartar o pacote, mas isso é análogo ao assert(0) — para depuração.

A máquina virtual eBPF (extended Berkley Packet Filter) é projetada para ser simples, para que o núcleo possa verificar se o código não entra em loop e não corrompe a memória alheia. Restrições e verificações combinadas:

  • Loops (transições para trás) são proibidos.
  • Há uma pilha para dados, mas não há funções (todas as funções C devem ser embutidas).
  • Acesso à memória além da pilha e do buffer de pacotes é proibido.
  • O tamanho do código é limitado, mas na prática isso não é muito significativo.
  • Apenas chamadas de funções especiais do núcleo (ajudantes eBPF) são permitidas.

O desenvolvimento e a instalação do filtro são feitos da seguinte forma:

  1. O código fonte (por exemplo, kernel.c) é compilado em um objeto (kernel.o) para a arquitetura da máquina virtual eBPF. Desde outubro de 2019, a compilação em eBPF é suportada pelo Clang e prometida no GCC 10.1.
  2. Se houver acessos a estruturas do núcleo (por exemplo, a tabelas e contadores) neste código objeto, em vez de seus IDs estarão zeros, ou seja, esse código não pode ser executado. Antes de carregar no núcleo, esses zeros devem ser substituídos pelos IDs de objetos específicos, criados através de chamadas do núcleo (linkar o código). Isso pode ser feito com utilitários externos ou pode-se escrever um programa que linke e carregue um filtro específico.
  3. O núcleo verifica o programa carregado. Ele verifica a ausência de loops e se não há saídas dos limites do pacote e da pilha. Se o verificador não consegue provar que o código é correto, o programa é rejeitado — deve-se saber agradá-lo.
  4. Após uma verificação bem-sucedida, o núcleo compila o código objeto da arquitetura eBPF em código de máquina da arquitetura do sistema (just-in-time).
  5. O programa é anexado à interface e começa a processar pacotes.

Como o XDP funciona no núcleo, a depuração é feita através de logs de rastreamento e, de fato, pelos pacotes que o programa filtra ou gera. No entanto, o eBPF garante a segurança do código carregado para o sistema, portanto, é possível experimentar com o XDP diretamente em um Linux local.

Preparação do ambiente

Compilação

O Clang não pode gerar diretamente o código objeto para a arquitetura eBPF, portanto, o processo consiste em duas etapas:

  1. Compilar o código em C para bytecode LLVM (clang -emit-llvm).
  2. Converter bytecode em código objeto eBPF (llc -march=bpf -filetype=obj).

Ao escrever um filtro, será útil ter alguns arquivos com funções auxiliares e macros dos testes do núcleo.É importante que eles correspondam à versão do núcleo (KVER). Estamos baixando-os em helpers/:

export KVER=v5.3.7
export BASE=https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/tools/testing/selftests/bpf
wget -P helpers --content-disposition "${BASE}/bpf_helpers.h?h=${KVER}" "${BASE}/bpf_endian.h?h=${KVER}"
unset KVER BASE

Makefile para Arch Linux (núcleo 5.3.7):

CLANG ?= clang
LLC ?= llc

KDIR ?= /lib/modules/$(shell uname -r)/build
ARCH ?= $(subst x86_64,x86,$(shell uname -m))

CFLAGS = 
    -Ihelpers 
    
    -I$(KDIR)/include 
    -I$(KDIR)/include/uapi 
    -I$(KDIR)/include/generated/uapi 
    -I$(KDIR)/arch/$(ARCH)/include 
    -I$(KDIR)/arch/$(ARCH)/include/generated 
    -I$(KDIR)/arch/$(ARCH)/include/uapi 
    -I$(KDIR)/arch/$(ARCH)/include/generated/uapi 
    -D__KERNEL__ 
    
    -fno-stack-protector -O2 -g

xdp_%.o: xdp_%.c Makefile
    $(CLANG) -c -emit-llvm $(CFLAGS) $< -o - | 
    $(LLC) -march=bpf -filetype=obj -o $@

.PHONY: all clean

all: xdp_filter.o

clean:
    rm -f ./*.o

KDIR contém o caminho para os cabeçalhos do núcleo, ARCH — a arquitetura do sistema. Os caminhos e ferramentas podem variar um pouco entre as distribuições.

Exemplo de diferenças para Debian 10 (núcleo 4.19.67)

# другая команда
CLANG ?= clang
LLC ?= llc-7

# другой каталог
KDIR ?= /usr/src/linux-headers-$(shell uname -r)
ARCH ?= $(subst x86_64,x86,$(shell uname -m))

# два дополнительных каталога -I
CFLAGS = 
    -Ihelpers 
    
    -I/usr/src/linux-headers-4.19.0-6-common/include 
    -I/usr/src/linux-headers-4.19.0-6-common/arch/$(ARCH)/include 
    # далее без изменений

CFLAGS incluem o diretório com cabeçalhos auxiliares e vários diretórios com cabeçalhos do núcleo. O símbolo __KERNEL__ indica que os cabeçalhos UAPI (API do espaço do usuário) são definidos para o código do núcleo, já que o filtro é executado no núcleo.

A proteção de pilha pode ser desativada (-fno-stack-protector), pois o verificador de código eBPF ainda verifica estouro de pilha. É aconselhável ativar otimizações, pois o tamanho do código de byte eBPF é limitado.

Começaremos com um filtro que permite todos os pacotes e não faz nada:

#include <uapi/linux/bpf.h>

#include <bpf_helpers.h>

SEC("prog")
int xdp_main(struct xdp_md* ctx) {
    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

Time make coleta xdp_filter.o. Onde testá-lo agora?

Bancada de Testes

O ambiente deve incluir duas interfaces: uma onde o filtro estará e a outra de onde os pacotes serão enviados. Devem ser dispositivos Linux completos com seus IP para verificar como os aplicativos comuns interagem com nosso filtro.

Dispositivos do tipo veth (Ethernet virtual) são adequados: são um par de interfaces de rede virtuais "conectadas" diretamente uma à outra. Você pode criá-los assim (neste seção todos os comandos ip são executados a partir de root):

ip link add xdp-remote type veth peer name xdp-local

Aqui xdp-remote e xdp-local — são os nomes dos dispositivos. No xdp-local (192.0.2.1/24) o filtro será anexado, enquanto o tráfego de entrada será enviado de xdp-remote (192.0.2.2/24). No entanto, há um problema: as interfaces estão na mesma máquina, e o Linux não enviará tráfego de uma delas pela outra. Isso pode ser resolvido com algumas regras complicadas, iptablesmas elas precisariam modificar os pacotes, o que não é conveniente para depuração. É melhor usar espaços de nomes de rede (network namespaces, a seguir netns).

O espaço de nomes de rede contém um conjunto de interfaces, tabelas de roteamento e regras do NetFilter, isoladas de objetos semelhantes em outros netns. Cada processo opera em um espaço de nomes específico, tendo acesso apenas aos objetos desse netns. Por padrão, o sistema possui um único espaço de nomes de rede para todos os objetos, tornando possível trabalhar no Linux sem saber sobre netns.

Vamos criar um novo espaço de nomes xdp-teste e moveremos para lá xdp-remote.

ip netns add xdp-teste
ip link set dev xdp-remote netns xdp-teste

Então, o processo em execução em xdp-teste, não "verá" xdp-local (ele permanecerá no netns padrão) e, ao enviar um pacote para 192.0.2.1, o transmitirá por xdp-remote, porque esta é a única interface em 192.0.2.0/24 acessível a esse processo. Isso funciona também na direção oposta.

Ao mover entre netns, a interface é removida e perde seu endereço. Para configurar a interface no netns, é necessário executar ip ... neste espaço de nomes de comando ip netns exec:

ip netns exec xdp-teste 
    ip address add 192.0.2.2/24 dev xdp-remote
ip netns exec xdp-teste 
    ip link set xdp-remote up

Como se pode ver, isso não é diferente de configurar xdp-local no espaço de nomes padrão:

    ip address add 192.0.2.1/24 dev xdp-local
    ip link set xdp-local up

Se você executar tcpdump -tnevi xdp-local, você pode ver que os pacotes enviados de xdp-teste, são entregues a essa interface:

ip netns exec xdp-teste   ping 192.0.2.1

É conveniente iniciar um shell em xdp-teste. No repositório, há um script que automatiza o trabalho com o stand, por exemplo, você pode configurar o stand com o comando sudo .\/stand up e removê-lo sudo .\/stand down.

Rastreamento

O filtro é vinculado ao dispositivo assim:

ip -force link set dev xdp-local xdp object xdp_filter.o verbose

Chave -force é necessário para vincular um novo programa, se outro já estiver vinculado. "No news is good news" não se aplica a este comando, a saída é volumosa em qualquer caso. Especificar verbose não é obrigatório, mas com isso vem um relatório sobre o trabalho do verificador de código com a listagem em assembly:

Verificação do verificador:

0: (b7) r0 = 2
1: (95) exit

Desvincular o programa da interface:

ip link set dev xdp-local xdp off

No script, esses são os comandos sudo .\/stand attach e sudo .\/stand detach.

Após vincular o filtro, você pode garantir que ping continue funcionando, mas será que o programa está funcionando? Vamos adicionar logs. A função bpf_trace_printk() é semelhante a printf(), mas suporta apenas até três argumentos além do formato e uma lista limitada de especificadores. O macro bpf_printk() facilita a chamada.

   SEC("prog")
   int xdp_main(struct xdp_md* ctx) {
+      bpf_printk("got packet: %pn", ctx);
       return XDP_PASS;
   }

A saída vai para o canal de rastreamento do núcleo, que precisa ser ativado:

echo -n 1 | sudo tee /sys/kernel/debug/tracing/options/trace_printk

Visualizar fluxo de mensagens:

cat /sys/kernel/debug/tracing/trace_pipe

Ambos os comandos fazem uma chamada sudo ./stand log.

Agora o Ping deve gerar mensagens como estas:

-110930 [004] ..s1 78803.244967: 0: got packet: 00000000ac510377

Se você olhar atentamente a saída do verificador, poderá notar cálculos estranhos:

0: (bf) r3 = r1
1: (18) r1 = 0xa7025203a7465
3: (7b) *(u64 *)(r10 -8) = r1
4: (18) r1 = 0x6b63617020746f67
6: (7b) *(u64 *)(r10 -16) = r1
7: (bf) r1 = r10
8: (07) r1 += -16
9: (b7) r2 = 16
10: (85) call bpf_trace_printk#6

O fato é que programas em eBPF não têm seção de dados, portanto a única forma de codificar uma string formatada são argumentos imediatos das instruções da VM:

$ python -c "import binascii; print(bytes(reversed(binascii.unhexlify('0a7025203a74656b63617020746f67'))))"
b'got packet: %pn'

Por esse motivo, a saída de depuração inflaciona muito o código final.

Envio de pacotes XDP

Vamos alterar o filtro: ele deve devolver todos os pacotes de entrada. Isso é incorreto do ponto de vista da rede, pois seria necessário alterar os endereços nos cabeçalhos, mas agora o importante é que funcione.

       bpf_printk("got packet: %pn", ctx);
-      return XDP_PASS;
+      return XDP_TX;
   }

Iniciamos tcpdump para xdp-remote. Ele deve mostrar requisições de ICMP Echo idênticas de saída e entrada e parar de mostrar ICMP Echo Reply. Mas não mostra. Acontece que, para funcionar XDP_TX em um programa em xdp-local é necessário, para que a interface correspondente xdp-remote também tenha um programa atribuído, mesmo que vazio, e que esteja ativo.

Como eu soube disso?

Acompanhar o caminho do pacote no núcleo permite o mecanismo perf events, que, aliás, usa a mesma máquina virtual, ou seja, para depurar eBPF se aplica eBPF.

Você deve fazer o bem a partir do mal, porque não há mais o que fazer com ele.

$ sudo perf trace --call-graph dwarf -e 'xdp:*'
   0.000 ping/123455 xdp:xdp_bulk_tx:ifindex=19 action=TX sent=0 drops=1 err=-6
                                     veth_xdp_flush_bq ([veth])
                                     veth_xdp_flush_bq ([veth])
                                     veth_poll ([veth])

O que é o código 6?

$ errno 6
ENXIO 6 Nenhum dispositivo ou endereço existente

Função veth_xdp_flush_bq() recebe um código de erro de veth_xdp_xmit(), onde buscamos por ENXIO e encontramos um comentário.

Vamos restaurar o filtro mínimo (XDP_PASS) no arquivo xdp_dummy.c, adicioná-lo ao Makefile, vinculá-lo a xdp-remote:

ip netns exec remote 
    ip link set dev int xdp object dummy.o

Agora, tcpdump mostra o que se espera:

62:57:8e:70:44:64 > 26:0e:25:37:8f:96, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 13762, offset 0, flags [DF], proto ICMP (1), length 84)
    192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, length 64
62:57:8e:70:44:64 > 26:0e:25:37:8f:96, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 13762, offset 0, flags [DF], proto ICMP (1), length 84)
    192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, length 64

Se em vez disso apenas ARP é mostrado, é necessário remover os filtros (isso faz sudo .\/stand detach), passar ping, em seguida, definir os filtros e tentar novamente. O problema é que o filtro XDP_TX também afeta ARP, e se a pilha
do espaço de nomes xdp-teste conseguiu "esquecer" o endereço MAC 192.0.2.1, ele não conseguirá resolver este IP.

Formulação do problema

Vamos passar à tarefa proposta: escrever um mecanismo SYN cookies em XDP.

Até agora, o ataque DDoS mais popular continua a ser o SYN flood, que se baseia no seguinte. Ao estabelecer uma conexão (TCP handshake), o servidor recebe um SYN, aloca recursos para a futura conexão, responde com um pacote SYNACK e aguarda um ACK. O atacante simplesmente envia pacotes SYN de endereços falsos em uma quantidade de milhares por segundo a partir de cada host de uma botnet de milhares. O servidor é obrigado a alocar recursos imediatamente ao receber o pacote, e libera após um grande timeout, resultando em esgotamento da memória ou limites, novas conexões não são aceitas, o serviço se torna indisponível.

Se não alocarmos recursos para cada pacote SYN, mas apenas respondermos com um pacote SYNACK, como o servidor entenderá que o pacote ACK, que chega depois, está relacionado ao pacote SYN que não foi salvo? Afinal, o atacante pode gerar ACKs falsos. A essência do SYN cookie é codificar nos seqnum os parâmetros da conexão como um hash dos endereços, portas e um sal variável. Se o ACK chegou antes da mudança do sal, podemos recalcular o hash e comparar com acknum. Um atacante não pode falsificar, pois o sal inclui um segredo, e ele não terá tempo para tentar devido à largura de banda limitada. acknum O SYN cookie já foi implementado no núcleo do Linux e pode até ser ativado automaticamente se os SYN chegarem muito rapidamente e em massa.

Mini-aula sobre TCP handshake

O TCP garante a transmissão de dados como um fluxo de bytes, por exemplo, as solicitações HTTP são transmitidas sobre o TCP. O fluxo é transmitido em pedaços dentro de pacotes. Todos os pacotes TCP têm bandeiras lógicas e números de sequência de 32 bits:

TCP assegura a transmissão de dados como um fluxo de bytes, por exemplo, as requisições HTTP são transmitidas sobre TCP. O fluxo é transmitido em pedaços encapsulados dentro de pacotes. Todos os pacotes TCP possuem bandeiras lógicas e números de sequência de 32 bits:

  • A combinação de flags determina o papel de um pacote específico. A flag SYN significa que este é o primeiro pacote do remetente na conexão. A flag ACK indica que o remetente recebeu todos os dados da conexão até o byte. acknum. O pacote pode ter várias flags e é nomeado pela sua combinação, por exemplo, um pacote SYNACK.

  • O número da sequência (seqnum) determina o deslocamento no fluxo de dados para o primeiro byte que é transmitido neste pacote. Por exemplo, se no primeiro pacote com X bytes de dados esse número foi N, no próximo pacote com novos dados ele será N+X. No início da conexão, cada lado escolhe esse número de forma aleatória.

  • O número de reconhecimento (acknum) é um deslocamento semelhante ao seqnum, mas determina não o número do byte transmitido, mas o número do primeiro byte do receptor que o remetente não viu.

No início da conexão, as partes devem concordar seqnum e acknum. O cliente envia um pacote SYN com seu seqnum = X. O servidor responde com um pacote SYNACK, onde registra seu seqnum = Y e define acknum = X + 1. O cliente responde ao SYNACK com um pacote ACK, onde seqnum = X + 1, acknum = Y + 1. Após isso, começa a transmissão propriamente dita dos dados.

Se o interlocutor não confirma o recebimento do pacote, o TCP o reenviará após um timeout.

Por que os cookies SYN não são usados sempre?

Em primeiro lugar, se o SYNACK ou ACK se perder, será necessário aguardar o reenvio — isso retarda a configuração da conexão. Em segundo lugar, no pacote SYN — e somente nele! — é transmitido uma série de opções que afetam o funcionamento posterior da conexão. Não memorizando os pacotes SYN recebidos, o servidor ignora essas opções, e nos pacotes seguintes o cliente já não as enviará. O TCP pode funcionar assim, mas pelo menos na etapa inicial a qualidade da conexão diminuirá.

Do ponto de vista dos pacotes, o programa XDP deve fazer o seguinte:

  • responder ao SYN com SYNACK e cookie;
  • responder ao ACK com RST (interromper a conexão);
  • descartar os demais pacotes.

Pseudocódigo do algoritmo junto com a análise do pacote:

Se não for Ethernet,
    descarte o pacote.
Se não for IPv4,
    descarte o pacote.
Se o endereço estiver na tabela de verificação,               (*)
        reduza o contador de verificações restantes,
        descarte o pacote.
Se não for TCP,
    descarte o pacote.     (**)
Se for SYN,
    responda com SYN-ACK e cookie.
Se for ACK,
    se acknum não contiver o cookie,
        descarte o pacote.
    Adicione o endereço à tabela com N verificações restantes.    (*)
    Responda com RST.   (**)
Nos demais casos, descarte o pacote.

Uma (*) marcada como pontos em que é necessário gerenciar o estado do sistema — na primeira fase, pode-se passar sem eles, implementando simplesmente o handshake TCP com a geração de SYN cookie como seqnum.

No local (**), enquanto não tivermos uma tabela, vamos descartar o pacote.

Implementação do handshake TCP

Análise do pacote e verificação do código

Precisaremos de estruturas de cabeçalhos de rede: Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) e TCP (uapi/linux/tcp.h). Não consegui conectar o último devido a erros relacionados a atomic64_t, tendo que copiar as definições necessárias para o código.

Todas as funções que em C são definidas para facilitar a leitura devem ser incorporadas no local de chamada, uma vez que o verificador eBPF no núcleo proíbe retrocessos, ou seja, de fato, ciclos e chamadas de função.

#define INTERNAL static __attribute__((always_inline))

Macro LOG() desativa a impressão na compilação de release.

O programa consiste em um pipeline de funções. Cada uma aceita um pacote, onde o cabeçalho do nível correspondente está alocado, por exemplo, process_ether() espera que esteja preenchido ether.Com base na análise dos campos, a função pode passar o pacote para o nível superior. O resultado do trabalho da função é uma ação XDP. Atualmente, os manipuladores SYN e ACK descartam todos os pacotes.

struct Packet {
    struct xdp_md* ctx;

    struct ethhdr* ether;
    struct iphdr* ip;
    struct tcphdr* tcp;
};

INTERNAL int process_tcp_syn(struct Packet* packet) { return XDP_PASS; }
INTERNAL int process_tcp_ack(struct Packet* packet) { return XDP_PASS; }
INTERNAL int process_tcp(struct Packet* packet) { ... }
INTERNAL int process_ip(struct Packet* packet) { ... }

INTERNAL int
process_ether(struct Packet* packet) {
    struct ethhdr* ether = packet->ether;

    LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));

    if (ether->h_proto != bpf_ntohs(ETH_P_IP)) {
        return XDP_PASS;
    }

    // B
    struct iphdr* ip = (struct iphdr*)(ether + 1);
    if ((void*)(ip + 1) > (void*)packet->ctx->data_end) {
        return XDP_DROP; /* pacote malformado */
    }

    packet->ip = ip;
    return process_ip(packet);
}

SEC("prog")
int xdp_main(struct xdp_md* ctx) {
    struct Packet packet;
    packet.ctx = ctx;

    // A
    struct ethhdr* ether = (struct ethhdr*)(void*)ctx->data;
    if ((void*)(ether + 1) > (void*)ctx->data_end) {
        return XDP_PASS;
    }

    packet.ether = ether;
    return process_ether(&packet);
}

Atenção às verificações marcadas como A e B. Se você comentar A, o programa será compilado, mas ao carregá-lo ocorrerá um erro de verificação:

Análise do verificador:


11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
acesso inválido ao pacote, off=13 tamanho=1, R7(id=0,off=0,r=0)
O deslocamento de R7 está fora do pacote
processados 11 in instruções (limite 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0

Erro ao buscar programa/mapa!

Linha chave acesso inválido ao pacote, off=13 tamanho=1, R7(id=0,off=0,r=0): existem caminhos de execução onde o décimo terceiro byte do início do buffer está fora do pacote. É difícil entender pelo listagem de qual linha se trata, mas há um número da instrução (12) e um desassemblador que mostra as linhas do código fonte:

llvm-objdump -S xdp_filter.o | less

Neste caso, ele aponta para a linha

LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));

onde é evidente que o problema está em ether.. Sempre seria assim.

Resposta ao SYN

O objetivo nesta etapa é formar um pacote SYNACK correto com o fixo seqnum, que será substituído futuramente por um cookie SYN. Todas as alterações ocorrem em process_tcp_syn() e arredores.

Verificação do pacote

Estranhamente, esta é a linha mais notável, ou melhor, o comentário a respeito dela:

/* Required to verify checksum calculation */
const void* data_end = (const void*)ctx->data_end;

Ao escrever a primeira versão do código, foi usado o kernel 5.1, para o qual havia uma diferença entre data_end e (const void*)ctx->data_end. Ao escrever este artigo, o kernel 5.3.1 não apresentava tal problema. Talvez o compilador se referisse à variável local de maneira diferente do que ao campo. A moral - em profundidade alta, simplificar o código pode ajudar.

Em seguida, verificações rotineiras de tamanho em louvor ao verificador; sobre MAX_CSUM_BYTES abaixo.

const u32 ip_len = ip->ihl * 4;
if ((void*)ip + ip_len > data_end) {
    return XDP_DROP; /* pacote malformado */
}
if (ip_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* limitação da implementação */
}

const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
    return XDP_DROP; /* pacote malformado */
}
if (tcp_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* limitação da implementação */
}

Inversão do pacote

Preenchemos seqnum e acknum, configuramos ACK (SYN já configurado):

const u32 cookie = 42;
tcp->ack_seq = bpf_htonl(bpf_ntohl(tcp->seq) + 1);
tcp->seq = bpf_htonl(cookie);
tcp->ack = 1;

Troca as portas TCP, endereço IP e endereços MAC. A biblioteca padrão não está disponível no programa XDP, portanto memcpy() é um macro que oculta o intrínseco do Clang.

const u16 temp_port = tcp->source;
tcp->source = tcp->dest;
tcp->dest = temp_port;

const u32 temp_ip = ip->saddr;
ip->saddr = ip->daddr;
ip->daddr = temp_ip;

struct ethhdr temp_ether = *ether;
memcpy(ether->h_dest, temp_ether.h_source, ETH_ALEN);
memcpy(ether->h_source, temp_ether.h_dest, ETH_ALEN);

Recalculando somas de verificação

As somas de verificação IPv4 e TCP exigem a soma de todas as palavras de 16 bits nos cabeçalhos, e o tamanho dos cabeçalhos está registrado neles, ou seja, no momento da compilação é desconhecido. Este é um problema, pois o verificador não permitirá um ciclo normal até a variável limite. No entanto, o tamanho dos cabeçalhos é limitado: até 64 bytes cada. Pode-se fazer um ciclo com um número fixo de iterações, que pode ser encerrado prematuramente.

Cabe ressaltar que há RFC 1624 sobre como recalcular a soma de verificação parcialmente, se apenas palavras fixas dos pacotes foram alteradas. No entanto, o método não é universal, e a implementação seria mais difícil de manter.

Função para calcular a soma de verificação:

#define MAX_CSUM_WORDS 32
#define MAX_CSUM_BYTES (MAX_CSUM_WORDS * 2)

INTERNAL u32
sum16(const void* data, u32 size, const void* data_end) {
    u32 s = 0;
#pragma unroll
    for (u32 i = 0; i < MAX_CSUM_WORDS; i++) {
        if (2*i >= size) {
            return s; /* normal exit */
        }
        if (data + 2*i + 1 + 1 > data_end) {
            return 0; /* should be unreachable */
        }
        s += ((const u16*)data)[i];
    }
    return s;
}

Embora tamanho seja verificado pelo código chamador, a segunda condição de saída é necessária para que o verificador possa provar a conclusão do ciclo.

Para palavras de 32 bits, uma versão mais simples foi implementada:

INTERNAL u32
sum16_32(u32 v) {
    return (v >> 16) + (v & 0xffff);
}

Na verdade, o recálculo das somas de verificação e o envio do pacote de volta:

ip->check = 0;
ip->check = carry(sum16(ip, ip_len, data_end));

u32 tcp_csum = 0;
tcp_csum += sum16_32(ip->saddr);
tcp_csum += sum16_32(ip->daddr);
tcp_csum += 0x0600;
tcp_csum += tcp_len <check = 0;
tcp_csum += sum16(tcp, tcp_len, data_end);
tcp->check = carry(tcp_csum);

return XDP_TX;

Função carry() transforma a soma de 32 bits em uma soma de verificação de palavras de 16 bits, de acordo com a RFC 791.

Verificação do handshake TCP

O filtro estabelece a conexão corretamente com netcat, ignorando o ACK final, ao qual o Linux respondeu com um pacote RST, já que a pilha de rede não recebeu o SYN — ele foi transformado em SYNACK e enviado de volta — e do ponto de vista do SO, o pacote chegou que não pertencia a conexões abertas.

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Conexão redefinida pelo par

É importante testar com aplicativos completos e observar tcpdump para xdp-remote porque, por exemplo, hping3 não responde a somas de verificação incorretas.

Do ponto de vista do XDP, a própria verificação é trivial. O algoritmo de cálculo é primitivo e, provavelmente, vulnerável a um invasor sofisticado. O núcleo Linux, por exemplo, utiliza o SipHash criptográfico, mas sua implementação para XDP claramente está além do escopo deste artigo.

Apareceu para novas TAREFAS, relacionadas à interação externa:

  • O programa XDP não pode armazenar cookie_seed (a parte secreta do sal) em uma variável global, é necessário um armazenamento no núcleo, cujo valor será periodicamente atualizado a partir de um gerador confiável.

  • Ao coincidir com o cookie SYN no pacote ACK, não deve-se imprimir uma mensagem, mas sim lembrar o IP do cliente verificado para permitir pacotes dele posteriormente.

Verificação de cliente legítimo:

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Conexão resetada pelo par

Nos logs foi registrado o sucesso da verificação (flags=0x2 — isso é SYN, flags=0x10 — isso é ACK):

Ether(proto=0x800)
  IP(src=0x20e6e11a dst=0x20e6e11e proto=6)
    TCP(sport=50836 dport=6666 flags=0x2)
Ether(proto=0x800)
  IP(src=0xfe2cb11a dst=0xfe2cb11e proto=6)
    TCP(sport=50836 dport=6666 flags=0x10)
      cookie corresponde para o cliente 20200c0

Enquanto não houver uma lista de IPs verificados, não haverá proteção contra o próprio ataque SYN flood, mas aqui está a reação ao ACK flood, executado com o seguinte comando:

sudo ip netns exec xdp-test   hping3 --flood -A -s 1111 -p 2222 192.0.2.1

Registros no log:

Ether(proto=0x800)
  IP(src=0x15bd11a dst=0x15bd11e proto=6)
    TCP(sport=3236 dport=2222 flags=0x10)
      cookie não corresponde

Conclusão

Às vezes, o eBPF e, em particular, o XDP é visto mais como uma ferramenta para administradores avançados do que como uma plataforma para desenvolvimento. De fato, o XDP é uma ferramenta de interferência no processamento de pacotes pelo kernel, e não uma alternativa ao stack do kernel, como o DPDK e outras opções de bypass do kernel. Por outro lado, o XDP permite implementar uma lógica bastante complexa, que, além disso, é fácil de atualizar sem pausas no processamento do tráfego. O verificador não cria grandes problemas, pessoalmente eu não desistiria de uma assim para partes do código de espaço de usuário.

Na segunda parte, se o tema for interessante, completaremos a tabela de clientes verificados e interrupções de conexão, implementaremos contadores e escreveremos uma utilidade de espaço de usuário para gerenciar o filtro.

Links:

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster