Nel capitolo precedente abbiamo introdotto eBPF. Qui vedremo due componenti fondamentali per capire gli exploit moderni su BPF: il verifier, che prova a garantire la sicurezza del programma, e il compilatore JIT, che lo traduce in codice macchina.
Il verifier
Cominciamo dal verifier di eBPF. Il codice si trova in kernel/bpf/verifier.c.
Il verifier analizza le istruzioni una per una e visita tutti i possibili flussi di controllo fino alle istruzioni exit. Il processo è diviso, a grandi linee, in due fasi: First Pass e Second Pass.
Nella prima fase controlla che il programma sia un DAG (Directed Acyclic Graph), cioè un grafo orientato senza cicli.
Questo basta a rifiutare programmi che:
- contengono più di
BPF_MAXINSNSistruzioni[1] - contengono loop
- contengono istruzioni non raggiungibili
- contengono salti fuori range o comunque non validi
Nella seconda fase il verifier ripercorre tutti i path e tiene traccia del tipo e dell’intervallo di valori che ogni registro può assumere.
Grazie a questi check vengono rifiutati, per esempio:
- usi di registri non inizializzati
- tentativi di ritornare un puntatore kernel
- scritture di puntatori kernel dentro una BPF map
- accessi tramite puntatori non validi
Prima fase
Il controllo del DAG è implementato nella funzione check_cfg. L’algoritmo è una depth-first search non ricorsiva.
check_cfg scorre il programma dall’inizio e, per ogni istruzione, invoca visit_insn. Questa funzione decide quale branch visitare per primo e lo spinge su uno stack interno. Il vero push avviene in push_insn, dove sono implementati sia il check dei salti fuori range sia il rilevamento dei cicli.
1 | if (w < 0 || w >= env->prog->len) { |
visit_insn visita un solo ramo per volta. Se l’istruzione è un branch condizionale come BPF_JEQ, nella prima visita viene spinto solo il primo ramo. Una volta esaurito quel path, il verifier torna all’istruzione di branch, visita l’altro ramo, e solo quando entrambi i lati sono stati esplorati restituisce DONE_EXPLORING.

A prima vista questo approccio sembra un po' macchinoso, ma ha un vantaggio: quando il verifier segnala un errore, riesce a produrre un traceback molto leggibile del path che ha seguito.
I programmi seguenti vengono tutti rifiutati già in questa prima fase:
1 | // C'e un'istruzione irraggiungibile |
1 | // C'e un salto fuori range |
1 | // C'e un loop |
Un salto all’indietro non è di per sé vietato: diventa illegale solo se crea un ciclo.
1 | struct bpf_insn insns[] = { |
Seconda fase
Per gli exploit sul verifier, la seconda fase è quella davvero importante.
Qui il codice principale è in do_check: il verifier segue l’evoluzione del tipo dei registri, degli offset e degli intervalli di valore.
Tracking dei tipi
Il verifier memorizza il “tipo” di ogni registro dentro la struttura bpf_reg_state.
Per esempio:
1 | BPF_MOV64_REG(BPF_REG_0, BPF_REG_10) |
La prima istruzione copia il frame pointer in R0, quindi R0 assume il tipo PTR_TO_STACK. La seconda sottrae 8, ma dato che il puntatore resta dentro lo stack BPF, il tipo non cambia.
Il tipo successivo dipende sia dall’istruzione sia dall’intervallo di valori possibili: per esempio, sommare due puntatori non è ammesso e non produce un nuovo puntatore valido.
Questo tracking è indispensabile. Se un valore scalare potesse essere reinterpretato liberamente come puntatore, avremmo AAR/AAW arbitrario. Se invece un helper che si aspetta un puntatore a contesto potesse ricevere un puntatore controllato dall’utente, potremmo forzarlo a leggere o scrivere in aree non previste.
Fra i tipi più comuni di enum bpf_reg_type troviamo:
| Tipo | Significato |
|---|---|
NOT_INIT |
non inizializzato |
SCALAR_VALUE |
valore scalare generico |
PTR_TO_CTX |
puntatore al contesto passato al programma BPF |
CONST_PTR_TO_MAP |
puntatore a una BPF map |
PTR_TO_MAP_VALUE |
puntatore al valore di una BPF map |
PTR_TO_MAP_KEY |
puntatore alla chiave di una BPF map |
PTR_TO_STACK |
puntatore allo stack BPF |
PTR_TO_MEM |
puntatore a memoria valida |
PTR_TO_FUNC |
puntatore a una funzione BPF |
Lo stato iniziale dei registri viene definito da init_reg_state.
Tracking dei valori
Oltre al tipo, il verifier mantiene anche intervalli di valore. In pratica, per ogni registro memorizza il minimo e il massimo che quel registro può assumere in quel punto del programma.
Per esempio, se al momento di eseguire R0 += R1 il verifier sa che R0 è in [10, 20] e R1 in [-2, 2], dopo l’operazione aggiorna R0 a [8, 22].
La logica è implementata in funzioni come adjust_reg_min_max_vals e adjust_scalar_min_max_vals.

Quando non conosci il valore concreto di un registro, lavori con un'astrazione. L'importante è che l'astrazione resti sound: se il verifier "crede" a un intervallo sbagliato, la sicurezza salta.
Per fare il tracking, ogni registro mantiene fra gli altri i seguenti campi:
| Variabile | Significato |
|---|---|
umin_value, umax_value |
minimo e massimo come intero unsigned 64-bit |
smin_value, smax_value |
minimo e massimo come intero signed 64-bit |
u32_min_value, u32_max_value |
minimo e massimo come unsigned 32-bit |
s32_min_value, s32_max_value |
minimo e massimo come signed 32-bit |
var_off |
informazione bit a bit sui bit noti e ignoti del registro |
var_off è un tnum, composto da mask e value.
mask ha a 1 i bit sconosciuti, value contiene i bit noti.
Per esempio, il valore 64-bit letto da una map all’inizio ha tutti i bit sconosciuti:
1 | (mask=0xffffffffffffffff; value=0x0) |
Se poi fai AND con 0xffff0000, i bit azzerati da quella maschera diventano noti:
1 | (mask=0xffff0000; value=0x0) |
Se in seguito sommi 0x12345, i 16 bit bassi diventano noti ma compare anche l’incertezza dovuta al carry:
1 | (mask=0x1ffff0000; value=0x2345) |
In quel momento i bounds corrispondenti diventano 0x1ffff0000, 0x1ffff2345, 0xffff0000, 0xffff2345.
Vediamo un caso concreto. Per BPF_ADD il verifier aggiorna il registro in questo modo:
1 | case BPF_ADD: |
Dentro scalar_min_max_add vengono considerati anche gli overflow signed e unsigned:
1 | static void scalar_min_max_add(struct bpf_reg_state *dst_reg, |
Gli intervalli prodotti da questo tracking vengono poi usati per verificare che gli accessi a stack, contesto e map restino entro limiti validi.
Per esempio, il controllo sullo stack avviene in check_stack_access_within_bounds. Se l’offset è un valore costante, il verifier calcola un offset preciso:
1 | if (tnum_is_const(reg->var_off)) { |
Se invece l’offset non è costante, usa l’intervallo minimo/massimo:
1 | } else { |
Infine verifica che sia l’estremo inferiore sia quello superiore siano validi:
1 | err = check_stack_slot_within_bounds(min_off, state, type); |
Lo stesso principio compare in moltissimi ambienti che fanno analisi statica o JIT ottimizzanti.

L'obiettivo è fare il più possibile in anticipo: se il verifier dimostra che il programma è sicuro, il JIT può generare codice molto diretto.
I programmi seguenti vengono rifiutati nella seconda fase:
1 | // Uso di un registro non inizializzato |
1 | // Leak di un puntatore kernel |
Considera ora questo programma, in cui la map contiene un valore di size 0x10:
1 | int mapfd = map_create(0x10, 1); |
map_lookup_elem restituisce un puntatore al valore della map, che salviamo in R7. Da quel valore leggiamo 8 byte in R6, poi mascheriamo R6 con 0b0111, quindi il suo intervallo diventa [0, 7].
Dato che il valore della map è grande 0x10, sommare un offset compreso fra 0 e 7 e poi leggere 8 byte resta un accesso valido. Il verifier quindi accetta il programma.
Se al posto di 0b0111 usi 0b1111, il verifier lo rifiuta:
1 | ... |
L’offset massimo è 15, e leggere 8 byte da lì uscirebbe dai limiti della map.
Non tutte le istruzioni sono trattate allo stesso modo dal tracking. Alcune, come BPF_NEG, degradano molto l’informazione disponibile. Per esempio il programma seguente, pur essendo di fatto sicuro, viene rifiutato:
1 | BPF_ALU64_IMM(BPF_AND, BPF_REG_6, 0b0111), // R6 &= 0b0111 |
Il punto chiave per un exploit è questo: se il verifier sbaglia il tracking, allora può approvare un accesso che in realtà va fuori dai limiti consentiti.
ALU sanitation
Negli anni gli exploit BPF hanno spinto gli sviluppatori a introdurre nuove mitigazioni. Una di queste è ALU sanitation.
Il problema di fondo era che un bug nel verifier poteva permettere di costruire un registro in cui il valore reale è diverso da quello “creduto” dal verifier. Se un puntatore a una map veniva sommato a un offset che il verifier pensava nullo ma in realtà non lo era, diventava possibile un out-of-bounds read/write.
Per ridurre questo rischio, nel 2019 è stata introdotta ALU sanitation.[2]
In eBPF le operazioni ammesse sui puntatori sono, di norma, solo addizioni e sottrazioni con scalari. Se lo scalare è noto come costante, il verifier trasforma l’operazione in una forma BPF_ALUxx_IMM. Per esempio, se R1 è un puntatore e R2 è un registro che il verifier crede costante 0, allora:
1 | BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_2) |
può essere riscritto in:
1 | BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 0) |
Questo meccanismo nasceva per mitigare Spectre, ma rende più difficile anche l’exploitation dei bug del verifier.
Se invece lo scalare non è costante, il verifier calcola un alu_limit, cioè il massimo offset ancora valido per quel puntatore, e inietta una piccola sequenza di istruzioni che azzera l’offset quando questo risulta fuori limite.
Nel caso generale la riscrittura è di questo tipo:
1 | BPF_MOV32_IMM(BPF_REG_AX, aux->alu_limit), |
L’idea è far diventare l’offset effettivo pari a 0 quando eccede il range consentito.
Cosa è permesso e cosa no
Ricapitolando in modo molto sintetico, nella seconda fase il verifier vieta fra le altre cose:
- sui registri:
- scritture su
R10(frame pointer) - letture di registri non inizializzati
- scritture su
- sul contesto:
- accessi fuori range
- accessi non autorizzati da
check_ctx_accesses
- sulle BPF map:
- letture e scritture oltre la dimensione del valore
- scrittura di puntatori
- sullo stack:
- accessi fuori dai 512 byte disponibili
- lettura di aree non inizializzate
- accessi non allineati a 8 byte (o 4 byte per 32-bit)
- scritture parziali di puntatori, come il solo low 32-bit
- sulla memoria in generale:
- dereferenziazione di puntatori che potrebbero essere
NULL
- dereferenziazione di puntatori che potrebbero essere
- sulle funzioni helper:
- passaggio di argomenti con tipo o range non compatibili con la definizione
Una differenza importante è che lo stack BPF può contenere puntatori e il verifier ne ricorda tipo e bounds. Le map, invece, non possono memorizzare puntatori come valori trusted. In cambio, lo stack è molto piccolo e più rigido sugli accessi.
JIT (Just-In-Time compiler)
Se il verifier approva un programma, allora il kernel assume che quel programma sia sicuro per ogni input possibile, almeno finché il verifier stesso è corretto. Questo permette al JIT di fare una traduzione quasi diretta in codice macchina.
Il JIT è implementato separatamente per ciascuna architettura. Nel caso x86-64, il codice si trova in arch/x86/net/bpf_jit_comp.c, in particolare nella funzione do_jit.
Per esempio, la traduzione dell’operazione di moltiplicazione è:
1 | case BPF_ALU | BPF_MUL | BPF_X: |
I byte 0x0F, 0xAF corrispondono all’istruzione imul.

Un verifier perfetto non basta se il JIT genera codice con una semantica diversa. Anche un bug lì può diventare exploitable.
Con questo abbiamo coperto i concetti interni di eBPF che servono per il capitolo successivo. Adesso possiamo passare a un exploit vero e proprio contro un bug del verifier.
(1) Confronto fra due puntatori
(2) Sottrazione fra due puntatori
(3) Scrittura di un puntatore nello stack BPF
(4) Scrittura di un puntatore dentro una BPF map
(5) Letture o scritture su indirizzi di stack validi ma non allineati