Linux

httpinspect: Como resolvi um problema de verifier BPF num servidor Kimsufi

O compilador sabia que cap >= 16. O verifier precisava de uma prova explícita de que cap > 0. O compilador eliminou-lhe a prova. Ninguém ficou contente. Como um volatile resolveu um conflito entre clang e o BPF verifier.

O compilador sabia que cap >= 16. O verifier precisava de uma prova explícita de que cap > 0. O compilador eliminou-lhe a prova. Ninguém ficou contente.

Há dias decidi montar um monitor de tráfego HTTP com eBPF num servidor OVH Kimsufi KS-4. A ideia era simples: usar TCX (Traffic Control eXpress) para observar passivamente pedidos HTTP a passar pela interface de rede, extrair as linhas de request e as status lines de response, e mandar tudo para um ring buffer no userspace.

O que parecia uma tarde de trabalho transformou-se numa viagem de várias horas pelo mundo do BPF verifier, otimizações de compilador, e uma interação inesperada entre o clang e o kernel que me fez aprender mais sobre eBPF do que qualquer tutorial.

httpinspect thumbnail

Terminal com erro do BPF verifier


O que é o httpinspect

O httpinspect é um programa eBPF que se liga a todas as interfaces de rede (ingress e egress) via TCX e filtra tráfego HTTP diretamente no kernel:

  • Captura linhas de request HTTP (GET, POST, PUT, etc.) e status lines de response
  • Filtra no kernel: só passam segments cujo payload começa com um token HTTP conhecido. ACKs, SYN, FIN, e tráfego não-HTTP nunca saem do kernel
  • Lida com IPv4 e IPv6, Ethernet e raw-IP
  • Usa bpf_skb_load_bytes para ler o payload de forma segura (funciona com GSO e paged frags)
  • Envia eventos via ring buffer (8 MB) para o userspace

O userspace, escrito em JavaScript com o runtime yeet, recebe os eventos e agrega por endpoint (method + host + path).

[Kernel]                       [Userspace]
TCX ingress/egress             yeet runtime
  |                              |
httptop.bpf.o (filter)         verify.js (parse + stats)
  |                              |
Ring Buffer ----------------->  subscribe events

O servidor: Kimsufi KS-4

O servidor onde isto corre não é propriamente um monstro. É um Atom N2800 lançado em 2012, dois cores físicos com Hyper-Threading. É o tipo de CPU que encontras num NAS antigo. A RAM está no limite, o swap está completamente saturado. Cada operação que aloca memória causa page faults para o disco SATA a 66 MB/s.

ComponenteEspecificação
CPUIntel Atom N2800 @ 1.86GHz (2 cores / 4 threads)
RAM3.8 GB DDR3
Swap512 MB (completamente cheio)
DiscoSATA 1.8 TB (~66 MB/s leitura sequencial)
KernelDebian 13, 6.12.90
Stackclang 19.1.7, bpftool 7.5.0, libbpf 1.5, yeet 0.19.2

Não é o ideal para compilar programas BPF com -O2, mas a vida é feita de escolhas.


O problema

Depois de escrever o programa e compilar, ao carregar o BPF object com o yeet:

R4 invalid zero-sized read: u64=[0,510]
processed 206 insns (limit 1000000)

O verifier do kernel rejeitou o programa. A instrução bpf_skb_load_bytes recebia um tamanho [0, 510] e zero não é permitido.

O código problemático era este:

__u32 plen = skb->len - poff;
if (plen < MIN_REQ)            // MIN_REQ = 16
    return TCX_NEXT;

// ... detecao HTTP ...

__u32 cap = plen;
if (cap > limit)
    cap = limit;
cap &= (DATA_MAX - 1);         // 511
if (cap == 0)                  // ELIMINADO PELO COMPILADOR
    return TCX_NEXT;

bpf_skb_load_bytes(skb, poff, e->data, cap);  // VERIFIER REJEITA

Eu tinha um if (cap == 0). O verifier pedia um check explícito de que cap > 0. O check estava lá. O que se passava?


A raiz do problema

O compilador clang, com -O2, é inteligente. Talvez demasiado inteligente.

Depois do check if (plen < MIN_REQ), o compilador sabe que plen >= 16. Depois, quando fazemos cap = plen e limitamos a 511, o compilador infere que cap >= min(16, 511) = 16. Portanto cap == 0 é impossível. O compilador elimina o branch if (cap == 0) como dead code.

Lá para o raciocínio dele está correto: se chegamos aquele ponto do código, cap nunca pode ser zero. O problema é que o BPF verifier não faz essa análise de ranges. Ele vê cap como um escalar com range [0, 510] (do mask) e exige uma prova explícita de que cap > 0 antes de chamar bpf_skb_load_bytes.

O compilador removeu-lhe a prova. O verifier chumbou. Ninguém fica contente.


A caça ao bug: 6 tentativas falhadas

Tentativa 1: Check explícito de range

Substituir o mask por um range check explícito:

cap &= (DATA_MAX - 1);
if (cap >= DATA_MAX)
    cap = DATA_MAX - 1;
cap = cap;
if (cap == 0)
    return TCX_NEXT;

Resultado: Falhou. O compilador vê que cap >= 16 e elimina o zero check na mesma.

