
CVE-2023-32233
Il codice affetto ha origine dal kernel Linux ufficiale di 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. Utilizzando 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 ogni operazione di base successiva sia valida e questo 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 di 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 quindi 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() che punta a nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() che dealloca la memoria utilizzata da `nft_set`
prima di elaborare l'operazione NFT_MSG_DELSETELEM, dove il riferimento
all'nft_set deallocato viene acceduto 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 acceduta 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 successive. Quindi, ogni volta che il valore di set->ops->elemsize
viene corrotto, una posizione di memoria imprevista potrebbe essere interpretata come
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 corsa contro nf_tables_trans_destroy_work() che viene eseguita da un thread worker in background del kernel Linux. Ciò sembra complicare lo sfruttamento pratico anche prima di considerare le mitigazioni esistenti, come l'hardening dell'allocatore di slab del kernel, il Kernel Address Space Layout Randomization (KASLR) e specialmente il Control-Flow Integrity. Tuttavia, il PoC allegato dimostra che è ancora 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 CPU.
Quando gareggiamo con nf_tables_trans_destroy_work(), miglioriamo le nostre possibilità
aggiungendo un ritardo controllato per il thread worker in background tra le chiamate
di nf_tables_rule_destroy() e nf_tables_set_elem_destroy(). Per fare ciò inseriamo
un'operazione aggiuntiva per distruggere un altro nft_set contenente un numero
elevato di elementi. Inoltre, manteniamo occupati tutti gli altri core CPU, in modo
che il thread worker in background venga probabilmente schedulato su un core CPU
specifico, così possiamo tentare di allocare una nuova struttura dallo stesso core CPU
subito dopo che dealloca 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 finalmente chiama
nf_tables_set_elem_destroy() per elaborare l'operazione NFT_MSG_DELSETELEM, interpreta
il suo argomento elem in modo errato, in modo che l'nft_set_ext *ext corrotto si
trovi pochi byte dopo la posizione corretta. Ciò significa che alcuni campi di dati
controllati dall'utente dell'originale nft_set_ext vengono ora interpretati come
intestazioni, risultando in una type confusion.
Un modo per abusare di questa type confusion è creare le intestazioni nft_set_ext
corrotte con valori di offset tali che nf_tables_set_elem_destroy() interpreti il
contenuto di 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 di puntatore assoluti.
Tuttavia, quando creiamo le intestazioni nft_set_ext corrotte possiamo ancora
utilizzare offset fuori intervallo, in modo che expr->ops->destroy() venga chiamato
su determinati nft_expr validi nei chunk di memoria adiacenti.
Per questo spruzziamo espressioni nft_log, con NFTA_LOG_PREFIX controllato.
Il 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);
Si noti 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 è necessario 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 perdiamo il contenuto della memoria.
Questo viene risolto nel passaggio successivo, dove allochiamo nft_object->udata per
riutilizzare il chunk di memoria nft_log->prefix e distruggiamo l'espressione nft_log.
Ciò dealloca la memoria nft_object->udata, ma ora possiamo ancora utilizzare il
puntatore dangling nft_object->udata per perdere 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 vivono negli stessi slab di
nft_log->prefix e nft_object->udata. Inoltre, abbiamo un controllo
ragionevole sulla dimensione dell'allocazione, in modo che in seguito potremmo passare
facilmente tra slab di dimensioni diverse se necessario.
Per utilizzare queste strutture, creiamo un filtro pacchetti con 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. Perdiamo 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 per ottenere l'indirizzo base del kernel. 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.
Si noti che non abbiamo messo alcuno sforzo intenzionale per bypassare il Control-Flow Integrity. Tuttavia, per ciascuno dei passaggi di sfruttamento, abbiamo scelto consapevolmente le primitive più flessibili e robuste. A quanto pare, la nostra selezione ha in qualche modo evitato qualsiasi primitiva che potesse potenzialmente essere bloccata dal Control-Flow Integrity. Siamo ora curiosi di confermare con test che l'exploit risultante funzioni realmente contro sistemi con mitigazioni Control-Flow Integrity.
per modificare nft_quota->consumed.