
Investigando el bug detrás de CVE-2021-26708
Investigando el error detrás de CVE-2021-26708
Este repositorio contiene un pequeño informe sobre CVE-2021-26708, y cómo este error puede convertirse en una primitiva de escritura Use After Free. El PoC aquí no es un exploit completo, sino solo mi andamiaje que usé al intentar investigar este error. Puede usar exitosamente una entrada del caché kmalloc-64 después de que se libera, pero no tiene código para preparar la memoria y colocar algo de interés en la ranura.
Este es un error divertido reportado por @a13xp0p0v. Me llamó la atención porque el parche era muy simple, solo prevenía que se obtuviera una referencia a vsk->transport fuera del bloqueo en 5 lugares diferentes.
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=c518adafa39f37858697ac9309c6cf1805581446
A continuación se presenta un breve recorrido del proceso para pasar del parche a una primitiva use-after-free que podría usarse para explotación. El recorrido debería ser útil para otros que quieran explorar este error.
Descargué el kernel de linux 5.10.13 y deshice manualmente el parche mostrado arriba. Para más información sobre cómo construir y ejecutar el kernel, la siguiente es una buena referencia.
https://fedoraproject.org/wiki/Building_a_custom_kernel
También modifiqué los parámetros de arranque para habilitar la depuración del kernel con kgdb. Usando gdb con el archivo vmlinux que había construido antes, tenía todos los símbolos del kernel para el kernel principal, pero no para ningún módulo kernel cargable. El código asociado con la vulnerabilidad no se cargaba por defecto, pero se cargaba en el kernel cuando se usaba la familia PF_VSOCK (dependiendo de cómo construiste tu kernel).
Para obtener los símbolos en kgdb para los módulos cargados, me aseguré de usar el socket vsock al menos una vez, luego usé sudo cat /proc/modules | grep vsock para obtener las direcciones base de los módulos asociados. En gdb luego hacía algo como (gdb) add-symbol-file ./net/vmw_vsock/vsock.ko 0xffffffffc0567000 para que gdb supiera dónde en memoria están los símbolos de ese archivo ko. vsock.ko y vmw_vsock_virtio_transport_common.ko fueron los dos más relevantes.
Es divertido trabajar hacia atrás desde parches porque, a diferencia de mucha caza de vulnerabilidades, ya sabes con certeza que estás mirando en el lugar correcto. En este caso, sabemos por el parche que se guarda una referencia al transporte antes de obtener sock_lock. Podemos esperar con seguridad que la vulnerabilidad se deba a que el transporte cambie, pero se usa la referencia antigua.
En este tipo de escenario, esperaríamos que el transporte en sí mismo fuera un objeto asignado dinámicamente que pueda liberarse y reemplazarse con otro objeto entre la obtención de la referencia y que se mantenga el bloqueo. Desafortunadamente, cuando rastreamos los tiempos de vida de los transportes relevantes implementados por los otros módulos, parecen estar todos en memoria global. Así que vamos a buscar un nivel más profundo para elementos utilizados cuando están fuera de alcance.
Mirando en af_vsock.c, podemos encontrar dos lugares donde se modifica vsk->transport. En vsock_assign_transport y vsock_deassign_transport. En vsock_assign_transport podemos ver que si hay un transporte existente diferente, entonces se llama a vsock_deassign_transport antes de colocar el nuevo transporte.
Si observamos las posibilidades para la llamada vsk->transport->destruct(vsk) aquí, vemos que tanto el transporte loopback como el virtio simplemente hacen kfree del parámetro vsk->trans aquí. ¡Bingo! Si podemos encontrar (1) una ruta a esta llamada que pueda competir con (2) una función vulnerable que use una referencia de transporte anterior a su destrucción para acceder a vsk->trans, entonces tendremos nuestra primitiva.
Buscando una ruta a vsock_deassign_transport, vemos que se llama desde vsock_sk_destruct o vsock_assign_transport. vsock_sk_destruct está configurado como la función sock->destruct y, por lo tanto, las llamadas a __sys_close, u otras llamadas disponibles a lo largo de la ruta de destrucción como sock_put, sock_close o vsock_release, pueden terminar aquí.
La ruta más relevante a vsock_assign_transport es a través de vsock_stream_connect, pero requiere que el socket esté en uno de unos pocos estados específicos, y solo termina llamando a vsock_deassign_transport si el transporte cambiara. Y reemplazaría el parámetro vsk->trans si no terminamos con un nuevo transporte NULL.
Antes de ir demasiado lejos encontrando qué ruta hacia la liberación es la adecuada para nosotros, queremos determinar que existe una ruta válida que use el miembro vsk->trans con una referencia no válida a un transporte destruido. Podemos verificar metódicamente cada lugar donde se usa el transporte con una referencia posiblemente no válida. Rastreando esos agujeros, podemos encontrar aquellos donde se usa vsk->trans. La mejor ruta parece ser a través de vsock_stream_setsockopt aquí, cuando transport->notify_buffer_size escribe en un desplazamiento dentro del vsk->trans para los transportes loopback y virtio justo aquí. Si el trans ya está liberado cuando se usa allí, obtenemos una bonita escritura de un u32 en un desplazamiento de 0x28 en una asignación kmalloc-64.
El uso de ese vsock_stream_setsockopt como primitiva depende de una competencia donde, entre la obtención de la referencia al transporte y la obtención de sock_lock, hay una pequeña ventana, y hay muchas instrucciones para llegar allí. Así que aquí podemos usar una característica interesante en linux llamada userfaultfd para darnos una mejor oportunidad. Este mecanismo nos permite manejar fallos de página en modo usuario a nuestro antojo.
Ver https://man7.org/linux/man-pages/man2/ioctl_userfaultfd.2.html y https://man7.org/linux/man-pages/man2/userfaultfd.2.html.
Con esto, podemos tener un hilo (el gater) que obtenga sock_lock y luego acceda a alguna memoria de usuario y cause un fallo de página. Podemos mantener ese hilo en pausa (con el bloqueo aún retenido) todo el tiempo que queramos. Otros hilos que intenten obtener ese bloqueo se quedarán allí hasta que dejemos ir al gater y libere el bloqueo. Podemos alinear nuestro hilo que terminará haciendo la destrucción, y nuestro hilo que usará la referencia no válida. Ambos esperarán en sock_lock, y ahora tenemos una gran oportunidad de ganar la competencia. Si el hilo que hace la destrucción es elegido para obtener el bloqueo a continuación, entonces nuestra llamada setsockopt se completará después. Usará el puntero vsk->trans después de que haya sido liberado (y reemplazado).
Si la llamada setsockopt va primero, perdemos la competencia, pero podemos intentar todo el proceso de nuevo de manera segura.
Al intentar construir esto, pasé un tiempo yendo por el camino equivocado. Intenté que se llamara a vsock_deassign_transport mediante close y timeouts, pero me encontré con muchos controles de conteo de referencias que retrasaban la destrucción real hasta que era demasiado tarde.
Como nota al margen, depurar estas rutas puede ser difícil; como puedes imaginar, un punto de interrupción en la llamada al sistema close se activará muchas veces. Incluso si usas puntos de interrupción condicionales para detenerte solo en el hilo adecuado, la máquina se ralentizará enormemente. Una ruta interesante para evitar esto es usar ebpf con tracepoints que solo llamen a bpf_trace_printk si las condiciones son correctas. Luego se puede colocar un punto de interrupción de kgdb en bpf_trace_printk, lo que nos llevará cerca del lugar correcto. Esto no funciona con kprobes porque ya estás en un manejador de puntos de interrupción. Creo que agregar una llamada bpf_trace_kgdb_break a ebpf podría ser una buena adición al kernel.
Cuando finalmente me cambié a mirar la ruta de vsock_assign_transport, todo se unió rápidamente. Para cumplir con los requisitos, primero nos conectamos a VM_ADDR_CID_LOCAL, cuando no hay un servidor escuchando. Esto nos dará el transporte loopback, pero luego, cuando nuestra conexión agote el tiempo o falle, nuestro estado volverá a SS_UNCONNECTED. Esto nos permite hacer otra conexión a una dirección mayor que VM_ADDR_CID_HOST, lo que hará que nuestro transporte cambie, destruyendo nuestro transporte existente y causando la liberación. Es importante que esto ocurra cuando no haya ningún transport_g2h o transport_h2g registrado, para que nuestro nuevo transporte sea NULL y nuestra referencia liberada permanezca en vsk->trans.
Con todo eso alineado, obtenemos un use-after-free confiable que podría usarse para escalada de privilegios.
Este repositorio solo trata sobre cómo llegar hasta el use-after-free inicial. Pero ahora tenemos una primitiva para escribir un valor en un desplazamiento dentro del caché kmalloc-64 donde antes estaba asignado virtio_vsock_sock. Decidí detener el recorrido allí porque, ¿qué, tengo que hacer todo aquí?