Executando verificação de segurança...
9

Proof-of-Transit para SRv6 com SipHash, Poly1305 e BLAKE3 direto em kernel-land com eBPF

Fala galera, venho aqui compartilhar com vocês a ideia de um projeto que estou desenvolvendo para a faculdade. O objetivo aqui é testar uma hipótese para resolver um problema recentemente discutido na comunidade de segurança em redes de computadores.

O problema, bem, a inabilidade de conseguir comprovar que um pacote de rede realmente seguiu o caminho que lhe foi atribuido pelo protocolo de controle de roteamento de origem.

Resumindo, diferentemente de protocolos tradicionais como o OSPF (Open-shortest Path First) ou BGP (Border Gateway Protocol) que decidem seu próximo salto a cada nó, alguns protocolos como o MPLS (Multiprotocol Label Switching), SR (Segment Routing) ou o mais recente SRv6 (Segment Routing over IPv6), usualmente mas não limitado á, definem o caminho inteiro direto no primeiro ponto de entrada na rede.

Vamos focar aqui no mais recente SRv6, claro, totalmente baseado em IPv6.

Definição do problema

Como vocês já devem saber, nenhum mecanismo de segurança é nativamente implementado nesse ponto, assim, qualquer nó malicioso pode alterar o caminho simplismente alterando um registro da lista de segmentos.

Algumas soluções já existem para contornar isso caso necessário, como por exemplo a implementaçao do existente TLV (Type-Lenght Variable) HMAC (Hashed Message Authentication Code), mitigando assim alguns destes ataques de modificaçao indesejada do pacote de rede em trânsito.

Porém nem tudo são flores, alguns nós ainda podem redirecionar os pacotes por outro caminho que não o designado originalmente, até mesmo sem modificar seu cabeçalho, pois como o HMAC compartilha uma chave secreta somente fim-a-fim, o que ocorre no meio do caminho não se é auditável.

E é aqui que o PoT (Proof-of-Transit) entra, algoritmos de PoT visam resolver este problema, tentando provar cryptograficamente o caminho tomado pelo pacote de rede.

Como não sou nenhum expert em criptografia andei pesquisando algumas implementações anteriores para tentar replicar em meu projeto, algumas boas referências foram alguns artigos científicos, caso queiram dar uma olhada:

Para o meu projeto, escolhi fazer algo parecido com este esquema:

Implementação

Basicamente, compartilhar uma chave única com cada nó no nó de origem e destino, assim podemos gerar provas criptográficos conforme o pacote passa por cada segmento, para podermos confirmar a integridade do caminho tomado.

Alguns dos artigos apresentados como referência utilizam P4 para fazer suas provas de conceito, no meu projeto escolhi utilizar eBPF + XDP e TC por uma questão de familiaridade e conforto.

eBPF basicamente é uma VM (Virtual Machine) residindo dentro do Kernel do Linux, com uma instruction-set bem limitada, desenhada especificamente para se trabalhar com operações simples de baixa latência. Já o XDP e TC são hooks que trabalham interceptando pacotes de rede em diferentes pontos da pilha de redes para que possamos aplicar nossa lógica em cima.

Todo o código eBPF é escrito em C, e compilado com Clang, para injetar o bytecode BTF, podemos utilizar o utilitário "seg6-pot-tlv" escrito em Golang, com uma usabilidade muito simples:

  Usage:
    seg6-pot-tlv --load <iface>
        Loads & attaches the eBPF XDP and TC programs to <iface> and pins the maps.

    seg6-pot-tlv --sid <sid> --key <key>
        Updates the pinned map with <sid> (IPv6) with the related <key> (max 32B).

    seg6-pot-tlv --keys
        Shows all the keys pinned on the key map with their related SID.

  Examples:
    sudo ./seg6-pot-tlv --load ens5
    sudo ./seg6-pot-tlv --sid 2001:db8:ff:1::1 --key 00112233445566778899aabbccddeeff00112233445566778899aabbccddee11

Para o cenário de testes, vamos utilizar a seguinte topologia:

Basicamente vamos carregar o programa em todas as intefaces pelo caminho dos segmentos, para que possamos realizar nossas operações com o novo TLV.


Quando o H1 (Host 1) tenta fazer um simples Ping para o H2 (Host 2), o H1 não sabe a rota para o endereço do H2, que então manda para seu gateway (R1 - Router 1) resolver.

ping 2001:db8:60:1::2

R1 conheçe a rede e sabe como chegar da melhor forma, definindo o caminho dos pacotes como:

R1 - R2 - R3 - R4

O mesmo acontece para H2, utilizando os segmentos reversos:

R4 - R3 - R2 - R1

Exemplo de um pacote com a lista de segmentos para chegar em H2:


Nós podemos adicionar um novo TLV onde está a linha verde desenhado na figura do pacote, no caso logo após o último byte do último segmento da lista de segmentos do SRv6.

