
CVE-2026-31413: bug de solidez do verificador BPF - escape de contêiner
Encontrei um bug de solidez no verificador BPF do Linux - um + 1 em uma chamada push_stack() que faz o verificador pular uma instrução ALU em um caminho bifurcado. Para BPF_OR, isso significa que o verificador rastreia dst = 0 enquanto a CPU calcula 0 | K = K. Escrevi um escape de contêiner completo: leitura/escrita fora dos limites (OOB) a partir de um mapa BPF, sequestro de vtable, sobrescrita de modprobe_path, root no host. Em seguida, criei uma série de dois patches - uma correção de um caractere no verificador e 90 linhas de selftests - e consegui mesclá-la no mainline.
📹 Vídeo de demonstração do escape de contêiner
| CVE | CVE-2026-31413 |
| Classe do bug | Solidez do verificador - divergência de valor de registrador |
| Causa raiz | push_stack(env, env->insn_idx + 1, ...) pula instrução ALU no caminho bifurcado |
| Introduzido | bffacdb80b93 - Linux 7.0-rc1 (Jan 14, 2026) |
| Corrigido | c845894ebd6f - Linux 7.0-rc5 (Mar 22, 2026) |
| Afetado | 6.12.75+ (backport estável dea9989a3f) até 7.0-rc4 |
| Impacto | R/W arbitrário no kernel → escape de contêiner → root no host |
| Requerido | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| Correção | Um caractere: insn_idx + 1 → insn_idx |
maybe_fork_scalars() bifurca o estado do verificador quando vê ARSH + AND/OR com uma constante. O caminho empurrado recebe dst = 0 e pula a instrução ALU. Para AND isso é válido: 0 & K = 0. Para OR está errado: 0 | K = K, não 0.
O verificador pensa que o registrador é zero. A CPU tem K. Usei isso para construir leitura/escrita OOB arbitrária a partir de um valor de mapa BPF, vazei o endereço de kernel do mapa, construí uma vtable bpf_map_ops falsa, redirecionei map_push_elem através de array_map_get_next_key para escrita arbitrária e sobrescrevi modprobe_path. Ao disparar um formato binário desconhecido, o kernel executa meu script como root. Em um contêiner, escape completo do host.
Série de dois patches: uma correção de um caractere no verificador mais 90 linhas de selftests BPF cobrindo OR vs bifurcação AND. Mesclada por Alexei Starovoitov em 22 de março. CVE-2026-31413 atribuída por Greg Kroah-Hartman em 12 de abril.
eBPF permite carregar pequenos programas no kernel - filtros de pacotes, hooks de rastreamento, políticas de segurança - sem compilar um módulo de kernel. O problema é que você está injetando código no ring 0. Se esse código tiver um bug, é um bug do kernel.
Então, antes de qualquer programa BPF executar, o verificador do kernel simula todos os possíveis caminhos de execução. Ele rastreia o que cada registrador contém (um ponteiro? um escalar? qual intervalo?), verifica cada acesso à memória em relação aos limites do mapa e rejeita qualquer coisa que possa ler ou escrever fora dos limites. Se o verificador diz que um programa é seguro, o JIT o compila para código de máquina nativo e o executa com privilégio total de kernel. Não há verificações de limites em tempo de execução depois desse ponto. O verificador é o limite de segurança.
É por isso que bugs de solidez do verificador são diferentes de corrupção de memória normal. Com um heap overflow ou UAF, você obtém uma primitiva de corrupção e precisa trabalhar a partir dela - fazer spray no heap, preparar objetos, disputar uma janela. Com um bug de verificador, você faz o kernel acreditar em uma mentira sobre o valor de um registrador. Toda verificação de limites que depende desse registrador passa. O kernel aprovou seu acesso OOB. Ele o executa sem questionar. Se você conseguir alinhar o estado do registrador corretamente, obtém uma primitiva limpa e confiável a partir disso.
Eu estava auditando maybe_fork_scalars() - código novo, adicionado em janeiro de 2026 em bffacdb80b93. A bifurcação de estado é sempre interessante porque é onde o verificador se divide em caminhos de exploração paralelos, e se qualquer caminho rastreia um valor incorreto, tudo a jusante desse caminho fica não sólido.
A função bifurca quando vê ARSH + AND/OR com uma fonte constante. O caminho empurrado recebe dst = 0 e pula a instrução ALU. Eu estava lendo a linha push_stack(env, env->insn_idx + 1, ...) e caiu a ficha na hora - o + 1 significa que o caminho empurrado nunca executa a operação ALU. Para AND, 0 & K = 0, então pular é válido. Para OR, 0 | K = K. O caminho empurrado pensa que o resultado é 0 quando na verdade é K.
Escrevi um programa BPF naquela noite. ARSH 63 para obter {0, -1}, OR com uma constante, desvio condicional para separar os caminhos do verificador, e então adicionei o registrador "zero" a um ponteiro de mapa. O verificador aprovou map_value + 0. A CPU acessou map_value + K. O KASAN confirmou o acesso fora dos limites nos testes.
Leitura/escrita OOB na manhã seguinte. Escape de contêiner na noite seguinte. Usei Claude (Opus 4.5) durante todo o processo - para analisar a lógica de bifurcação de estado do verificador, gerar ideias de primitivas de exploração e transformar o OOB em uma cadeia completa de escape. A abordagem de sequestro de vtable surgiu de uma troca em que Claude percorreu os ponteiros de função de bpf_map_ops procurando gadgets chamáveis.
O commit bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier") foi incorporado em 14 de janeiro de 2026 no 7.0-rc1. Alexei Starovoitov, co-desenvolvido por Puranjay Mohan. Ele adicionou maybe_fork_scalars() para lidar com um padrão do DAGCombiner do LLVM:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVM converte `select_cc setlt X, 0, A, 0` em `sra + and`. Após o deslocamento
aritmético para a direita, o registrador é ou `0` (entrada não negativa) ou `-1` (todos os bits em 1).
O AND com uma constante resulta em `0` ou `K`.
O verificador não consegue rastrear `{0, K}` em um único `bpf_reg_state` — seu intervalo com sinal
`[0, K]` é uma superaproximação, e isso estava fazendo com que ele rejeitasse programas Cilium
válidos. A correção: bifurcar o estado do verificador. Um caminho explora `dst = 0`, o
outro `dst = -1`, cada um rastreando o valor preciso.
A implementação:```c
static int maybe_fork_scalars(struct bpf_verifier_env *env,
struct bpf_insn *insn,
struct bpf_reg_state *dst_reg)
{
// ... condition check: dst range is [-1, 0], src is constant ...
branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
// ^^^^^^^^^^^^
// pushed path resumes AFTER the ALU insn
if (IS_ERR(branch))
return PTR_ERR(branch);
regs = branch->frame[branch->curframe]->regs;
__mark_reg_known(®s[insn->dst_reg], 0); // pushed: dst = 0
__mark_reg_known(dst_reg, -1ull); // current: dst = -1
return 0;
}
Duas coisas acontecem no caminho empurrado:
0insn_idx + 1 - a instrução após a operação ALUPara BPF_AND: dst = 0, pula a operação AND. Em tempo de execução: 0 & K = 0. Correspondência. Sólido.
Para BPF_OR: dst = 0, pula a operação OR. Em tempo de execução: 0 | K = K. Divergência.
O verificador vê 0. A CPU tem K. Não sólido.
A função não verifica o opcode. Ela foi escrita para AND - onde pular
a instrução é o mesmo que executá-la com dst = 0 - e foi aplicada também a
OR. Para OR, essa equivalência não se sustenta.