Escalación de privilegios en el kernel: cómo el aislamiento de contenedores de Kubernetes afecta los ataques de escalación de privilegios
Kamil Potrec
3 de diciembre de 2020
0 minutos de lecturaDurante el día, dedico mi tiempo a analizar código de Terraform, archivos de configuración de objetos de Kubernetes e identificar problemas de seguridad comunes. Cuando se pone el sol, me pongo la sudadera con capucha, inicio máquinas virtuales de Linux y depuradores para examinar a fondo las tecnologías que conforman el ecosistema nativo de la nube.
En esta publicación, exploraremos cómo el aislamiento de contenedores de Kubernetes afecta los ataques de escalación de privilegios. Usaremos técnicas comunes de explotación del kernel para descubrir cómo las capas de abstracción de los contenedores pueden dificultarnos el acceso a esa preciada shell de root.
¿Qué es la escalación de privilegios?
La escalación de privilegios es un término que describe el proceso de obtener más permisos para acceder a un recurso. La escalación de privilegios en el kernel es el proceso de obtener estos permisos al explotar una debilidad en uno de los muchos puntos de entrada del kernel, también conocidos como vectores de ataque. Un vector de ataque es simplemente una ruta que permite acceder al código vulnerable.
Interactuamos con el kernel de muchas maneras: al leer el sistema de archivos, abrir un archivo de dispositivo, realizar llamadas al sistema o enviar un paquete a través de la interfaz de red. Todas estas acciones requieren que se ejecute algún proceso en el espacio del kernel. Cuando el kernel realiza una acción en nombre del proceso del usuario, decimos que opera en un contexto de proceso. Cada proceso está representado en el kernel mediante una estructura struct task_struct. Estas estructuras se almacenan en una lista circular doblemente enlazada y se accede a ellas desde variables PER_CPU en la arquitectura x86-64 cuando se produce un cambio de contexto del espacio de usuario al espacio del kernel.
Una task_struct contiene un miembro struct creds que almacena el identificador de usuario y las capacidades asociadas con el proceso. El kernel usa esta información para determinar si el proceso puede realizar una acción, por ejemplo, si tiene permiso para ejecutar una llamada al sistema específica. El objetivo general de la escalación de privilegios en el kernel es reemplazar o actualizar la estructura de credenciales para obtener más permisos.
¿Cómo funciona la escalación de privilegios?
La técnica más común para obtener permisos elevados en el espacio del kernel consiste en usar la combinación de funciones del kernel [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437)([prepare_kernel_cred(0)](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682)). Esto solo se puede lograr cuando un exploit obtiene el control de un puntero de instrucción (RIP) y logra evadir los controles de acceso y aleatorización de memoria. [prepare_kernel_cred](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L682) puede generar un objeto de credenciales a partir de uno existente o, de forma más generosa, generar uno predeterminado con todos los permisos de root. [commit_creds](https://github.com/torvalds/linux/blob/master/kernel/cred.c#L437) simplemente actualiza la task_struct del proceso actual con el nuevo objeto de credenciales.
La explotación del kernel es un campo muy amplio, así que en esta publicación solo exploraremos una versión demasiado simplificada de la escalación de privilegios en el kernel. El kernel cuenta con numerosos controles de seguridad diseñados para dificultar la explotación. SMEP, SMAP, KASLR y KPTI son mecanismos implementados en el hardware o en el kernel, y se habilitan o deshabilitan según la distribución que uses o las decisiones de un administrador del sistema. No hay una forma directa de controlar estos ajustes desde Kubernetes, por lo que no los abordaremos en esta publicación.
Usaremos un problema antiguo de la implementación de af_packet, que recibió el identificador CVE-2017-7308. La vulnerabilidad se puede explotar con la capacidad CAP_NET_RAW, ya que requiere acceso a sockets sin procesar. Los detalles de la vulnerabilidad se explican exhaustivamente aquí, así que no entraremos en ellos. Podemos obtener todas las capacidades que necesitamos en un espacio de nombres de usuario sin privilegios. En la distribución Ubuntu, el acceso a los espacios de nombres de usuario not está restringido de forma predeterminada.
Veamos los detalles
Primero, veamos el proceso de principio a fin en un entorno que no use contenedores.
Debemos conectar el depurador GNU (gdb) al stub de la máquina virtual. Una vez conectado el depurador, podemos establecer un punto de interrupción en un lugar conveniente. En este caso, usamos una llamada al sistema mlock, que podemos activar manualmente desde el exploit cuando queramos consultar el estado interno del proceso en ejecución. Ten en cuenta que gdb solo se detendrá si el proceso en ejecución se llama “exploit”. Esto reduce el riesgo de que el punto de interrupción se active por otro proceso del sistema. Las tareas de configuración están convenientemente programadas en un archivo de comandos .gdb. Ejecutamos GDB con la opción -x para realizar la configuración de forma coherente y repetible.
Los puntos de interrupción se activarán antes de crear el espacio de nombres de usuario sin privilegios, justo antes de ejecutar la vulnerabilidad y después de obtener credenciales de root. Implementamos este comportamiento simplemente ejecutando la llamada al sistema correspondiente.
Ahora podemos ejecutar el exploit:
El primer punto de interrupción se activa como esperábamos. Podemos examinar la estructura cred ejecutando la función auxiliar de gdb $lx_current. El UID efectivo del proceso actual es 1000 y, como esperábamos, no tiene capacidades efectivas en el espacio de nombres actual.
El segundo punto de interrupción se activa después de una llamada a unshare, cuando se crea el nuevo espacio de nombres de usuario para el proceso. Observa que el UID no cambia, pero los atributos cap_effective y user_ns sí. Las capacidades se almacenan como una máscara de bits, que es más fácil de leer en formato hexadecimal.
El último punto de interrupción se activa después de explotar la vulnerabilidad. Observa que el UID ahora es 0 y que el espacio de nombres de usuario se restableció a init_user_ns, que representa el espacio de nombres de usuario init del host.
Volvemos a la shell y ahora tenemos todos los permisos de root en el host.
Exploit del kernel en un contenedor
A continuación, intentaremos ejecutar el mismo exploit dentro de un pod. Creamos una definición muy sencilla de un objeto pod y la implementamos en el clúster.
Veamos qué ocurre con la configuración predeterminada.
Root de forma predeterminada
La imagen que usamos en la demostración no especifica un usuario sin privilegios y, de forma predeterminada, Kubernetes no aplica ningún UID. Así que parece que teníamos acceso root sin necesidad de explotar el kernel. Volvemos a ejecutar el mismo exploit y detenemos el kernel justo antes de que ejecute la ruta vulnerable. Si observas las capacidades efectivas del proceso, queda claro que faltan algunas. El valor es 2818844155, que representa el conjunto de capacidades predeterminado que concede el entorno de ejecución de Docker.
Una vez que termina el exploit, el conjunto efectivo vuelve a incluir todas las capacidades.
Esta vez, aplicaremos un ID de usuario distinto de root al contenedor mediante la configuración de los atributos de contexto de seguridad runAs.
Esta vez no tenemos permisos de root de entrada. Sin embargo, el exploit se comporta de manera idéntica, con una diferencia importante en el resultado final. Parece que tenemos todos los permisos, pero no podemos ver todo lo que hay en el sistema.
La barrera de los espacios de nombres
Logramos obtener todas las capacidades y el UID de root, pero solo superamos la barrera de capacidades del contenedor: seguimos sin tener acceso al sistema de archivos del host, así que no podemos ver todos los procesos ni comunicarnos a través de las interfaces de red del host.
En este punto, podemos cargar los módulos del kernel que queramos, pero eso genera ruido y activará la mayoría de los sistemas básicos de detección de intrusiones (eso esperamos). Para probarlo, quitaremos un módulo que no se usa. Ten en cuenta que la imagen de Docker debe tener instalados los paquetes de módulos. En el caso de las imágenes Debian, debes instalar el paquete kmod.
En su lugar, podemos ampliar nuestro exploit del kernel y establecer el objeto [struct nsproxy](https://github.com/torvalds/linux/blob/master/include/linux/nsproxy.h#L31) en el contexto actual para que apunte a los espacios de nombres que queramos. Los espacios de nombres se identifican mediante inodos, pero el kernel exporta la dirección de [init_nsproxy](https://github.com/torvalds/linux/blob/master/kernel/nsproxy.c#L32), que podemos usar para copiar los espacios de nombres init del host a nuestro contenedor.
La llamada al sistema sys_setns se puede usar para actualizar los espacios de nombres del contexto del proceso. Hay tres espacios de nombres principales a los que queremos escalar privilegios: PID, red y montaje. Primero, debemos obtener una referencia a los espacios de nombres root. Para ello, podemos mover el PID 1 del contenedor a los espacios de nombres del host. Luego, podemos obtener referencias a cualquier espacio de nombres desde el sistema de archivos /proc/ del PID 1. Por último, movemos el proceso actual a los espacios de nombres necesarios.
Después de ejecutar nuestro exploit, podemos acceder a todos los recursos interesantes del sistema.
Uso de las capacidades
Las capacidades predeterminadas que se asignan a los contenedores de Kubernetes (con el entorno de ejecución de Docker) conceden al contenedor CAP_NET_RAW. ¿Significa esto que podríamos explotar la vulnerabilidad incluso si los espacios de nombres de usuario sin privilegios están deshabilitados? Agregamos código para establecer las capacidades efectivas necesarias para llegar al código vulnerable.
Como puedes ver, el exploit falla. ¿Por qué?
Esto tiene que ver con las capacidades heredables y su implementación. Aunque el entorno de ejecución del contenedor haya concedido estas capacidades a los procesos del contenedor, deben activarse explícitamente como capacidades efectivas mediante sys_capset. Por ahora, solo los procesos con UID 0 pueden establecer capacidades efectivas. Así que, si quieres ejecutar un proceso como usuario sin privilegios de root y aun así acceder a algunas capacidades, debes incluir un binario suid en el contenedor para establecer las capacidades efectivas. Como alternativa, puedes establecer las capacidades necesarias en el ejecutable y eliminar las capacidades del contenedor. Las capacidades de archivo solo se aplican a sistemas de archivos con atributos extendidos.
Seccomp al rescate
Ahora hablemos de reachability de vectores de ataque. Nuestro exploit funciona porque los usuarios sin privilegios pueden obtener la capacidad CAP_NET_RAW en espacios de nombres de usuario sin privilegios. Vimos cómo esto afecta nuestro exploit en la sección anterior sobre capacidades. Hay otra medida de protección que podemos usar para detener este ataque y, sí, puedes habilitarla mediante Kubernetes.
Seccomp es un mecanismo que permite reducir la superficie de ataque del kernel mediante el filtrado de llamadas al sistema. Lamentablemente, Kubernetes no aplicará un perfil de seccomp a tu contenedor de forma predeterminada. Esto significa que se permiten todas las llamadas al sistema, sujetas a las comprobaciones de permisos que ya explicamos. Podemos cambiar esto agregando una anotación a la declaración del objeto (antes de la versión 1.19) o agregando el atributo del perfil de seccomp al contexto de seguridad del pod.
Veamos cómo afecta nuestro exploit el perfil predeterminado que proporciona el entorno de ejecución del contenedor (en este caso, Docker). Aparece el error “Operation not permitted” porque el perfil de seccomp predeterminado no permite la llamada al sistema unshare.
Seccomp es excelente para limitar los puntos de entrada del kernel que no son necesarios. Las llamadas al sistema, como unshare o userfaultfd, se pueden deshabilitar de forma segura en la mayoría de los casos y son muy útiles para detener algunas técnicas de explotación. Sin embargo, hay algunas llamadas que serían difíciles de bloquear, como waitid. Puedes encontrar estas y otras técnicas para explotar contenedores aquí.
Conclusiones
Logramos prevenir este exploit con un perfil seccomp predeterminado. Como puedes ver, aunque nuestro sistema operativo es vulnerable, la ruta de explotación no es accesible desde nuestro contenedor (en esta ocasión). Esta técnica podría darte el tiempo necesario para planificar la actualización del sistema operativo que tanta falta hace. Considera estas medidas como controles de defensa en profundidad y estrategias de mitigación.
¡Aplica siempre los parches a tus sistemas! Snyk Infrastructure as Code puede ayudarte a detectar estas opciones de mitigación desde las primeras etapas de tu pipeline de CI/CD, mucho antes de que se implemente algo en producción. Usamos técnicas adversariales para identificar opciones de seguridad de alto impacto en Kubernetes y en los proveedores de servicios en la nube. Usa Snyk gratis: regístrate para crear una cuenta gratuita.
Empieza con Capture the Flag
Aprende a resolver desafíos de Capture the Flag viendo nuestro taller virtual introductorio a pedido.