Tentativa 2: Calcular cap depois do ringbuf_reserve

Mover o cálculo de cap para depois do ringbuf_reserve para quebrar a ligação ao check anterior.

Resultado: Falhou. O compilador ainda sabe que plen >= 16 e propaga esse range.

Tentativa 3: Fresh computation
__u32 cap = skb->len - poff;

Resultado: Falhou. O clang faz Common Subexpression Elimination (CSE) e apercebe-se que skb->len - poff já foi calculado em plen. Liga o range ao plen anterior.

Tentativa 4: asm volatile
asm volatile("" : "+r"(cap));

Resultado: Falhou, e de forma espetacular. O compilador gerou dois registos diferentes para a mesma variável: R1 (narrowed para [1,510] pelo zero check) e R4 (ainda [0,510]). O bpf_skb_load_bytes usou R4, o que não foi narrowed.

Tentativa 5: Função noinline
__attribute__((noinline))
static __u32 get_cap(struct __sk_buff *skb, __u32 poff) {
    return skb->len - poff;
}

Resultado: Falhou. O clang faz análise inter-procedural e vê que a função retorna um valor sempre >= 16. Elimina o zero check no caller.

Tentativa 6: Compilar com -O1
make CFLAGS="-O1 -g -Wall ..."

Resultado: Falhou. Mesmo com -O1, o clang mantém a análise de ranges e elimina o zero check. A análise de ranges é uma otimização "básica" que não está nos passes de -O2.

Neste ponto comecei a pensar que talvez o problema não tivesse solução. Seis tentativas, todas falhadas.


A solução: volatile

A sétima tentativa foi a que resultou. Usar a palavra-chave volatile para forçar o compilador a tratar a variável como opaca.

volatile __u32 vcap = skb->len - poff;
__u32 cap = vcap;

O volatile diz ao compilador: "confia em mim, este valor pode mudar a qualquer momento, não faças suposições sobre ele." O compilador não pode propagar o range de plen para vcap, porque volatile implica que o valor é lido da stack de cada vez.

O código final:

struct http_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
    return TCX_NEXT;
e->ts        = bpf_ktime_get_ns();
e->sport     = bpf_ntohs(sport);
e->dport     = bpf_ntohs(dport);
e->seq       = bpf_ntohl(seq);
e->family    = family;
e->dir       = dir;
e->kind      = kind;
e->total_len = plen;

/* volatile quebra a CSE do compilador */
volatile __u32 vcap = skb->len - poff;
__u32 cap = vcap;
if (cap > limit)
    cap = limit;
if (cap == 0) {
    bpf_ringbuf_discard(e, 0);
    return TCX_NEXT;
}
e->captured = cap;
if (bpf_skb_load_bytes(skb, poff, e->data, cap) < 0) {
    bpf_ringbuf_discard(e, 0);
    return TCX_NEXT;
}
bpf_ringbuf_submit(e, 0);

Porque funciona:

  1. volatile quebra a propagação de ranges: o compilador não sabe nada sobre vcap
  2. O zero check é mantido: o compilador não pode provar que cap >= 16
  3. O verifier vê o check: pode narrow o range de [0, limit] para [1, limit]
  4. Nenhuma penalidade real: o volatile só impede a propagação de ranges

O patch final: 14 linhas adicionadas, 7 removidas. O Makefile ficou inalterado, tudo compila com -O2.

$ yeet run verify.js
[verify] attaching to ifindexes 1,2

O programa carrega, o verifier aceita.


Lições aprendidas

  1. O compilador não é teu amigo (no BPF). Em C normal, ter o compilador a eliminar dead code é ótimo. Em BPF, onde o verifier precisa de provas explícitas, pode ser um problema. É preciso pensar nas duas fases: o que o compilador faz, e o que o verifier precisa.

  2. volatile é a última barreira. Depois de tentar asm, noinline, -O1, a única coisa que realmente quebrou o tracking do compilador foi volatile. Não há otimização que ignore volatile.

  3. O verifier é estúpido (por design). O verifier BPF é intencionalmente conservador. Não faz análise sofisticada de ranges porque tem de ser rápido e seguro. Cabe ao programador dar-lhe as provas de que precisa, de forma que o compilador não remova.

  4. Hardware velho, problemas novos. O Atom N2800 não tem nada de especial, mas corre um kernel 6.12 com BPF moderno. É possível fazer coisas interessantes em servidores antigos.

  5. CSE é mais agressivo do que parece. Mesmo com uma conta "fresca" (skb->len - poff), o clang liga expressões equivalentes. Só o volatile quebra essa ligação.


Conclusão

O httpinspect está funcional no Kimsufi. O patch volatile é mínimo e resolve um problema subtil que cruzou otimizações de compilador com regras de verificação de segurança do kernel.

O programa compila com -O2 sem alterações ao Makefile, carrega e anexa as interfaces, e filtra tráfego HTTP no kernel com overhead mínimo.

Se estiveres a ler isto porque o teu programa BPF foi rejeitado com um erro de zero-sized read e já tentaste de tudo, experimenta volatile. Às vezes a ferramenta mais simples é a que resolve.

Recursos adicionais

Partilhar

Comentários 0

Ainda sem comentários — sê o primeiro.