Vulnerabilidad en el kernel Linux con eBPF: descubrimiento y cómo solucionarla
(bughunters.google.com)- Google descubrió CVE-2023-2163 en el verifier de eBPF con Buzzer, y confirmó que esta vulnerabilidad podía explotarse para escalamiento local de privilegios y escape de contenedores
- eBPF extiende funcionalidades del kernel en tiempo de ejecución, pero como ejecuta bytecode arbitrario con altos privilegios, la verificación del verifier antes de la carga se vuelve un eje central de la seguridad
- El problema ocurre porque el path pruning del verifier juzga erróneamente que una ruta de ejecución en realidad distinta es equivalente a una ruta ya considerada segura
- El exploit aprovecha la diferencia entre un registro que el verifier ve como 0 y que en tiempo de ejecución tiene otro valor, corrompe el puntero de stack de eBPF y deriva en lectura/escritura arbitraria y bypass de KASLR
- El parche consiste en marcar como precise también los registros imprecise que afectan a registros precise, y con la misma estrategia de fuzzing de aritmética de punteros no se encontraron problemas adicionales después
La superficie de ataque del kernel que amplía el verifier de eBPF
- eBPF es una tecnología que permite extender funcionalidades del kernel Linux en tiempo de ejecución sin módulos de kernel complejos
- Los programas eBPF se escriben en bytecode personalizado y pasan por una verificación de seguridad antes de ejecutarse cuando ocurre un evento específico
- Un ejemplo representativo es un programa eBPF que se ejecuta al invocarse una syscall específica
- Como su estructura permite ejecutar código arbitrario con un nivel alto de privilegios, la superficie de ataque del kernel aumenta considerablemente
- Antes de cargarse, el programa debe pasar por el verifier, que comprueba si se cumplen los supuestos de seguridad de eBPF
- Si se explota una vulnerabilidad del verifier, normalmente deriva en escalamiento local de privilegios o en escape de contenedores en entornos con contenedores
Buzzer y fuzzing de aritmética de punteros
- Google creó Buzzer para auditar automáticamente el código del verifier de eBPF
- Buzzer es un fuzzer que genera en masa programas eBPF sintácticamente válidos, y permite configurar estrategias para provocar bugs lógicos
- Para descubrir CVE-2023-2163 se usó la estrategia de aritmética de punteros
- Genera un encabezado que inicializa registros con valores arbitrarios
- Genera una secuencia arbitraria de instrucciones aritméticas y de salto
- Elige un registro arbitrario y realiza una suma con un puntero a un elemento de un eBPF map
- Escribe un valor mágico en ese elemento
- Si el valor escrito no se observa desde el espacio de usuario, puede haber ocurrido una escritura fuera de límites
- CVE-2023-2163, descubierta por Buzzer, estaba en la lógica de path pruning de eBPF y llevó a un exploit utilizable tanto para escape de contenedores como para escalamiento local de privilegios
Bug de path pruning y precise tracking
- El verifier de eBPF simula las posibles rutas de ejecución para comprobar si un programa puede ejecutarse de forma segura
- Si en una rama condicional el valor de un registro no es seguro, el verifier intenta seguir la ejecución de todos los estados posibles
- A medida que aumentan los saltos condicionales, la cantidad de rutas de ejecución crece de forma exponencial y también afecta negativamente el rendimiento al cargar el programa en el kernel
- Para reducir esto, los desarrolladores de eBPF introdujeron path pruning
- Si el verifier puede garantizar que un estado equivalente a un estado específico ya llega de forma segura a una instrucción exit, deja de explorar esa ruta
- Para lograr un pruning más eficiente, se usa además el concepto de precise tracking
- Si un registro participa en una operación aritmética de punteros o se pasa como constante a una función helper, se marca como precise
- El verifier debe explorar todos los estados relacionados con ese registro
- En CVE-2023-2163, r9, que afectaba la precisión de r6, no se marcaba correctamente
- El verifier asumía que r9 no contribuía al preciseness de r6
- Después de determinar que en una ruta anterior se podía llegar de forma segura a exit con r6, consideraba equivalentes los demás estados y los podaba
- En tiempo de ejecución se ejecutaba la ruta 1:2:4:6 y, en 6, r6, que el verifier veía como 0, en realidad podía usarse en una operación aritmética de punteros con otro valor
Lectura/escritura arbitraria y bypass de KASLR
- El código del exploit está publicado en el repositorio de Google security research
- Para el desarrollo del exploit se aprovechó de forma importante el trabajo de @chompie y @_manfp
- El flujo completo consiste primero en obtener lectura/escritura arbitraria y luego buscar las credentials del proceso y parchear el uid y el puntero
fs_structpara elevar privilegios - En la primera etapa se convierte el valor del registro corrompido en 1
- En el análisis manual, el valor de r6 en tiempo de ejecución era
0x400, y el verifier lo veía como 0 - Con la instrucción
r6 >>= 10se obtuvo el valor deseado, 1
- En el análisis manual, el valor de r6 en tiempo de ejecución era
- Luego se corrompe un puntero del stack de eBPF con la función helper
bpf_skb_load_bytes_relative- El verifier determina que
lenes 8, pero en tiempo de ejecuciónlenpasa a ser 9 debido al valor de r6 - Como resultado, escribe 9 bytes en lugar de 8 y corrompe el primer byte del valor en el offset de stack
-32
- El verifier determina que
- Al manipular el puntero de stack corrompido, se puede filtrar un eBPF map pointer
- Desde el espacio de usuario se leen los valores de R2 y R3, y se comprueba si R2 se vuelve
0xBACA - Si se cumple esta condición, R3 pasa a ser el valor filtrado del map pointer
- La filtración de un eBPF map pointer deriva en un estado en el que se hizo bypass de KASLR
- Desde el espacio de usuario se leen los valores de R2 y R3, y se comprueba si R2 se vuelve
- Con la misma estrategia, si se sobrescribe toda un área continua del stack con el puntero deseado en lugar de un solo byte, es posible la lectura/escritura arbitraria
- Desde la perspectiva del verifier es una manipulación del stack de BPF, pero en realidad permite leer y escribir memoria del kernel
Del map leak a una root shell
- Después de la filtración del map pointer, el exploit no difiere mucho del exploit de Chompie y toma parte de su código
- El procedimiento aproximado es el siguiente
- Busca repetidamente la cadena
init_pid_nsen kstrtab - Encuentra el símbolo de ksymtab que referencia esa cadena y obtiene la dirección de la estructura init_pid_ns
- Recorre el radix tree para encontrar una entry cuyo campo
commcoincida con el nombre del ejecutable del exploit en ejecución- Si se ejecuta dentro de un contenedor, el PID no es una heurística confiable, por lo que no se usa solo el PID
- Parchea el uid a 0 y parchea el puntero
fs_struct - Si
fs_structse parchea con el mismo valor que el puntero referenciado por el PID 1, al ejecutarse dentro de un contenedor se puede observar el sistema de archivos del host - Ejecuta
system("/bin/bash")para obtener una root shell
- Busca repetidamente la cadena
- El código publicado en GitHub funciona como escape de contenedores solo en versiones específicas de Linux
- En Ubuntu y algunas distribuciones, los offsets de la estructura de datos sobrescrita son distintos, por lo que solo funciona como escalamiento local de privilegios
- Para que funcione en cualquier distribución Linux, hace falta ajustar el código
Parche y verificación posterior
- El análisis de causa raíz y el parche de CVE-2023-2163 pueden consultarse en la kernel mailing list
- La corrección consiste en marcar como precise los registros imprecise de las operaciones que afectaron a un registro precise
- No está claro si esta corrección afecta el rendimiento del verifier de eBPF
- Se siguió ejecutando la misma estrategia de fuzzing de aritmética de punteros, pero no se encontraron problemas adicionales
- Buzzer sigue en desarrollo y recibe contribuciones de la comunidad open source a través del repo de GitHub
1 comentarios
Opiniones de Hacker News
En las plataformas donde eBPF se usa con más frecuencia, de entrada el código sin privilegios no puede cargar programas eBPF, así que muchas veces el impacto de los bugs del verificador no es tan grande.
Al final, este tipo de bugs son vulnerabilidades de root → ring0; eso no significa que no importen, pero en cargas de trabajo del lado servidor suele ser una concesión aceptable.
En particular, el historial de escaladas locales de privilegios en el kernel vía eBPF es bastante bueno comparado con el kernel en su conjunto, y el mayor valor actual del verificador en el entorno eBPF es que hace difícil matar accidentalmente el kernel con un programa eBPF incorrecto.
En los módulos de kernel cargables comunes, esto no se cumple ni de cerca.
En ese caso, en la mayoría de las plataformas no se requieren privilegios.
Y si existen espacios de nombres de usuario sin privilegios, uno puede volverse “root” por su cuenta, así que root → ring0 también se vuelve un problema menos restringido.
Es el patrón que hemos visto una y otra vez en los PoC de bugs de eBPF desde que las distribuciones lo activaron y luego, en su mayoría, lo volvieron a desactivar.
A medida que crecen herramientas como Cilium, las rutas de ataque que entran en entornos de contenedores con cap_bpf se vuelven cada vez más realistas.
“Uno no es ninguno” literalmente significa “uno no es ninguno”, es decir, algo cercano a “One is not none”.
En español, es común que la doble negación en realidad no sea una doble negación.
Por ejemplo, “aquí no hay nada” se dice “no hay nada aquí”, que palabra por palabra podría parecer “no es que no haya nada aquí”.
La Real Academia Española también lo explica así:
https://www.rae.es/espanol-al-dia/doble-negacion-no-vino-nad...
La llamada “doble negación” surge por la concordancia negativa, que debe darse en ciertas situaciones en español y otras lenguas romances; como resultado, el adverbio no y otros elementos con sentido negativo aparecen juntos en una misma oración.
Que esas dos “negaciones” estén presentes a la vez no cancela el sentido negativo de la oración.
Cuando intenté usar eBPF hace tiempo, no tenía suficiente expresividad para lo que necesitaba hacer.
Me pregunto si de verdad se justifica aumentar la complejidad en el espacio del kernel para obtener una flexibilidad limitada.
Lo entiendo para filtrado de paquetes, pero me parece menos convincente usarlo para otros fines, como sandboxing.
Las opciones del kernel no son eBPF o nada, sino eBPF u otra cosa parecida.
Puede que uno no lo use mucho directamente, pero hay gente que lo usa todo el día.
Creo que ingenieros de FAANG decían que siempre ejecutan decenas, quizá cientos, de estos programas en todos sus servidores, sin contar los usos puntuales.
FAANG también contrata desarrolladores de kernel dedicados, así que en cierto modo también financian esta complejidad que ellos mismos usan.
Yo también resolví problemas con eBPF.
Eran problemas que, sin eBPF, en la práctica serían imposibles de resolver para alguien que no sea experto en kernel; no se necesitan a menudo, pero cuando se necesitan no hay sustituto.
En algunos casos, incluso para un experto en kernel la elección es usar eBPF o mantener para siempre un parche de kernel personalizado.
Sé que no son seguros, pero quienes los manejan saben que un gran poder conlleva una gran responsabilidad.
Creo que “Uno no es ninguno” sí se traduce como “One is not none”.
https://bughunters.google.com/blog/6303226026131456/a-deep-d...
En cambio, lo tradujeron como “One is none”.
Es la famosa doble negación que nos complica la vida a quienes hablamos lenguas extranjeras, incluyéndome.
https://spanish.stackexchange.com/questions/26777/how-does-d...
Literalmente sí, pero en español, curiosamente, la doble negación normalmente se usa simplemente como negación.
Quizá algo como “one ain't nothin'” sería más adecuado.
En nuestro país tenemos la expresión “un erizo dentro de los pantalones”.
Por muy útil que sea, esto no parece haber sido escrito de forma segura y cuidadosa.