O novo TLV consiste em basicamente um Nonce randômico, e duas provas criptográficas, uma sendo incrementada por cada nó onde o pacote passa, e outra contendo o resultado final esperado, também tem os campos Type, Lenght e Flags para que as implementações seguinda a RFC consigam localizar o next-header do IPv6 após o SRv6, como definido pela RFC.

Operações criptográficas

Implementei 3 diferentes algorítmos para verificar qual teria a melhor performance, as escolhidas foram: BLAKE3 Keyed-hash function, SipHash e Poly1305. Vale lembrar que por limitação da quantidade de instruções permitidas pelo eBPF (1KK), algumas implementações não ficaram genuínas, nem com aceleração de hardware, pois não conseguimos ter acesso à instruções SIMD por exemplo para utilizar o máximo do BLAKE3.

Para computar a prova criptográfica de cada nó, pegamos a chave associada à ele e computamos em conjunto com o Nonce:

static __always_inline int compute_witness_once(struct pot_tlv *tlv, struct srh *srh, void *end)
{
    __u32 segment_id_size = srh_hdr_len(srh) / IPV6_LEN;
    if ((void *)((__u8 *)srh + SRH_FIXED_HDR_LEN + (IPV6_LEN * segment_id_size)) > end) {
        bpf_printk("[seg6_pot_tlv][-] SRH segments out-of-bounds");
        return -1;
    }

    __u32 idx = srh->last_entry - srh->segments_left;
    idx = srh->last_entry - idx;
    if (idx < 0 || idx > segment_id_size)
        return -1;

    __u32 segment_offset = SRH_FIXED_HDR_LEN + (IPV6_LEN * idx);
    if ((void *)((__u8 *)srh + segment_offset + IPV6_LEN) > end) {
        bpf_printk("[seg6_pot_tlv][-] SID %u extends beyond packet", idx);
        return -1;
    }

    struct in6_addr sid;
    __builtin_memcpy(&sid, (__u8 *)srh + segment_offset, IPV6_LEN);

    struct pot_sid_key *pot_sid_key = bpf_map_lookup_elem(&seg6_pot_keys, &sid.s6_addr);
    if (!pot_sid_key) {
        bpf_printk("[seg6_pot_tlv][-] Cannot retrieve key for SID %pI6", sid.s6_addr);
        return -1;
    }

    bpf_printk("[seg6_pot_tlv][*] Computing keyed-hash for SID %pI6", sid.s6_addr);
    compute_tlv(tlv, pot_sid_key->key);

    bpf_printk("[seg6_pot_tlv][*] keyed-hash calculated for witness");
    return 0;
}

Para a prova do caminho completo temos que encadear as hash com a chave de cada segmento, por exemplo:

static __always_inline int chain_keys(struct pot_tlv *tlv, struct srh *srh, void *end)
{
    __u32 segment_id_size = srh_hdr_len(srh) / IPV6_LEN;
    if ((void *)((__u8 *)srh + SRH_FIXED_HDR_LEN + (IPV6_LEN * segment_id_size)) > end) {
        bpf_printk("[seg6_pot_tlv][-] SRH segments out-of-bounds");
        return -1;
    }

#pragma clang loop unroll(disable)
    for (__s16 i = SEG6_MAX_KEYS; i >= 0; i--) {
        if (i < 0) i = 0;
        if ((__u32)i >= segment_id_size) continue;

        __u32 segment_offset = SRH_FIXED_HDR_LEN + (IPV6_LEN * (__u32)i);
        if ((void *)((__u8 *)srh + segment_offset + IPV6_LEN) > end) {
            bpf_printk("[seg6_pot_tlv][-] SID %u extends beyond packet", i);
            return -1;
        }

        struct in6_addr sid;
        __builtin_memcpy(&sid, (__u8 *)srh + segment_offset, IPV6_LEN);

        struct pot_sid_key *pot_sid_key = bpf_map_lookup_elem(&seg6_pot_keys, &sid.s6_addr);
        if (!pot_sid_key) {
            bpf_printk("[seg6_pot_tlv][-] Cannot retrieve key for SID %pI6", sid.s6_addr);
            return -1;
        }

        bpf_printk("[seg6_pot_tlv][*] Computing keyed-hash for SID %pI6", sid.s6_addr);
        compute_tlv(tlv, pot_sid_key->key);
    }

    bpf_printk("[seg6_pot_tlv][*] keyed-hash calculated to each SID successfully");
    return 0;
}

Operações low-level

Nós primários (Source Routers)

