
CVE-2023-32233
El código afectado se origina del kernel oficial de Linux de https://kernel.org/ y es parte del componente Netfilter nf_tables (net/netfilter/nf_tables_api.c).
Netfilter nf_tables permite actualizar su configuración como una operación atómica. Al usar esta característica, los clientes en modo usuario envían solicitudes por lotes que contienen una lista de operaciones básicas. Netfilter nf_tables entonces procesa todas las operaciones dentro del lote como una sola transacción. Al procesar el lote, Netfilter nf_tables verifica las actualizaciones de estado de la configuración para asegurar que cada operación básica sucesiva sea válida y esto también tiene en cuenta las actualizaciones de estado de todas las operaciones anteriores dentro del lote. Sin embargo, la verificación actualmente implementada es insuficiente.
En nuestro escenario específico comenzamos con una configuración de Netfilter nf_tables
que tiene una nft_rule con expresión lookup en un nft_set anónimo, y
donde el nft_set anónimo contiene algunos elementos. A continuación, enviamos una solicitud por lotes
que contiene las siguientes dos operaciones básicas:
NFT_MSG_DELRULE para eliminar la nft_rule.lookup y el
nft_set anónimo.NFT_MSG_DELSETELEM para eliminar cualquiera de los elementos del
nft_set anónimo eliminado.La versión actual de Netfilter nf_tables acepta la solicitud por lotes anterior.
Luego llama a nf_tables_commit_release() que añade los recursos liberados a
nf_tables_destroy_list. El nf_tables_destroy_list es luego procesado por
nf_tables_trans_destroy_work() que primero desasigna los recursos relacionados con
la operación NFT_MSG_DELRULE llamando a:
nft_commit_release()
nf_tables_rule_destroy()
nf_tables_expr_destroy()
expr->ops->destroy() que apunta a nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() que desasigna la memoria usada por `nft_set`
antes de procesar la operación NFT_MSG_DELSETELEM, donde la referencia al
nft_set desasignado se accede mediante nft_trans_elem_set() durante las
siguientes llamadas:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
Dentro de nft_set_elem_ext() anterior, se accede a la ubicación de memoria del
nft_set desasignado para determinar la ubicación 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;
}
para las operaciones que siguen. Así que siempre que el valor de set->ops->elemsize
se corrompa, cierta ubicación de memoria inesperada podría interpretarse como
lista de nft_expr a destruir:
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));
Explotar la vulnerabilidad anterior requiere ganar una carrera contra nf_tables_trans_destroy_work() que se ejecuta desde un hilo trabajador en segundo plano del kernel de Linux. Esto parece complicar la explotación práctica incluso antes de considerar las mitigaciones existentes, como el endurecimiento del asignador de losas del kernel, la Aleatorización del Diseño del Espacio de Direcciones del Kernel (KASLR) y especialmente la Integridad del Flujo de Control. Sin embargo, el PoC adjunto demuestra que aún es posible lograr una explotación razonablemente confiable en la práctica.
Para explotar la vulnerabilidad necesitamos modificar el contenido de la memoria
de nft_set después de que se desasigna bajo nf_tables_rule_destroy(), pero
antes de que se use bajo nf_tables_set_elem_destroy(). Ambas
nf_tables_rule_destroy() y nf_tables_set_elem_destroy() son llamadas dentro de una
sola invocación de nf_tables_trans_destroy_work() que se ejecuta desde
un hilo trabajador en segundo plano del kernel de Linux. Además, el fragmento de memoria
desasignado generalmente está disponible para su reutilización solo desde el mismo núcleo de CPU.
Al competir con nf_tables_trans_destroy_work(), mejoramos nuestras posibilidades al
agregar un retardo controlado para el hilo trabajador en segundo plano entre sus llamadas
a nf_tables_rule_destroy() y nf_tables_set_elem_destroy(). Para eso insertamos
una operación adicional para destruir otro nft_set que contiene un gran
número de elementos. Adicionalmente, mantenemos todos los demás núcleos de CPU ocupados, de modo
que es probable que el hilo trabajador en segundo plano se programe en un núcleo de CPU
específico, para poder intentar asignar una nueva estructura desde el mismo núcleo de CPU
justo después de que desasigne nft_set bajo nf_tables_rule_destroy(). Nuestro
objetivo es asignar un nuevo nft_set de diferente tipo para reutilizar la ubicación de memoria del
nft_set desasignado bajo nf_tables_rule_destroy().
El nuevo tipo de nft_set se selecciona para usar un valor diferente para
set->ops->elemsize. Así que cuando el hilo trabajador en segundo plano finalmente llama a
nf_tables_set_elem_destroy() para procesar la operación NFT_MSG_DELSETELEM, interpreta
su argumento elem incorrectamente, de modo que el
nft_set_ext *ext corrupto está unos bytes después de la ubicación correcta. Esto significa que
ciertos campos de datos controlados por el usuario del nft_set_ext original ahora se
interpretan como encabezados, resultando en una confusión de tipos.
Una forma de abusar de esta confusión de tipos es manipulando los
encabezados del nft_set_ext corrupto con valores de desplazamiento tales que
nf_tables_set_elem_destroy() interprete el contenido de cualquier bloque de memoria adyacente
como la lista de nft_expr a destruir a través de las siguientes llamadas:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
En este punto de la explotación, aún no tenemos detalles del diseño de la memoria
del kernel. Por lo tanto, no es posible manipular direcciones de puntero absolutas.
Sin embargo, al manipular los encabezados del nft_set_ext corrupto aún podemos usar
desplazamientos fuera de rango, de modo que se llame a expr->ops->destroy() en ciertos
nft_expr válidos en los fragmentos de memoria adyacentes.
Para ello, esparcimos expresiones nft_log, con un NFTA_LOG_PREFIX controlado.
Ese nft_log->prefix es luego desasignado por nft_log_destroy() una vez que se llama a
expr->ops->destroy():
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);
Tenga en cuenta que aún podemos acceder e incluso desasignar nuevamente esta memoria a través de la
otra referencia desde la expresión nft_log esparcida.
Adicionalmente, también podemos controlar el tamaño de nft_log->prefix, de modo que
pueda asignarse desde cualquiera de las losas kmalloc-{8, ..., 192}. Finalmente, la
memoria referida es interpretada como una cadena de caracteres por el kernel, por lo que no
hay que preocuparse por corrupciones cuando superponemos diferentes objetos sobre ella.
Esto es esencialmente el final del juego.
Un inconveniente es que cualquier carácter NULL termina nft_log->prefix, por lo que
no podemos leer más allá de los bytes NULL al filtrar contenido de memoria. Esto se aborda
en el siguiente paso, donde asignamos nft_object->udata para reutilizar
el fragmento de memoria nft_log->prefix y destruir la expresión nft_log. Esto
desasigna la memoria de nft_object->udata, pero ahora aún podemos usar el
puntero colgante nft_object->udata para filtrar contenido de memoria sin restricciones
sobre bytes NULL.
Buscando estructuras adecuadas para los siguientes pasos, decidimos usar
nft_expr asignado desde nft_dynset_new(). Estos viven en las mismas losas que
nft_log->prefix y nft_object->udata. Y también, tenemos un control
razonable sobre el tamaño de asignación, de modo que luego podamos cambiar fácilmente entre
losas de diferentes tamaños si es necesario.
Para usar estas estructuras, creamos un filtro de paquetes con la expresión nft_dynset.
Y cuando enviamos cualquier paquete a través de la interfaz de loopback,
la expresión nft_dynset llama a nft_dynset_new() para crear nuevos elementos para el
nft_set asociado. Los elementos creados son expresiones con estado de los siguientes
tipos:
nft_counter para obtener la ubicación de nf_tables.ko en la memoria del kernel.
La estructura incluye un puntero a nft_counter_ops en el módulo del kernel
nf_tables.ko. Filtramos este puntero leyendo nft_object->udata.
nft_quota para lectura y escritura arbitraria de memoria.
Podemos desasignar y reasignar repetidamente nft_object->udata para modificar
el puntero nft_quota->consumed. A continuación, realizamos la operación
NFT_MSG_GETSETELEM que llama a nft_quota_do_dump() para leer el contenido de la
memoria referenciada y pasa el resultado como atributo NFTA_QUOTA_CONSUMED
en el resultado. En cuanto a escrituras, simplemente enviamos paquetes a través de la
interfaz de loopback, donde nft_quota_do_eval() llama:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
Usamos la lectura de memoria arbitraria anterior para obtener la dirección base del núcleo del kernel. Y luego procedemos a modificar la subcadena "sbin" de la ruta "/sbin/modprobe", de modo que sea reemplazada por "/tmp". La ruta resultante "//tmp/modprobe" es luego usada por el kernel para iniciar un proceso con privilegios de root, donde controlamos el contenido del archivo.
Tenga en cuenta que no hemos puesto ningún esfuerzo intencional en omitir la Integridad del Flujo de Control. Sin embargo, para cada uno de los pasos de explotación, elegimos conscientemente las primitivas más flexibles y más robustas. Resulta que nuestra selección de alguna manera evitó cualquiera de las primitivas que podrían potencialmente ser bloqueadas por la Integridad del Flujo de Control. Ahora tenemos curiosidad por confirmar con pruebas que el exploit resultante realmente funcione contra sistemas con mitigaciones de Integridad del Flujo de Control.
para modificar nft_quota->consumed.