
CVE-2026-31413: bug di correttezza del verifier BPF - container escape
Ho trovato un bug di soundness nel verificatore BPF di Linux - un + 1 in una chiamata a push_stack() che fa saltare al verificatore un'istruzione ALU su un percorso forkato. Per BPF_OR, questo significa che il verificatore traccia dst = 0 mentre la CPU calcola 0 | K = K. Ho scritto una fuga completa dal container: lettura/scrittura OOB da una mappa BPF, hijack della vtable, sovrascrittura di modprobe_path, root sull'host. Poi ho creato una serie di due patch - una correzione di un carattere al verificatore e 90 righe di selftest - e l'ho fatta integrare nel ramo mainline.
📹 Video dimostrativo della fuga dal container
| CVE | CVE-2026-31413 |
| Classe del bug | Soundness del verificatore - divergenza del valore dei registri |
| Causa principale | push_stack(env, env->insn_idx + 1, ...) salta l'istruzione ALU sul percorso forkato |
| Introdotto | bffacdb80b93 - Linux 7.0-rc1 (14 gennaio 2026) |
| Corretto | c845894ebd6f - Linux 7.0-rc5 (22 marzo 2026) |
| Versioni affette | 6.12.75+ (backport stabile dea9989a3f) fino a 7.0-rc4 |
| Impatto | Lettura/scrittura arbitraria del kernel → fuga dal container → root sull'host |
| Requisiti | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| Correzione | Un carattere: insn_idx + 1 → insn_idx |
maybe_fork_scalars() fa il fork dello stato del verificatore quando vede ARSH + AND/OR con una costante. Il percorso pushato riceve dst = 0 e salta l'istruzione ALU. Per AND va bene: 0 & K = 0. Per OR è sbagliato: 0 | K = K, non 0.
Il verificatore pensa che il registro sia zero. La CPU ha K. L'ho usato per costruire una lettura/scrittura arbitraria OOB dal valore di una mappa BPF, ho leakato l'indirizzo kernel della mappa, ho costruito una vtable bpf_map_ops finta, ho reindirizzato map_push_elem tramite array_map_get_next_key per la scrittura arbitraria, e ho sovrascritto modprobe_path. Attivando un formato binario sconosciuto, il kernel esegue il mio script come root. In un container, fuga completa dall'host.
Serie di due patch: una correzione di un carattere al verificatore più 90 righe di selftest BPF che coprono il forking tra OR e AND. Integrata da Alexei Starovoitov il 22 marzo. CVE-2026-31413 assegnato da Greg Kroah-Hartman il 12 aprile.
eBPF ti permette di caricare piccoli programmi nel kernel - filtri di pacchetti, hook di tracing, policy di sicurezza - senza compilare un modulo del kernel. Il problema è che stai iniettando codice nel ring 0. Se quel codice ha un bug, è un bug del kernel.
Quindi, prima che qualsiasi programma BPF venga eseguito, il verificatore del kernel simula ogni possibile percorso di esecuzione. Tiene traccia di cosa contiene ogni registro (un puntatore? uno scalare? con quale intervallo?), verifica ogni accesso in memoria rispetto ai limiti della mappa e rifiuta qualsiasi cosa che possa leggere o scrivere fuori dai limiti. Se il verificatore dice che un programma è sicuro, il JIT lo compila in codice macchina nativo e lo esegue con pieni privilegi kernel. Dopo quel punto non ci sono controlli runtime dei limiti. Il verificatore è il confine di sicurezza.
Questo è il motivo per cui i bug di soundness del verificatore sono diversi dalla normale corruzione di memoria. Con un heap overflow o una UAF ottieni una primitiva di corruzione e devi lavorare da lì - sprayare l'heap, groomare gli oggetti, vincere una race. Con un bug del verificatore, fai credere al kernel una bugia sul valore di un registro. Ogni controllo dei limiti che dipende da quel registro passa. Il kernel ha approvato il tuo accesso OOB. Lo esegue senza fiatare. Se riesci ad allineare correttamente lo stato dei registri, ne ottieni una primitiva pulita e affidabile.
Stavo facendo auditing di maybe_fork_scalars() - codice nuovo, aggiunto a gennaio 2026 in bffacdb80b93. Il forking dello stato è sempre interessante perché è lì che il verificatore si divide in percorsi di esplorazione paralleli, e se un percorso traccia un valore errato, tutto ciò che sta a valle di quel percorso è unsound.
La funzione fa il fork quando vede ARSH + AND/OR con una sorgente costante. Il percorso pushato riceve dst = 0 e salta l'istruzione ALU. Stavo leggendo la riga push_stack(env, env->insn_idx + 1, ...) e mi è scattato subito qualcosa: il + 1 significa che il percorso pushato non esegue mai l'operazione ALU. Per AND, 0 & K = 0, quindi saltarla va bene. Per OR, 0 | K = K. Il percorso pushato pensa che il risultato sia 0 quando in realtà è K.
Quella sera ho scritto un programma BPF. ARSH 63 per ottenere {0, -1}, OR con una costante, ramo condizionale per separare i percorsi del verificatore, poi ho aggiunto il registro "zero" a un puntatore a una mappa. Il verificatore ha approvato map_value + 0. La CPU ha acceduto a map_value + K. KASAN ha confermato l'accesso fuori dai limiti nei test.
Lettura/scrittura OOB la mattina dopo. Fuga dal container la notte successiva. Ho usato Claude (Opus 4.5) per tutto il percorso - per analizzare la logica di forking dello stato del verificatore, fare brainstorming sulle primitive di exploitation e trasformare l'OOB in una catena di fuga completa. L'approccio dell'hijack della vtable è nato da un botta e risposta in cui Claude ha esaminato i puntatori a funzione di bpf_map_ops cercando gadget richiamabili.
Il commit bffacdb80b93 ("bpf: Recognize special arithmetic shift in the verifier") è arrivato il 14 gennaio 2026 in 7.0-rc1. Alexei Starovoitov, co-sviluppato da Puranjay Mohan. Ha aggiunto maybe_fork_scalars() per gestire un pattern di LLVM DAGCombiner:```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVM abbassa `select_cc setlt X, 0, A, 0` a `sra + and`. Dopo lo scorrimento aritmetico a destra, il registro è `0` (input non negativo) oppure `-1` (tutti a 1). L'AND con una costante dà `0` o `K`.
Il verifier non può tracciare `{0, K}` in un singolo `bpf_reg_state` - il suo intervallo con segno `[0, K]` è una sovra-approssimazione, e questo faceva sì che rifiutasse programmi Cilium validi. La correzione: biforcare lo stato del verifier. Un percorso esplora `dst = 0`, l'altro `dst = -1`, ciascuno tracciando il valore preciso.
L'implementazione:```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;
}
Sul percorso push accadono due cose:
0insn_idx + 1 - l'istruzione successiva all'operazione ALUPer BPF_AND: dst = 0, salta l'AND. A runtime: 0 & K = 0. Corrisponde. Corretto.
Per BPF_OR: dst = 0, salta l'OR. A runtime: 0 | K = K. Mancata corrispondenza.
Il verificatore vede 0. La CPU ha K. Non corretto.
La funzione non controlla l'opcode. È stata scritta per AND - dove saltare
l'istruzione equivale a eseguirla con dst = 0 - ed è stata applicata anche a
OR. Per OR, quell'equivalenza non vale.