
Use-After-Free in Netfilter nf_tables durante l'elaborazione di richieste batch CVE-2023-32233

Il codice interessato proviene dal kernel Linux ufficiale da https://kernel.org/ e fa parte del componente Netfilter nf_tables (net/netfilter/nf_tables_api.c).
Netfilter nf_tables consente di aggiornare la propria configurazione come operazione atomica. Quando si utilizza questa funzionalità, i client in spazio utente inviano richieste batch contenenti un elenco di operazioni di base. Netfilter nf_tables elabora quindi tutte le operazioni all'interno del batch come un'unica transazione. Durante l'elaborazione del batch, Netfilter nf_tables controlla gli aggiornamenti dello stato della configurazione per garantire che ciascuna operazione di base successiva sia valida e ciò tiene conto anche degli aggiornamenti di stato di tutte le operazioni precedenti all'interno del batch. Tuttavia, il controllo attualmente implementato è insufficiente.
Nel nostro scenario specifico partiamo da una configurazione Netfilter
nf_tables che ha una nft_rule con espressione lookup su un nft_set
anonimo, e dove l'nft_set anonimo contiene alcuni elementi. Successivamente,
inviamo una richiesta batch contenente le seguenti due operazioni di base:
NFT_MSG_DELRULE per eliminare la nft_rule.lookup e
l'nft_set anonimo.NFT_MSG_DELSETELEM per eliminare uno qualsiasi degli elementi
dell'nft_set anonimo eliminato.La versione attuale di Netfilter nf_tables accetta la richiesta batch sopra
descritta. Chiama quindi nf_tables_commit_release() che aggiunge le risorse
rilasciate a nf_tables_destroy_list. La nf_tables_destroy_list viene poi
elaborata da nf_tables_trans_destroy_work() che prima dealloca le risorse
relative all'operazione NFT_MSG_DELRULE chiamando:
nft_commit_release()
nf_tables_rule_destroy()
nf_tables_expr_destroy()
expr->ops->destroy() that points to nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() that deallocates memory used by `nft_set`
prima di elaborare l'operazione NFT_MSG_DELSETELEM, dove il riferimento
all'nft_set deallocato viene accesso tramite nft_trans_elem_set() durante
le seguenti chiamate:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
All'interno di nft_set_elem_ext() sopra, la posizione di memoria dell'nft_set
deallocato viene accessa per determinare la posizione di nft_set_ext:
static inline struct nft_set_ext *nft_set_elem_ext(const struct nft_set *set,
void *elem)
{
return elem + set->ops->elemsize;
}
per le operazioni che seguono. Quindi, ogni volta che il valore di
set->ops->elemsize viene corrotto, una certa posizione di memoria inaspettata
potrebbe essere interpretata come un elenco di nft_expr da distruggere:
static void nf_tables_set_elem_destroy(const struct nft_ctx *ctx,
const struct nft_set *set, void *elem)
{
struct nft_set_ext *ext = nft_set_elem_ext(set, elem);
if (nft_set_ext_exists(ext, NFT_SET_EXT_EXPRESSIONS))
nft_set_elem_expr_destroy(ctx, nft_set_ext_expr(ext));
Sfruttare la vulnerabilità sopra descritta richiede di vincere una race condition con nf_tables_trans_destroy_work() che viene eseguito da un thread worker in background del kernel Linux. Questo sembra complicare lo sfruttamento pratico ancor prima di considerare le mitigazioni esistenti, come l'hardening dell'allocatore slab del kernel, la randomizzazione del layout dello spazio degli indirizzi del kernel (KASLR) e specialmente l'integrità del flusso di controllo. Tuttavia, il PoC allegato dimostra che è comunque possibile ottenere uno sfruttamento ragionevolmente affidabile nella pratica.
Per sfruttare la vulnerabilità dobbiamo modificare il contenuto della memoria
dell'nft_set dopo che è stato deallocato in nf_tables_rule_destroy(), ma
prima che venga utilizzato in nf_tables_set_elem_destroy(). Sia
nf_tables_rule_destroy() che nf_tables_set_elem_destroy() vengono chiamati
all'interno di una singola invocazione di nf_tables_trans_destroy_work() che
viene eseguita da un thread worker in background del kernel Linux. Inoltre, il
chunk di memoria deallocato è solitamente disponibile per il riutilizzo solo
dallo stesso core della CPU.
Quando si compete con nf_tables_trans_destroy_work(), miglioriamo le nostre
possibilità aggiungendo un ritardo controllato per il thread worker in
background tra le chiamate a nf_tables_rule_destroy() e
nf_tables_set_elem_destroy(). Per questo inseriamo un'operazione aggiuntiva
per distruggere un altro nft_set contenente un gran numero di elementi.
Inoltre, manteniamo occupati tutti gli altri core della CPU, in modo che il
thread worker in background venga probabilmente schedulato su un core specifico
della CPU, così possiamo tentare di allocare una nuova struttura dallo stesso
core della CPU subito dopo che ha deallocato nft_set in
nf_tables_rule_destroy(). Il nostro obiettivo è allocare un nuovo nft_set di
tipo diverso per riutilizzare la posizione di memoria dell'nft_set deallocato
in nf_tables_rule_destroy().
Il nuovo tipo di nft_set viene selezionato per utilizzare un valore diverso
per set->ops->elemsize. Quindi, quando il thread worker in background chiama
finalmente nf_tables_set_elem_destroy() per elaborare l'operazione
NFT_MSG_DELSETELEM, interpreta il suo argomento elem in modo errato,
cosicché il nft_set_ext *ext corrotto si trova alcuni byte dopo la posizione
corretta. Ciò significa che determinati campi dati controllati dall'utente
dell'nft_set_ext originale vengono ora interpretati come intestazioni,
risultando in una type confusion.
Un modo per abusare di questa type confusion è creare intestazioni nft_set_ext
corrotte con valori di offset tali che nf_tables_set_elem_destroy() interpreti
il contenuto di eventuali blocchi di memoria adiacenti come l'elenco di
nft_expr da distruggere tramite le seguenti chiamate:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
A questo punto dello sfruttamento, non abbiamo ancora dettagli del layout della
memoria del kernel. Quindi non è possibile creare indirizzi puntatori assoluti.
Tuttavia, quando creiamo le intestazioni nft_set_ext corrotte possiamo
comunque utilizzare offset fuori range, in modo che expr->ops->destroy()
venga chiamato su certi nft_expr validi nei chunk di memoria adiacenti.
Per questo spruzziamo espressioni nft_log, con NFTA_LOG_PREFIX controllato.
Quel nft_log->prefix viene quindi deallocato da nft_log_destroy() una volta
che expr->ops->destroy() viene chiamato:
static void nft_log_destroy(const struct nft_ctx *ctx,
const struct nft_expr *expr)
{
struct nft_log *priv = nft_expr_priv(expr);
struct nf_loginfo *li = &priv->loginfo;
if (priv->prefix != nft_log_null_prefix)
kfree(priv->prefix);
Nota che possiamo ancora accedere e persino deallocare nuovamente questa
memoria tramite l'altro riferimento dall'espressione nft_log spruzzata.
Inoltre, possiamo anche controllare la dimensione di nft_log->prefix, in modo
che possa essere allocato da qualsiasi slab kmalloc-{8, ..., 192}. Infine, la
memoria referenziata viene interpretata come una stringa di caratteri dal
kernel, quindi non c'è bisogno di preoccuparsi di corruzioni quando
sovrapponiamo oggetti diversi su di essa. Questo è essenzialmente game over.
Un inconveniente è che qualsiasi carattere NULL termina nft_log->prefix,
quindi non possiamo leggere oltre i byte NULL quando trapeliamo il contenuto
della memoria. Questo viene affrontato nel passo successivo, dove allochiamo
nft_object->udata per riutilizzare il chunk di memoria di nft_log->prefix
e distruggiamo l'espressione nft_log. Questo dealloca la memoria di
nft_object->udata, ma ora possiamo ancora utilizzare il puntatore dangling
nft_object->udata per trapelare il contenuto della memoria senza restrizioni
sui byte NULL.
Cercando strutture adatte per i passaggi successivi, abbiamo deciso per
nft_expr allocato da nft_dynset_new(). Queste risiedono nelle stesse slab di
nft_log->prefix e nft_object->udata. Inoltre, abbiamo un controllo
ragionevole sulla dimensione di allocazione, in modo che in seguito possiamo
facilmente passare da slab di dimensioni diverse se necessario.
Per utilizzare queste strutture, creiamo un filtro pacchetti con l'espressione
nft_dynset. E quando inviamo qualsiasi pacchetto sull'interfaccia di
loopback, l'espressione nft_dynset chiama nft_dynset_new() per creare nuovi
elementi per l'nft_set associato. Gli elementi creati sono espressioni
stateful dei seguenti tipi:
nft_counter per ottenere la posizione di nf_tables.ko nella memoria del kernel.
La struttura include un puntatore a nft_counter_ops nel modulo del kernel
nf_tables.ko. Trapeliamo questo puntatore leggendo nft_object->udata.
nft_quota per lettura e scrittura arbitraria della memoria.
Possiamo deallocare e riallocare ripetutamente nft_object->udata per
modificare il puntatore nft_quota->consumed. Successivamente, eseguiamo
l'operazione NFT_MSG_GETSETELEM che chiama nft_quota_do_dump() per leggere
il contenuto della memoria referenziata e passa il risultato come attributo
NFTA_QUOTA_CONSUMED nel risultato. Per quanto riguarda le scritture,
inviamo semplicemente pacchetti sull'interfaccia di loopback, dove
nft_quota_do_eval() chiama:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
Utilizziamo la lettura arbitraria della memoria sopra descritta per ottenere l'indirizzo di base del kernel core. E poi procediamo a modificare la sottostringa "sbin" del percorso "/sbin/modprobe", in modo che venga sostituita con "/tmp". Il percorso risultante "//tmp/modprobe" viene quindi utilizzato dal kernel per avviare un processo con privilegi di root, dove controlliamo il contenuto del file.
Nota che non abbiamo fatto alcuno sforzo intenzionale per bypassare l'integrità del flusso di controllo. Tuttavia, per ciascuno dei passaggi di sfruttamento, abbiamo scelto consapevolmente le primitive più flessibili e robuste. Si scopre che la nostra selezione ha in qualche modo evitato tutte le primitive che potrebbero essere bloccate dall'integrità del flusso di controllo. Siamo ora curiosi di confermare con dei test che l'exploit risultante funzioni davvero contro sistemi con mitigazioni di integrità del flusso di controllo.
per modificare nft_quota->consumed.