
Use-After-Free in Netfilter nf_tables bei der Verarbeitung von Batch-Anfragen CVE-2023-32233

Der betroffene Code stammt aus dem offiziellen Linux-Kernel von https://kernel.org/ und ist Teil der Netfilter-nf_tables-Komponente (net/netfilter/nf_tables_api.c).
Netfilter nf_tables ermöglicht es, seine Konfiguration als atomare Operation zu aktualisieren. Bei Verwendung dieser Funktion senden die Benutzermodus-Clients Batch-Anfragen, die eine Liste von Basisoperationen enthalten. Netfilter nf_tables verarbeitet dann alle Operationen innerhalb des Batches als einzelne Transaktion. Bei der Verarbeitung des Batches prüft Netfilter nf_tables die Aktualisierungen des Konfigurationszustands, um sicherzustellen, dass jede aufeinanderfolgende Basisoperation gültig ist; dabei werden auch die Zustandsaktualisierungen aller vorherigen Operationen im Batch berücksichtigt. Allerdings ist die derzeit implementierte Prüfung unzureichend.
In unserem spezifischen Szenario gehen wir von einer Netfilter-nf_tables-Konfiguration aus, die eine nft_rule mit lookup-Ausdruck auf einem anonymen nft_set enthält, wobei das anonyme nft_set einige Elemente enthält. Als Nächstes senden wir eine Batch-Anfrage mit den folgenden zwei Basisoperationen:
NFT_MSG_DELRULE-Operation zum Löschen der nft_rule.lookup-Ausdruck und das anonyme nft_set löscht.NFT_MSG_DELSETELEM-Operation zum Löschen eines der Elemente des gelöschten anonymen nft_set.Die aktuelle Version von Netfilter nf_tables akzeptiert die obige Batch-Anfrage. Sie ruft dann nf_tables_commit_release() auf, das freigegebene Ressourcen an nf_tables_destroy_list anhängt. Die nf_tables_destroy_list wird anschließend von nf_tables_trans_destroy_work() verarbeitet, das zuerst Ressourcen im Zusammenhang mit der NFT_MSG_DELRULE-Operation freigibt, indem es Folgendes aufruft:
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`
bevor die NFT_MSG_DELSETELEM-Operation verarbeitet wird, wobei über nft_trans_elem_set() während der folgenden Aufrufe auf das freigegebene nft_set zugegriffen wird:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
Innerhalb des obigen nft_set_elem_ext() wird auf die Speicherstelle des freigegebenen nft_set zugegriffen, um die Position von nft_set_ext zu bestimmen:
static inline struct nft_set_ext *nft_set_elem_ext(const struct nft_set *set,
void *elem)
{
return elem + set->ops->elemsize;
}
für die folgenden Operationen. Wenn also der Wert von set->ops->elemsize korrumpiert wird, könnte eine unerwartete Speicherstelle als Liste der zu zerstörenden nft_expr interpretiert werden:
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));
Die Ausnutzung der obigen Schwachstelle erfordert es, ein Rennen mit nf_tables_trans_destroy_work() zu gewinnen, das von einem Hintergrund-Worker-Thread des Linux-Kernels ausgeführt wird. Dies scheint die praktische Ausnutzung selbst dann zu erschweren, wenn wir bestehende Mitigationen wie die Härtung des Kernel-Slab-Allokators, Kernel Address Space Layout Randomization (KASLR) und insbesondere Control-Flow Integrity berücksichtigen. Der beigefügte PoC beweist jedoch, dass eine einigermaßen zuverlässige Ausnutzung in der Praxis weiterhin möglich ist.
Um die Schwachstelle auszunutzen, müssen wir den Inhalt des Speichers von nft_set verändern, nachdem er unter nf_tables_rule_destroy() freigegeben wurde, aber bevor er unter nf_tables_set_elem_destroy() verwendet wird. Sowohl nf_tables_rule_destroy() als auch nf_tables_set_elem_destroy() werden innerhalb eines einzelnen Aufrufs von nf_tables_trans_destroy_work() aufgerufen, das von einem Hintergrund-Worker-Thread des Linux-Kernels ausgeführt wird. Darüber hinaus ist der freigegebene Speicherblock normalerweise nur vom selben CPU-Kern aus für eine Wiederverwendung verfügbar.
Beim Rennen mit nf_tables_trans_destroy_work() verbessern wir unsere Chancen, indem wir dem Hintergrund-Worker-Thread zwischen dem Aufruf von nf_tables_rule_destroy() und nf_tables_set_elem_destroy() eine kontrollierte Verzögerung hinzufügen. Dazu fügen wir eine zusätzliche Operation ein, die ein weiteres nft_set mit einer großen Anzahl von Elementen zerstört. Zusätzlich halten wir alle anderen CPU-Kerne beschäftigt, sodass der Hintergrund-Worker-Thread wahrscheinlich auf einem bestimmten CPU-Kern geplant wird. Dadurch können wir versuchen, unmittelbar nach der Freigabe von nft_set unter nf_tables_rule_destroy() eine neue Struktur vom selben CPU-Kern zu allokieren. Unser Ziel ist es, ein neues nft_set eines anderen Typs zu allokieren, um die Speicherstelle des unter nf_tables_rule_destroy() freigegebenen nft_set wiederzuverwenden.
Der neue nft_set-Typ wird so gewählt, dass er einen anderen Wert für set->ops->elemsize verwendet. Wenn der Hintergrund-Worker-Thread schließlich nf_tables_set_elem_destroy() aufruft, um die NFT_MSG_DELSETELEM-Operation zu verarbeiten, interpretiert er sein elem-Argument falsch, sodass das korrumpierte nft_set_ext *ext einige Bytes hinter der korrekten Position liegt. Das bedeutet, dass bestimmte vom Benutzer kontrollierte Datenfelder des ursprünglichen nft_set_ext nun als Header interpretiert werden, was zu einer Typverwechslung führt.
Eine Möglichkeit, diese Typverwechslung auszunutzen, besteht darin, die korrumpierten nft_set_ext-Header mit Offset-Werten zu konstruieren, sodass nf_tables_set_elem_destroy() den Inhalt beliebiger angrenzender Speicherblöcke über die folgenden Aufrufe als Liste der zu zerstörenden nft_expr interpretiert:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
Zu diesem Zeitpunkt der Ausnutzung haben wir noch keine Details zum Speicherlayout des Kernels. Daher ist es nicht möglich, absolute Zeigeradressen zu konstruieren. Bei der Konstruktion der korrumpierten nft_set_ext-Header können wir jedoch weiterhin Offset-Werte außerhalb des gültigen Bereichs verwenden, sodass expr->ops->destroy() für bestimmte gültige nft_expr in den angrenzenden Speicherblöcken aufgerufen wird.
Zu diesem Zweck sprayen wir nft_log-Ausdrücke mit kontrolliertem NFTA_LOG_PREFIX. Dieses nft_log->prefix wird dann von nft_log_destroy() freigegeben, sobald expr->ops->destroy() aufgerufen wird:
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);
Beachten Sie, dass wir über die andere Referenz aus dem gesprayten nft_log-Ausdruck weiterhin auf diesen Speicher zugreifen und ihn sogar erneut freigeben können.
Darüber hinaus können wir auch die Größe von nft_log->prefix kontrollieren, sodass er aus einem der Slabs kmalloc-{8, ..., 192} allokiert werden kann. Schließlich wird der referenzierte Speicher vom Kernel als Zeichenkette interpretiert, sodass wir uns keine Gedanken über Korruptionen machen müssen, wenn wir verschiedene Objekte darüber legen. Das ist im Wesentlichen Game over.
Eine Unannehmlichkeit besteht darin, dass jedes NULL-Zeichen nft_log->prefix beendet, sodass wir beim Auslesen des Speicherinhalts nicht über NULL-Bytes hinauslesen können. Dies wird im nächsten Schritt behoben, indem wir nft_object->udata allokieren, um den Speicherblock von nft_log->prefix wiederzuverwenden, und den nft_log-Ausdruck zerstören. Dadurch wird der Speicher von nft_object->udata freigegeben, aber wir können den hängenden Zeiger nft_object->udata weiterhin verwenden, um Speicherinhalte ohne Einschränkungen durch NULL-Bytes auszulesen.
Auf der Suche nach geeigneten Strukturen für die folgenden Schritte haben wir uns für nft_expr entschieden, das von nft_dynset_new() allokiert wird. Diese befinden sich in denselben Slabs wie nft_log->prefix und nft_object->udata. Außerdem haben wir eine angemessene Kontrolle über die Allokationsgröße, sodass wir später bei Bedarf leicht zwischen Slabs unterschiedlicher Größe wechseln können.
Um diese Strukturen zu nutzen, erstellen wir einen Paketfilter mit nft_dynset-Ausdruck. Wenn wir Pakete über die Loopback-Schnittstelle senden, ruft der nft_dynset-Ausdruck nft_dynset_new() auf, um neue Elemente für das zugehörige nft_set zu erstellen. Die erstellten Elemente sind zustandsbehaftete Ausdrücke der folgenden Typen:
nft_counter, um die Position von nf_tables.ko im Kernel-Speicher zu erhalten.
Die Struktur enthält einen Zeiger auf nft_counter_ops im Kernelmodul nf_tables.ko. Wir leaken diesen Zeiger, indem wir nft_object->udata lesen.
nft_quota für beliebige Speicherzugriffe (Lesen und Schreiben).
Wir können nft_object->udata wiederholt freigeben und neu allokieren, um den Zeiger nft_quota->consumed zu verändern. Als Nächstes führen wir die NFT_MSG_GETSETELEM-Operation aus, die nft_quota_do_dump() aufruft, um den Inhalt des referenzierten Speichers zu lesen und das Ergebnis als NFTA_QUOTA_CONSUMED-Attribut in der Antwort übergibt. Für Schreibvorgänge senden wir einfach Pakete über die Loopback-Schnittstelle, wobei nft_quota_do_eval() Folgendes aufruft:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
um nft_quota->consumed zu verändern.
Wir verwenden den obigen beliebigen Speicher-Lesezugriff, um die Basisadresse des Kernel-Cores zu ermitteln. Anschließend modifizieren wir die Teilzeichenkette "sbin" des Pfadnamens "/sbin/modprobe", sodass sie durch "/tmp" ersetzt wird. Der resultierende Pfadname "//tmp/modprobe" wird dann vom Kernel verwendet, um einen Prozess mit Root-Rechten zu starten, wobei wir den Dateiinhalt kontrollieren.
Beachten Sie, dass wir keine absichtlichen Anstrengungen unternommen haben, um die Control-Flow Integrity zu umgehen. Für jeden der Ausnutzungsschritte haben wir jedoch bewusst die flexibelsten und robustesten Primitiven gewählt. Wie sich herausstellt, hat unsere Auswahl irgendwie alle Primitiven vermieden, die möglicherweise von der Control-Flow Integrity blockiert werden könnten. Wir sind nun gespannt, durch Tests zu bestätigen, dass der resultierende Exploit tatsächlich gegen Systeme mit Control-Flow-Integrity-Mitigationen funktioniert.