#pragma clang loop unroll(full)
for (__u32 i = 0; i < MAX_PAYLOAD_SHIFT_LEN; ++i) {
    if (i >= POT_TLV_WIRE_LEN + HDR_ADDING_OFFSET) break;

    __u32 tail_ptr = len - 1 - i;
    __u32 head_ptr = len - 1 - i + POT_TLV_WIRE_LEN;

    __u8 byte;
    if (bpf_skb_load_bytes(skb, tail_ptr, &byte, 1) < 0) return -1;
    if (bpf_skb_store_bytes(skb, head_ptr, &byte, 1, 0) < 0) return -1;
}
#pragma clang loop unroll(full)
for (__u32 i = 0; i < MAX_PAYLOAD_SHIFT_LEN; ++i) {
    if (i >= POT_TLV_WIRE_LEN) break;

    __u32 head_ptr = offset - 1 - i + POT_TLV_WIRE_LEN;
    __u32 tail_ptr = offset - 1 - i + POT_TLV_WIRE_LEN + POT_TLV_WIRE_LEN;

    __u8 byte;
    if (bpf_skb_load_bytes(skb, head_ptr, &byte, 1) < 0) return -1;
    if (bpf_skb_store_bytes(skb, tail_ptr, &byte, 1, 0) < 0) return -1;
}
  • Aumentar o buffer do pacote de acordo com o tamanho do novo TLV (por exemplo, 32 bytes para SipHash).
  • Mover o payload do pacote existente para a frente, byte-a-byte, começando do ponto de inserção (imediatamente após o último segmento SRv6) até o final do pacote (isso cria espaço para o novo TLV sem sobrescrever os dados).
  • Gerar um novo nonce aleatório e calcular todos as provas criptográficas do PoT usando as chaves dos segmentos pelo caminho.
  • Escrever o novo TLV no pacote, colocando-o diretamente após o último segmento SRv6.
  • Atualizar os campos de comprimento do pacote para refletir a adição do TLV.
    *Não é necessário recalcular a soma de verificação L7, o que é benéfico para o desempenho.

Nós intermediários (Transit Routers)

  • Localizar o TLV, imediatamente após o último segmento SRv6 no buffer de pacotes.
  • Atualizar a prova criptográfica "witness" computando uma rodada com sua própria chave.
  • Reescrever o novo campo sobrescrevendo no buffer o valor anterior.

Nós finais (Destination Routers)

#pragma clang loop unroll(disable)
for (__u32 i = 0; i < max_shift; ++i) {
    void *tail_ptr = data + offset + i;
    void *head_ptr = tail_ptr + POT_TLV_WIRE_LEN;

    if (head_ptr + 1 > end) {
        bpf_printk("[seg6_pot_tlv][-] Shift bounds error i=%u", i);
        break;
    }

    *(volatile __u8 *)tail_ptr = *(volatile __u8 *)head_ptr;
}
  • Localizar o TLV, imediatamente após o último segmento SRv6 no buffer de pacotes.
  • Validar o TLV recalculando os resumos criptográficos e comparando-os com os valores recebidos para garantir a integridade do caminho.
  • Desloquar byte a byte o payload do pacote de volta para o local do pacote original, sobrescrevendo o TLV no buffer.
  • Ajustar os campos de comprimento do pacote para refletir a remoção do TLV.
    *Não é necessário recalcular a soma de verificação L7 após a remoção do TLV.

Resultados obtidos

Uma das coisas mais importantes aqui, é que buscamos resolver nosso problema, mas com eficiência. Uma boa métrica para isto, é o RTT (Round-Trip Time), onde podemos ver se se adição do novo TLV impactou muito na performance geral.

Environment: x86_64 Xeon E5-2683 v4 @ 2.10GHz, 128G RAM, Ubuntu 24.04
Tools: Clang 18.1.3, Kernel 6.11.0-19-generic, Realtek RTL8411 PCI Gigabit Ethernet


Se você curtiu a ideia, e quer acompanhar a evolução ou até contribuir com sugestões e melhorias, o projeto está disponível no GitHub, totalmente open-source (MIT). Ainda está em desenvolvimento, mas todo feedback é bem-vindo.

https://github.com/MuriloChianfa/srv6-pot-tlv

O projeto é bem low-level, então pra quem gosta é divertido, se puder deixar uma ⭐ por lá, já ajuda demais a dar visibilidade pro projeto e fortalecer a comunidade que se interessa por Segurança em Redes de Computadores.

Carregando publicação patrocinada...
2

Meus 2 cents:

Dei uma olhada por cima na ideia, me pareceu interessante mas um pouco complexa na implementacao.

Sem pensar muito, uma sugestao: e se no PoT usar "homomorphic encryption" ?

As chaves continuariam sendo distribuidas apenas nos endpoints (inicio/fim), mas o roteadores intermediarios poderiam apenas incrementar/modificar algum dado, e no final voce teria como verificar se o valor resultante eh o esperado - com um custo de implementacao mais simples.

2

Cara, eu não conhecia este conceito, mas pelo que li por cima, achei muito interessante, e parece sim se aplicar ao cenário.

Realmente uma desvantagem da abordagem utilizada na implementaçao atual é a troca de chaves com os nós intermediários.

Vou ler sobre mais sobre o homomorphic encryption e quem sabe fazer uma PoC também.
Valeu pela dica!