
Use-After-Free dans Netfilter nf_tables lors du traitement des requêtes par lots CVE-2023-32233

Le code affecté provient du noyau Linux officiel depuis https://kernel.org/ et fait partie du composant Netfilter nf_tables (net/netfilter/nf_tables_api.c).
Netfilter nf_tables permet de mettre à jour sa configuration comme une opération atomique. Lors de l'utilisation de cette fonctionnalité, les clients en mode utilisateur envoient des requêtes par lots contenant une liste d'opérations de base. Netfilter nf_tables traite ensuite toutes les opérations du lot comme une transaction unique. Lors du traitement du lot, Netfilter nf_tables vérifie les mises à jour de l'état de la configuration pour s'assurer que chaque opération de base successive est valide, en tenant également compte des mises à jour d'état de toutes les opérations précédentes du lot. Cependant, la vérification actuellement implémentée est insuffisante.
Dans notre scénario spécifique, nous partons d'une configuration Netfilter
nf_tables qui contient une nft_rule avec une expression lookup sur un
nft_set anonyme, et où le nft_set anonyme contient certains éléments.
Ensuite, nous envoyons une requête par lots contenant les deux opérations de
base suivantes :
NFT_MSG_DELRULE pour supprimer la nft_rule.lookup et le
nft_set anonyme.NFT_MSG_DELSETELEM pour supprimer l'un des éléments du nft_set anonyme
supprimé.La version actuelle de Netfilter nf_tables accepte la requête par lots
ci-dessus. Elle appelle ensuite nf_tables_commit_release() qui ajoute les
ressources libérées à nf_tables_destroy_list. La nf_tables_destroy_list
est ensuite traitée par nf_tables_trans_destroy_work() qui commence par
désallouer les ressources liées à l'opération NFT_MSG_DELRULE en appelant :
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`
avant de traiter l'opération NFT_MSG_DELSETELEM, où la référence au
nft_set désalloué est accédée via nft_trans_elem_set() lors des appels
suivants :
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
Dans nft_set_elem_ext() ci-dessus, l'emplacement mémoire du nft_set
désalloué est accédé pour déterminer l'emplacement de 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;
}
pour les opérations qui suivent. Ainsi, chaque fois que la valeur de
set->ops->elemsize est corrompue, un emplacement mémoire inattendu peut être
interprété comme une liste de nft_expr à détruire :
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));
Exploiter la vulnérabilité ci-dessus nécessite de gagner une course avec nf_tables_trans_destroy_work() qui s'exécute dans un thread de travail en arrière-plan du noyau Linux. Cela semble compliquer l'exploitation pratique avant même de considérer les mitigations existantes, comme le durcissement de l'allocateur de slabs du noyau, le Kernel Address Space Layout Randomization (KASLR) et surtout l'intégrité du flux de contrôle (Control-Flow Integrity). Cependant, la preuve de concept (PoC) jointe prouve qu'il est toujours possible d'obtenir une exploitation raisonnablement fiable en pratique.
Afin d'exploiter la vulnérabilité, nous devons modifier le contenu de la
mémoire du nft_set après qu'il a été désalloué sous nf_tables_rule_destroy(),
mais avant qu'il ne soit utilisé sous nf_tables_set_elem_destroy().
nf_tables_rule_destroy() et nf_tables_set_elem_destroy() sont toutes deux
appelées dans une seule invocation de nf_tables_trans_destroy_work() qui
s'exécute depuis un thread de travail en arrière-plan du noyau Linux. De plus,
le bloc mémoire désalloué n'est généralement disponible pour réutilisation que
sur le même cœur de CPU.
Lors de la course avec nf_tables_trans_destroy_work(), nous améliorons nos
chances en ajoutant un délai contrôlé pour le thread de travail en arrière-plan
entre ses appels à nf_tables_rule_destroy() et nf_tables_set_elem_destroy().
Pour cela, nous insérons une opération supplémentaire afin de détruire un autre
nft_set contenant un grand nombre d'éléments. De plus, nous maintenons tous
les autres cœurs de CPU occupés, de sorte que le thread de travail en
arrière-plan est susceptible d'être ordonnancé sur un cœur de CPU spécifique.
Nous pouvons ainsi tenter d'allouer une nouvelle structure sur le même cœur de
CPU juste après la désallocation du nft_set sous nf_tables_rule_destroy().
Notre objectif est d'allouer un nouveau nft_set d'un type différent pour
réutiliser l'emplacement mémoire du nft_set désalloué sous
nf_tables_rule_destroy().
Le nouveau type de nft_set est sélectionné pour utiliser une valeur
différente pour set->ops->elemsize. Ainsi, lorsque le thread de travail en
arrière-plan appelle finalement nf_tables_set_elem_destroy() pour traiter
l'opération NFT_MSG_DELSETELEM, il interprète incorrectement son argument
elem, de sorte que le nft_set_ext *ext corrompu se trouve quelques octets
après l'emplacement correct. Cela signifie que certains champs de données
contrôlés par l'utilisateur du nft_set_ext d'origine sont désormais
interprétés comme des en-têtes, entraînant une confusion de type (type
confusion).
Une façon d'abuser de cette confusion de type consiste à façonner les en-têtes
nft_set_ext corrompus avec des valeurs de décalage telles que
nf_tables_set_elem_destroy() interprète le contenu de blocs mémoire adjacents
comme la liste de nft_expr à détruire via les appels suivants :
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
À ce stade de l'exploitation, nous ne disposons pas encore des détails de la
disposition de la mémoire du noyau. Il n'est donc pas possible de façonner des
adresses de pointeur absolues. Cependant, lors du façonnage des en-têtes
nft_set_ext corrompus, nous pouvons encore utiliser des décalages hors
limites, de sorte que expr->ops->destroy() soit appelé sur certains
nft_expr valides dans les blocs mémoire adjacents.
Pour cela, nous pulvérisons (spray) des expressions nft_log, avec un
NFTA_LOG_PREFIX contrôlé. Ce nft_log->prefix est ensuite désalloué par
nft_log_destroy() une fois que expr->ops->destroy() est appelé :
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);
Notez que nous pouvons toujours accéder à cette mémoire et même la désallouer à
nouveau via l'autre référence de l'expression nft_log pulvérisée.
De plus, nous pouvons également contrôler la taille de nft_log->prefix, afin
qu'il puisse être alloué depuis n'importe laquelle des slabs kmalloc-{8, ...,
192}. Enfin, la mémoire référencée est interprétée par le noyau comme une
chaîne de caractères, donc pas besoin de s'inquiéter des corruptions lorsque
nous superposons différents objets dessus. C'est essentiellement la fin de la
partie.
Un inconvénient est que tout caractère NULL termine nft_log->prefix, donc
nous ne pouvons pas lire au-delà des octets NULL lors de la fuite du contenu
mémoire. Cela est résolu à l'étape suivante, où nous allouons
nft_object->udata pour réutiliser le bloc mémoire de nft_log->prefix et
détruisons l'expression nft_log. Cela désalloue la mémoire de
nft_object->udata, mais nous pouvons désormais encore utiliser le pointeur
pendant nft_object->udata pour fuiter le contenu mémoire sans restriction sur
les octets NULL.
En cherchant des structures adaptées pour les étapes suivantes, nous avons opté
pour nft_expr alloué via nft_dynset_new(). Ces structures vivent dans les
mêmes slabs que nft_log->prefix et nft_object->udata. De plus, nous avons
un contrôle raisonnable sur la taille d'allocation, ce qui nous permet par la
suite de basculer facilement entre des slabs de tailles différentes si
nécessaire.
Pour utiliser ces structures, nous créons un filtre de paquets avec l'expression
nft_dynset. Lorsque nous envoyons des paquets sur l'interface de bouclage
(loopback), l'expression nft_dynset appelle nft_dynset_new() pour créer de
nouveaux éléments pour le nft_set associé. Les éléments créés sont des
expressions avec état des types suivants :
nft_counter pour obtenir l'emplacement de nf_tables.ko dans la mémoire du
noyau.
La structure inclut un pointeur vers nft_counter_ops dans le module noyau
nf_tables.ko. Nous fuitons ce pointeur en lisant nft_object->udata.
nft_quota pour la lecture et l'écriture mémoire arbitraires.
Nous pouvons désallouer et réallouer nft_object->udata de manière répétée
pour modifier le pointeur nft_quota->consumed. Ensuite, nous effectuons
l'opération NFT_MSG_GETSETELEM qui appelle nft_quota_do_dump() pour lire
le contenu de la mémoire référencée et transmet le résultat sous forme
d'attribut NFTA_QUOTA_CONSUMED dans la réponse. Pour les écritures, nous
envoyons simplement des paquets sur l'interface de bouclage, où
nft_quota_do_eval() appelle :
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
pour modifier nft_quota->consumed.
Nous utilisons la lecture mémoire arbitraire ci-dessus pour obtenir l'adresse de base du noyau (kernel core). Ensuite, nous modifions la sous-chaîne « sbin » du chemin « /sbin/modprobe », afin qu'elle soit remplacée par « /tmp ». Le chemin résultant « //tmp/modprobe » est ensuite utilisé par le noyau pour démarrer un processus avec les privilèges root, dont nous contrôlons le contenu du fichier.
Notez que nous n'avons fait aucun effort intentionnel pour contourner l'intégrité du flux de contrôle. Cependant, pour chacune des étapes de l'exploitation, nous avons consciemment choisi les primitives les plus flexibles et les plus robustes. Il s'avère que notre sélection a en quelque sorte évité toutes les primitives qui pourraient potentiellement être bloquées par l'intégrité du flux de contrôle. Nous sommes maintenant curieux de confirmer par des tests que l'exploit résultant fonctionne réellement contre les systèmes dotés de mitigations d'intégrité du flux de contrôle.