
Esta es una prueba de concepto que escribí para CVE-2024-6387.
Qualys Security Advisory
regreSSHion: RCE in OpenSSH's server, on glibc-based Linux systems (CVE-2024-6387)
Resumen SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, de 2005)
Todo lo que se necesita es un acto de fe
-- The Interrupters, "Leap of Faith"
Nota preliminar: OpenSSH es uno de los programas más seguros del mundo; esta vulnerabilidad es un desliz en una implementación por lo demás casi impecable. Su diseño y código de defensa en profundidad son un modelo y una inspiración, y agradecemos a los desarrolladores de OpenSSH su ejemplar trabajo.
Descubrimos una vulnerabilidad (una condición de carrera en el manejador de señales) en el servidor de OpenSSH (sshd): si un cliente no se autentica dentro de LoginGraceTime segundos (120 por defecto, 600 en versiones antiguas de OpenSSH), entonces el manejador SIGALRM de sshd se invoca asincrónicamente, pero este manejador de señales llama a varias funciones que no son seguras para señales asíncronas (por ejemplo, syslog()). Esta condición de carrera afecta a sshd en su configuración por defecto.
Al investigar, nos dimos cuenta de que esta vulnerabilidad es en realidad una regresión de CVE-2006-5051 ("Signal handler race condition in OpenSSH before 4.4 allows remote attackers to cause a denial of service (crash), and possibly execute arbitrary code"), reportada en 2006 por Mark Dowd.
Esta regresión se introdujo en octubre de 2020 (OpenSSH 8.5p1) mediante el commit 752250c ("revised log infrastructure for OpenSSH"), que eliminó accidentalmente un "#ifdef DO_LOG_SAFE_IN_SIGHAND" de sigdie(), una función llamada directamente por el manejador SIGALRM de sshd. En otras palabras:
OpenSSH < 4.4p1 es vulnerable a esta condición de carrera en el manejador de señales, si no tiene un backport del parche contra CVE-2006-5051, o no está parcheado contra CVE-2008-4109, que fue una corrección incorrecta para CVE-2006-5051;
4.4p1 <= OpenSSH < 8.5p1 no es vulnerable a esta condición de carrera en el manejador de señales (porque el "#ifdef DO_LOG_SAFE_IN_SIGHAND" que se añadió a sigdie() mediante el parche para CVE-2006-5051 transformó esta función insegura en una llamada segura a _exit(1));
8.5p1 <= OpenSSH < 9.8p1 vuelve a ser vulnerable a esta condición de carrera en el manejador de señales (porque el "#ifdef DO_LOG_SAFE_IN_SIGHAND" se eliminó accidentalmente de sigdie()).
Esta vulnerabilidad es explotable remotamente en sistemas Linux basados en glibc, donde syslog() a su vez llama a funciones no seguras para señales asíncronas (por ejemplo, malloc() y free()): una ejecución remota de código no autenticada como root, porque afecta al código privilegiado de sshd, que no está en sandbox y se ejecuta con privilegios completos. No hemos investigado ninguna otra libc ni sistema operativo; pero OpenBSD no es vulnerable notablemente, porque su manejador SIGALRM llama a syslog_r(), una versión más segura para señales asíncronas de syslog() que fue inventada por OpenBSD en 2001.
Para explotar esta vulnerabilidad remotamente (hasta donde sabemos, CVE-2006-5051 nunca se había explotado con éxito antes), nos inspiramos en un artículo visionario, "Delivering Signals for Fun and Profit", publicado en 2001 por Michal Zalewski:
https://lcamtuf.coredump.cx/signals.txt
Sin embargo, inmediatamente nos enfrentamos a tres problemas principales:
Desde un punto de vista teórico, debemos encontrar una ruta de código útil que, si es interrumpida en el momento adecuado por SIGALRM, deje a sshd en un estado inconsistente, y luego debemos explotar ese estado inconsistente dentro del manejador SIGALRM.
Desde un punto de vista práctico, debemos encontrar una forma de alcanzar esta ruta de código útil en sshd, y maximizar nuestras posibilidades de interrumpirla en el momento adecuado.
Desde un punto de vista de temporización, debemos encontrar una forma de aumentar aún más nuestras posibilidades de interrumpir esta ruta de código útil en el momento adecuado, de forma remota.
Para centrarnos en estos tres problemas sin tener que luchar de inmediato contra todas las protecciones de los sistemas operativos modernos (en particular, ASLR y NX), decidimos explotar primero versiones antiguas de OpenSSH, en i386, y luego, basándonos en esta experiencia, versiones recientes:
Primero, "SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3", de "debian-30r6-dvd-i386-binary-1_NONUS.iso": esta es la primera versión de Debian que tiene la separación de privilegios habilitada por defecto y que está parcheada contra todas las vulnerabilidades críticas de esa época (en particular, CVE-2003-0693 y CVE-2002-0640).
Para explotar esta versión remotamente, interrumpimos una llamada a free() con SIGALRM (dentro del código de análisis de claves públicas de sshd), dejamos el heap en un estado inconsistente, y explotamos este estado inconsistente durante otra llamada a free(), dentro del manejador SIGALRM.
En nuestros experimentos, se necesitan ~10.000 intentos en promedio para ganar esta condición de carrera; es decir, con 10 conexiones (MaxStartups) aceptadas por cada 600 segundos (LoginGraceTime), se necesitan ~1 semana en promedio para obtener una shell root remota.
Segundo, "SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3", de "ubuntu-6.06.1-server-i386.iso": esta es la última versión de Ubuntu que sigue siendo vulnerable a CVE-2006-5051 ("Signal handler race condition in OpenSSH before 4.4").
Para explotar esta versión remotamente, interrumpimos una llamada a pam_start() con SIGALRM, dejamos una de las estructuras de PAM en un estado inconsistente, y explotamos este estado inconsistente durante una llamada a pam_end(), dentro del manejador SIGALRM.
En nuestros experimentos, se necesitan ~10.000 intentos en promedio para ganar esta condición de carrera; es decir, con 10 conexiones (MaxStartups) aceptadas por cada 120 segundos (LoginGraceTime), se necesitan ~1-2 días en promedio para obtener una shell root remota.
Finalmente, "SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2", de "debian-12.5.0-i386-DVD-1.iso": esta es la versión estable actual de Debian, y es vulnerable a la regresión de CVE-2006-5051.
Para explotar esta versión remotamente, interrumpimos una llamada a malloc() con SIGALRM (dentro del código de análisis de claves públicas de sshd), dejamos el heap en un estado inconsistente, y explotamos este estado inconsistente durante otra llamada a malloc(), dentro del manejador SIGALRM (más precisamente, dentro de syslog()).
En nuestros experimentos, se necesitan ~10.000 intentos en promedio para ganar esta condición de carrera, así que ~3-4 horas con 100 conexiones (MaxStartups) aceptadas por cada 120 segundos (LoginGraceTime). En última instancia, se necesitan ~6-8 horas en promedio para obtener una shell root remota, porque solo podemos adivinar correctamente la dirección de la glibc la mitad de las veces (debido a ASLR).
Esta investigación sigue siendo un trabajo en progreso:
solo nos hemos dirigido a máquinas virtuales, no a servidores de metal desnudo, en un enlace de red mayormente estable (~10 ms de fluctuación de paquetes);
estamos convencidos de que varios aspectos de nuestros exploits pueden mejorarse enormemente;
hemos comenzado a trabajar en un exploit para amd64, que es mucho más difícil debido al ASLR más fuerte.
Unos días después de comenzar nuestro trabajo en amd64, notamos el siguiente informe de error (en el Bugzilla público de OpenSSH), sobre un deadlock en el manejador SIGALRM de sshd:
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
Por lo tanto, decidimos contactar inmediatamente a los desarrolladores de OpenSSH (para hacerles saber que este deadlock es causado por una vulnerabilidad explotable), pusimos nuestro trabajo en amd64 en espera, y comenzamos a redactar este advisory.
Pero eso no es propio de mí, me estoy liberando
-- The Interrupters, "Haven't Seen the Last of Me"
El manejador SIGALRM de esta versión de OpenSSH llama a packet_close(), que llama a buffer_free(), que llama a xfree() y por lo tanto a free(), que no es segura para señales asíncronas:
En consecuencia, comenzamos a leer el código malloc de la glibc de este Debian (2.2.5), para ver si una primera llamada a free() puede ser interrumpida por SIGALRM y explotada durante una segunda llamada a free() dentro del manejador SIGALRM (en las líneas 341-344, arriba). Debido a que el malloc de esta glibc no está endurecido contra la técnica unlink() pionera de Solar Designer en 2000, rápidamente detectamos una ruta de código interesante en chunk_free() (que es llamada internamente por free()):
Para explotar esta ruta de código, disponemos que el heap de sshd tenga el siguiente diseño (chunk_X, chunk_Y y chunk_Z son fragmentos de memoria asignados con malloc(), y p, s, f, b son sus campos prev_size, size, fd y bk):
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| user data
Primero, si una llamada a free(chunk_Y) es interrumpida por SIGALRM después de la línea 3246 pero antes de la línea 3251, entonces chunk_Y ya está marcado como libre (porque el bit PREV_INUSE de chunk_Z se limpia en la línea 3246) pero aún no está enlazado en su lista doblemente enlazada (en la línea 3251): en otras palabras, los punteros fd y bk de chunk_Y todavía contienen datos de usuario (datos controlados por el atacante).
Segundo, si (dentro del manejador SIGALRM) packet_close() llama a free(chunk_X), entonces se entra en el bloque de código de las líneas 3230-3244 (porque chunk_Y está marcado como libre) y chunk_Y es desenlazado con unlink() (en la línea 3241): un llamado primitivo aa4bmo (almost arbitrary 4 bytes mirrored overwrite), porque los punteros fd y bk de chunk_Y siguen siendo controlados por el atacante. Para más información sobre la técnica unlink() y el primitivo aa4bmo:
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
Finalmente, con este primitivo aa4bmo sobrescribimos el puntero de función __free_hook de la glibc (esta versión antigua de Debian no tiene ASLR, ni NX) con la dirección de nuestro shellcode en el heap, logrando así ejecución remota de código durante la siguiente llamada a free() en packet_close().
Ahora se están apoderando y tienen el control total
-- The Interrupters, "Liberty"
Para montar este ataque contra sshd, interrumpimos una llamada a free() dentro del código de análisis de sshd de una clave pública DSA (es decir, la línea 144 de abajo es nuestro free(chunk_Y)) y la explotamos durante una de las llamadas a free() en packet_close() (es decir, una de las líneas 341-344 de arriba es nuestro free(chunk_X)):
Inicialmente, sin embargo, nunca pudimos ganar esta condición de carrera (es decir, interrumpir la llamada a free() en la línea 144 en el momento adecuado). Finalmente, nos dimos cuenta de que podíamos mejorar enormemente nuestras posibilidades de ganar esta carrera: el código de análisis de claves públicas DSA nos permite llamar a free() cuatro veces (en las líneas 704-707 de abajo), y además sshd nos permite intentar seis autenticaciones de usuario (AUTH_FAIL_MAX); si cualquiera de estas 24 llamadas a free() es interrumpida en el momento adecuado, entonces posteriormente logramos ejecución remota de código dentro del manejador SIGALRM.
Con esta mejora, finalmente ganamos la condición de carrera después de ~1 mes: estábamos felices (e hicimos un baile de shell root), pero también sentimos que todavía había margen de mejora.
No te preocupes, solo espera y verás
-- The Interrupters, "Haven't Seen the Last of Me"
Por lo tanto, implementamos la siguiente estrategia de temporización en tres partes:
No esperamos hasta el último momento para enviar nuestro paquete de clave pública DSA (bastante grande) a sshd: en cambio, enviamos el paquete completo menos un byte (el último byte) mucho antes del LoginGraceTime, y enviamos el último byte en el último momento, para minimizar los efectos de los retrasos de red. (Y deshabilitamos el algoritmo de Nagle).
Realizamos un seguimiento de la mediana del tiempo de ida y vuelta (enviando regularmente paquetes que producen una respuesta de sshd), y realizamos un seguimiento de la diferencia entre el momento en que esperamos que nuestra conexión sea cerrada por sshd (esencialmente el momento en que recibimos el primer byte del banner de sshd, más LoginGraceTime) y el momento en que nuestra conexión es realmente cerrada por sshd, y ajustamos nuestra temporización en consecuencia (es decir, el momento en que enviamos el último byte de nuestro paquete DSA).
Estas diferencias de tiempo nos permiten rastrear desviaciones de reloj y retrasos de red, que muestran patrones predecibles con el tiempo: experimentamos con regresiones lineales y spline, pero al final, nada funcionó mejor que simplemente reutilizar la medición más reciente. Posiblemente, el aprendizaje profundo podría producir resultados aún mejores; esto queda como ejercicio para el lector interesado.
Más importante aún, aumentamos aún más nuestras posibilidades de ganar esta condición de carrera ajustando lentamente nuestra temporización a través de la retroalimentación involuntaria de sshd:
si recibimos una respuesta (SSH2_MSG_USERAUTH_FAILURE) a nuestro paquete de clave pública DSA, entonces lo enviamos demasiado pronto (sshd tuvo tiempo de recibir nuestro paquete en el hijo sin privilegios, analizarlo, enviarlo al hijo privilegiado, analizarlo allí y enviarnos una respuesta de vuelta);
si ni siquiera podemos enviar el último byte de nuestro paquete DSA, entonces esperamos demasiado (sshd ya recibió el SIGALRM y cerró nuestra conexión);
si podemos enviar el último byte de nuestro paquete DSA y no recibimos ninguna respuesta antes de que sshd cierre nuestra conexión, entonces nuestra temporización fue razonablemente precisa.
Esta retroalimentación nos permite apuntar a lo que llamamos la ventana de carrera "grande": acertar en ella no garantiza que ganemos la condición de carrera, pero dentro de esta ventana grande están las 24 ventanas de carrera "pequeñas" (dentro de las 24 llamadas a free()) que, si se aciertan, garantizan que ganemos la condición de carrera.
Con estas mejoras, se necesitan ~10.000 intentos en promedio para ganar esta condición de carrera; es decir, con 10 conexiones (MaxStartups) aceptadas por cada 600 segundos (LoginGraceTime), se necesitan ~1 semana en promedio para obtener una shell root remota.
Duermo cuando el sol comienza a salir
-- The Interrupters, "Alien"
El manejador SIGALRM de esta versión de OpenSSH ya no llama a packet_close(); además, la glibc de este Ubuntu (2.3.6) siempre toma un bloqueo obligatorio al entrar en las funciones de la familia malloc (incluso si es de un solo hilo como sshd), lo que nos impide interrumpir una llamada a una de las funciones de malloc y luego explotarla durante otra llamada a estas funciones (siempre habría un deadlock). Debemos encontrar otra solución.
CVE-2006-5051 menciona un doble free en GSSAPI, pero GSSAPI (o Kerberos) no está habilitado por defecto, por lo que esto no parece muy atractivo. Por otro lado, PAM está habilitado por defecto, y pam_end() es llamado por el manejador SIGALRM de sshd (y, por supuesto, no es seguro para señales asíncronas). Por lo tanto, buscamos una función de PAM que, si es interrumpida por SIGALRM en el momento adecuado, dejara las estructuras internas de PAM en un estado inconsistente, explotable durante pam_end() en el manejador SIGALRM. Encontramos pam_set_data():
33 int pam_set_data( 34 pam_handle_t *pamh, .. 37 void (*cleanup)(pam_handle_t *pamh, void *data, int error_status)) 38 { 39 struct pam_data *data_entry; .. 57 } else if ((data_entry = malloc(sizeof(*data_entry)))) { .. 65 data_entry->next = pamh->data; 66 pamh->data = data_entry; .. 74 data_entry->cleanup = cleanup; ------------------------------------------------------------------------Si esta función se interrumpe por SIGALRM después de la línea 66 pero antes de la línea 74, entonces data_entry ya está enlazado en las estructuras de PAM (pamh), pero su campo cleanup (un puntero a función) aún no está inicializado (ya que malloc() en la línea 57 no inicializa su memoria). Si podemos controlar cleanup (a través de restos de asignaciones de heap anteriores), entonces podemos ejecutar código arbitrario cuando pam_end() (dentro del manejador de SIGALRM) llama a _pam_free_data() (en la línea 118):
Este habría sido un exploit extremadamente simple; desafortunadamente, pasamos por alto por completo que pam_set_data() solo puede ser llamado desde módulos de PAM: si lo interrumpimos con SIGALRM, entonces pamh->caller_is sigue siendo _PAM_CALLED_FROM_MODULE, en cuyo caso pam_end() retorna inmediatamente, sin llamar nunca a _pam_free_data(). De vuelta a la mesa de dibujo.
No nos rendimos, no es lo que hacemos
-- The Interrupters, "Title Holder"
Notamos que, en la línea 601 más abajo, sshd pasa un puntero a su puntero global sshpam_handle directamente a pam_start() (que se llama una vez por conexión):
Por lo tanto, decidimos examinar pam_start() en sí: si se interrumpe por SIGALRM, podría dejar la estructura apuntada por sshpam_handle en un estado inconsistente, que luego podría explotarse dentro del manejador de SIGALRM, cuando se llama "pam_end(sshpam_handle, sshpam_err)".
En la línea 32, pam_start() establece inmediatamente sshpam_handle de sshd a un fragmento de memoria asignado con calloc(); esto es seguro, porque calloc() inicializa esta memoria a cero. Por otro lado, si _pam_add_handler() (que es llamado múltiples veces por pam_start()) es interrumpido por SIGALRM después de la línea 874 pero antes de la línea 886, entonces una estructura asignada con malloc() se enlaza en pamh, pero su campo next aún no está inicializado. Si podemos controlar next (a través de restos de asignaciones de heap anteriores), entonces podemos pasar un puntero arbitrario a free() durante la llamada a pam_end() (dentro del manejador de SIGALRM), en la línea 1020 (y línea 1017) más abajo:
Debido a que el malloc de la glibc de este Ubuntu ya está endurecido contra la antigua técnica de unlink(), decidimos transformar nuestro free() arbitrario en el House of Mind del Malloc Maleficarum (versión fastbin): liberamos (free()) nuestro propio chunk NON_MAIN_ARENA, apuntamos nuestro falso arena a .got.plt de sshd (el sshd de este Ubuntu tiene ASLR pero no PIE), y sobrescribimos la entrada de _exit() con la dirección de nuestro shellcode en el heap (el heap de este Ubuntu aún es ejecutable por defecto). Para más información sobre el Malloc Maleficarum:
https://seclists.org/bugtraq/2005/Oct/118
Aprendí todo de la manera difícil
-- The Interrupters, "The Hard Way"
Para montar este ataque contra sshd, inicialmente enfrentamos tres problemas:
El House of Mind requiere que almacenemos el puntero a nuestro falso arena en la dirección 0x08100000 del heap; pero ¿podemos almacenar datos controlados por el atacante en una dirección tan alta? Debido a que sshd llama a pam_start() al comienzo mismo de la autenticación del usuario, no controlamos nada excepto el propio nombre de usuario; afortunadamente, un nombre de usuario de ~128KB de longitud (más corto que DEFAULT_MMAP_THRESHOLD) nos permite almacenar nuestros propios datos en la dirección 0x08100000.
El campo size de nuestro falso chunk NON_MAIN_ARENA no debe ser demasiado grande (para pasar las comprobaciones de seguridad de free()); es decir, debe contener bytes nulos. Pero nuestro largo nombre de usuario es una cadena terminada en nulo que no puede contener bytes nulos; afortunadamente recordamos que _pam_free_handlers_aux() pone a cero las estructuras que libera con free() (línea 1019 más arriba): por lo tanto, "parcheamos" el campo size de nuestro falso chunk con tal memset(0), y solo entonces lo liberamos con free().
Debemos sobrevivir a varias llamadas a free() (en las líneas 1017 y 1020 más arriba) antes del free() de nuestro falso chunk NON_MAIN_ARENA. Transformamos estos free() en no-ops apuntándolos a chunks IS_MMAPPED falsos: free() llama a munmap_chunk(), que llama a munmap(), que falla porque estos chunks IS_MMAPPED falsos están desalineados; efectivamente es un no-op, porque los fallos de aserción (assert()) no se aplican en la glibc de este Ubuntu.
Finalmente, nuestro largo nombre de usuario también nos permite controlar el campo next potencialmente no inicializado de 20 estructuras diferentes (a través de restos de copias temporales de nuestro largo nombre de usuario), porque pam_start() llama a _pam_add_handler() múltiples veces; es decir, nuestra gran ventana de carrera contiene 20 pequeñas ventanas de carrera.
Los mismos trucos que usaron antes
-- The Interrupters, "Divide Us"
Para este ataque contra Ubuntu 6.06.1, simplemente reutilizamos la estrategia de temporización que usamos contra Debian 3.0r6: se necesitan ~10,000 intentos en promedio para ganar la condición de carrera, y con 10 conexiones (MaxStartups) aceptadas cada 120 segundos (LoginGraceTime), se tarda ~1-2 días en promedio para obtener un shell root remoto.
Nota: debido a que la glibc de este Ubuntu siempre toma un bloqueo obligatorio al entrar en las funciones de la familia malloc, un atacante desafortunado podría bloquear (deadlock) las 10 conexiones MaxStartups antes de obtener un shell root; no hemos intentado solucionar este problema porque nuestro objetivo final era explotar una versión moderna de OpenSSH de todos modos.
Ahora estás listo, enfréntate a los demonios
-- The Interrupters, "Be Gone"
El manejador de SIGALRM de esta versión de OpenSSH no llama a packet_close() ni a pam_end(); de hecho, solo llama a una función interesante, syslog():
Nuestras dos preguntas clave, entonces, son: ¿El syslog() de la glibc (2.36) de este Debian llama a funciones no seguras para señales asíncronas (async-signal-unsafe) como malloc() y free()? Y si es así, ¿esta glibc todavía toma un bloqueo obligatorio al entrar en las funciones de la familia malloc?
Nota: debido a que no controlamos nada acerca de estas asignaciones con malloc() (ni su orden, ni sus tamaños, ni su contenido), tomamos el "rce" en la línea 166 como un buen presagio muy necesario.
Y afortunadamente para nosotros, la respuesta a nuestra segunda pregunta es no; desde octubre de 2017, las funciones malloc de la glibc ya no toman ningún bloqueo cuando son de un solo hilo (single-threaded) (como sshd):
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
Además, esta versión de Debian sufre la debilidad de ASLR descrita en las siguientes excelentes publicaciones de blog (por Justin Miller y Mathias Krause, respectivamente):
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
Concretamente, en el caso de sshd en i386, cada mapeo de memoria se aleatoriza normalmente (el PIE de sshd, el heap, la mayoría de las bibliotecas, la pila), pero la propia glibc siempre se mapea ya sea en la dirección 0xb7200000 o en la dirección 0xb7400000; en otras palabras, podemos adivinar correctamente la dirección de la glibc la mitad de las veces (un pequeño precio a pagar para derrotar a ASLR). En nuestro exploit asumimos que la glibc está mapeada en la dirección 0xb7400000, porque es ligeramente más común que 0xb7200000.
Nuestra siguiente pregunta es: ¿qué rutas de código dentro de las funciones malloc de la glibc, si se interrumpen por SIGALRM en el momento adecuado, dejan el heap en un estado inconsistente, explotable durante una de las llamadas a malloc() dentro del manejador de SIGALRM?
Encontramos varias rutas de código interesantes (¡y sorprendentes!), pero la que elegimos involucra solo tamaños relativos, no direcciones absolutas (a diferencia de varias rutas de código dentro de unlink_chunk(), por ejemplo); esta diferencia podría resultar crucial para un futuro exploit en amd64. Esta ruta de código, dentro de malloc(), divide un gran chunk libre (victim) en dos chunks más pequeños; el primer chunk se devuelve al llamador de malloc() (en la línea 4345) y el segundo chunk (remainder) se enlaza en una lista no ordenada de chunks libres (en las líneas 4324-4327):
Si esta ruta de código se interrumpe por SIGALRM después de la línea 4327 pero antes de la línea 4339, entonces el chunk remainder de esta división ya está enlazado en la lista no ordenada de chunks libres (líneas 4324-4327), pero su campo size (mchunk_size) aún no está inicializado (línea 4339).
Si podemos controlar su campo size (a través de restos de asignaciones de heap anteriores), entonces podemos hacer que este chunk remainder sea más grande y se superponga con otros chunks del heap, y por lo tanto corromper la memoria del heap cuando este chunk remainder agrandado y superpuesto finalmente se asigne con malloc() y se escriba en él (dentro del manejador de SIGALRM).
Nuestra última pregunta, entonces, es: dado que no controlamos nada acerca de las llamadas a malloc() dentro del manejador de SIGALRM, ¿qué podemos sobrescribir en el heap para lograr la ejecución de código arbitrario antes de que sshd llame a _exit() (en sshsigdie())?
Debido a que __tzfile_read() (dentro del manejador de SIGALRM) asigna con malloc() una estructura FILE en el heap (en la línea 166 más arriba), y debido a que las estructuras FILE tienen una larga historia de abuso para la ejecución de código arbitrario, decidimos apuntar nuestra corrupción del heap a esta estructura FILE. Sin embargo, esto es más fácil decirlo que hacerlo: nuestra corrupción del heap es muy limitada, y las estructuras FILE han sido significativamente endurecidas a lo largo de los años (por IO_validate_vtable() y PTR_DEMANGLE(), por ejemplo).
Finalmente, ideamos la siguiente técnica (que parece ser específica de la glibc i386 -- la glibc amd64 no parece usar _vtable_offset en absoluto):
con nuestra limitada corrupción del heap, sobrescribimos el campo _vtable_offset (un solo char con signo) de la estructura FILE de __tzfile_read();
las funciones libio de la glibc buscarán por lo tanto el puntero vtable de esta estructura FILE (un puntero a un array de punteros a función) en un offset distinto de cero (nuestro _vtable_offset sobrescrito), en lugar del offset cero predeterminado;
nosotros (los atacantes) podemos controlar fácilmente este puntero vtable falso (a través de restos de asignaciones de heap anteriores), porque la estructura FILE alrededor de este offset no es inicializada explícitamente por fopen();
para pasar las comprobaciones de seguridad de la glibc, nuestro puntero vtable falso debe apuntar a algún lugar dentro de la sección __libc_IO_vtables: decidimos apuntarlo a la vtable para flujos de caracteres anchos, _IO_wfile_jumps (es decir, a 0xb761b740, ya que asumimos que la glibc está mapeada en la dirección 0xb7400000);
como resultado, __fread_unlocked() (en la línea 186 más arriba) llama a _IO_wfile_underflow() (en lugar de _IO_file_underflow()), que llama a un puntero a función (__fct) que básicamente proviene de una estructura cuyo puntero (_codecvt) es otro campo más de la estructura FILE;
nosotros (los atacantes) podemos controlar fácilmente este puntero _codecvt (a través de restos de asignaciones de heap anteriores, porque este campo de la estructura FILE no es inicializado explícitamente por fopen()), lo que también nos permite controlar el puntero a función __fct.
En resumen, al sobrescribir un solo byte (_vtable_offset) de la estructura FILE asignada con malloc() por fopen(), podemos llamar a nuestro propio puntero a función __fct y ejecutar código arbitrario durante __fread_unlocked().
Lo quería perfecto, sin arrugas
-- The Interrupters, "In the Mirror"
Para montar este ataque contra el hijo privilegiado de sshd, imaginemos primero el siguiente diseño del heap (las "XXX" son chunks "barrera" que nos permiten hacer agujeros en el heap; por ejemplo, pequeños chunks con fugas de memoria):
---|----------------------------------------------|---|------------|---
| XXX | large hole | XXX | small hole | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | large allocated chunk | free remainder chunk | XXX | small hole | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
pero si este malloc() se interrumpe por SIGALRM después de la línea 4327 pero antes de la línea 4339, entonces el chunk remainder de esta división ya está enlazado en la lista no ordenada de chunks libres, pero su campo size está bajo nuestro control (a través de restos de asignaciones de heap anteriores), y este chunk remainder artificialmente agrandado se superpone con el siguiente pequeño agujero:---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | real remainder chunk |XXX| small hole |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| artificially enlarged remainder chunk
cuando el manejador SIGALRM llama a syslog() y por tanto a __tzfile_read(), fopen() asigna con malloc() el pequeño hueco para su estructura FILE, y __fread_unlocked() asigna con malloc() un buffer de lectura de 4KB, dividiendo así el resto agrandado en dos (el buffer de lectura de 4KB y un pequeño resto):
---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<-------| 4KB read buffer remainder
por lo tanto, sobrescribimos partes de la estructura FILE con el encabezado interno de este pequeño resto: más precisamente, sobrescribimos el _vtable_offset del FILE con el tercer byte del campo bk de este encabezado, que es un puntero a la lista no ordenada de bloques libres, 0xb761d7f8 (es decir, sobrescribimos _vtable_offset con 0x61);
entonces, como se explicó en la subsección "Teoría", __fread_unlocked() llama a _IO_wfile_underflow() (en lugar de _IO_file_underflow()), que llama a nuestro propio puntero a función __fct (a través de nuestro propio puntero _codecvt) y ejecuta nuestro código arbitrario.
Nota: todavía no hemos explicado cómo pasar de forma fiable de un puntero _codecvt controlado a un puntero a función __fct controlado; lo haremos, pero primero debemos resolver un problema más acuciante.
De hecho, aprendimos de nuestro trabajo en versiones anteriores de OpenSSH que nunca ganaremos esta condición de carrera del manejador de señales si nuestra gran ventana de carrera contiene solo una pequeña ventana de carrera. En consecuencia, implementamos la siguiente estrategia, basada en el siguiente diseño del heap:
---|------------|---|------------|---|------------|---|------------|---
| XXX | large hole 1 | XXX | small hole 1 | XXX | large hole 2 | XXX | small hole 2 | ... |
|---|---|---|---|---|---|---|---|---|
El último paquete que enviamos a sshd (poco antes de la entrega de SIGALRM) obliga a sshd a realizar la siguiente secuencia de llamadas a malloc(): malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), etc.
1/ Nuestro primer malloc(~4KB) divide el hueco grande 1 en dos:
si esta primera división es interrumpida por SIGALRM en el momento adecuado, entonces el fopen() dentro del manejador de SIGALRM asigna con malloc() el hueco pequeño 1 para su estructura FILE, y logramos ejecución de código arbitrario como se explicó anteriormente;
si no, entonces asignamos nosotros mismos el hueco pequeño 1 con nuestro primer malloc(304), y:
2/ Nuestro segundo malloc(~4KB) divide el hueco grande 2 en dos:
si esta segunda división es interrumpida por SIGALRM en el momento adecuado, entonces el fopen() dentro del manejador de SIGALRM asigna con malloc() el hueco pequeño 2 para su estructura FILE, y logramos ejecución de código arbitrario como se explicó anteriormente;
si no, entonces asignamos nosotros mismos el hueco pequeño 2 con nuestro segundo malloc(304), etc.
Pudimos crear 27 pares de tales huecos grandes y pequeños en el heap de sshd (28 excederían PACKET_MAX_SIZE, 256KB): ¡nuestra gran ventana de carrera ahora contiene 27 pequeñas ventanas de carrera! Lograr este complejo diseño del heap fue extremadamente doloroso y requirió mucho tiempo, pero los dos aspectos más destacados son:
Para lograr este diseño del heap de forma fiable, enviamos cinco paquetes de clave pública diferentes a sshd (los paquetes a/ a d/ pueden enviarse mucho antes de SIGALRM; la mayor parte del paquete e/ también puede enviarse mucho antes de SIGALRM, pero su último byte debe enviarse en el último momento):
a/ Asignamos con malloc() y liberamos con free() una variedad de bloques tcache, para asegurar que las asignaciones de heap que no controlamos terminen en estos bloques tcache y no interfieran con nuestro cuidadoso diseño del heap.
b/ Asignamos con malloc() y liberamos con free() bloques de varios tamaños, para crear nuestros 27 pares de huecos grandes y pequeños (y los correspondientes bloques "barrera").
c/ Asignamos con malloc() y liberamos con free() bloques de ~4KB y bloques de 320B, para:
escribir el encabezado falso (el campo de tamaño grande) de nuestro posible resto agrandado, en el medio de nuestros huecos grandes;
escribir el pie falso de nuestro posible resto agrandado, al final de nuestros huecos pequeños (para pasar las comprobaciones de seguridad de glibc);
escribir nuestros punteros falsos a vtable y _codecvt, en nuestros huecos pequeños (que son posibles estructuras FILE).
d/ Asignamos con malloc() y liberamos con free() una cadena muy grande (de casi 256KB), para asegurar que nuestros huecos grandes y pequeños se eliminen de la lista no ordenada de bloques libres y se coloquen en sus respectivos bins de malloc.
e/ Forzamos a sshd a realizar nuestra secuencia final de llamadas a malloc() (malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), etc), para abrir nuestras 27 pequeñas ventanas de carrera.
Los lectores atentos habrán notado que todavía no hemos abordado (literal y figuradamente) el problema de _codecvt. De hecho, _codecvt es un puntero a una estructura (_IO_codecvt) que contiene un puntero a una estructura (__gconv_step) que contiene el puntero a función __fct que nos permite ejecutar código arbitrario. Para controlar __fct de forma fiable a través de _codecvt, simplemente apuntamos _codecvt a uno de los bins de malloc de glibc, que convenientemente contiene un puntero a uno de nuestros bloques libres en el heap, que contiene nuestro propio puntero a función __fct hacia código arbitrario de glibc (todas estas direcciones de glibc nos son conocidas, porque asumimos que glibc está mapeada en la dirección 0xb7400000).
Se nos acaba el tiempo
-- The Interrupters, "As We Live"
Mientras implementábamos este tercer exploit, quedó claro que no podíamos simplemente reutilizar la estrategia de sincronización que habíamos usado contra las dos versiones anteriores de OpenSSH: nunca ganábamos esta nueva condición de carrera. Finalmente, entendimos por qué:
A sshd le lleva mucho tiempo (~10ms) analizar nuestra quinta y última clave pública (el paquete e/ anterior); en otras palabras, nuestra gran ventana de carrera es demasiado grande (nuestras 27 pequeñas ventanas de carrera son como agujas en un pajar).
El user_specific_delay() que se introdujo recientemente (OpenSSH 7.8p1) retrasa la respuesta de sshd a nuestro último paquete de clave pública hasta ~9ms y, por lo tanto, destruye nuestra estrategia de sincronización basada en retroalimentación.
Como resultado, desarrollamos una estrategia de sincronización completamente diferente:
de vez en cuando, enviamos nuestro último paquete de clave pública con un pequeño error que produce una respuesta de error (líneas 138-142 más abajo), justo antes de la llamada a sshkey_from_blob() que analiza nuestra clave pública;
de vez en cuando, enviamos nuestro último paquete de clave pública con otro pequeño error que produce una respuesta de error (líneas 151-155 más abajo), justo después de la llamada a sshkey_from_blob() que analiza nuestra clave pública;
la diferencia entre estos dos tiempos de respuesta es el tiempo que tarda sshd en analizar nuestra última clave pública, y esto nos permite sincronizar con precisión la transmisión de nuestros últimos paquetes (para asegurar que sshd tenga tiempo de analizar nuestra clave pública en el hijo no privilegiado, enviarla al hijo privilegiado y comenzar a analizarla allí, antes de la entrega de SIGALRM).
Con este cambio de estrategia, se necesitan ~10,000 intentos en promedio para ganar la condición de carrera; es decir, con 100 conexiones (MaxStartups) aceptadas por cada 120 segundos (LoginGraceTime), se necesitan ~3-4 horas en promedio para ganar la condición de carrera, y ~6-8 horas para obtener una shell remota como root (debido al ASLR).
¿Cuál es tu plan para mañana?
-- The Interrupters, "Take Back the Power"
Decidimos apuntar a Rocky Linux 9 (una derivada de Red Hat Enterprise Linux 9), a partir de "Rocky-9.4-x86_64-minimal.iso", por dos razones:
su versión de OpenSSH (8.7p1) es vulnerable a esta condición de carrera del manejador de señales y su glibc siempre está mapeada en un múltiplo de 2MB (debido a la debilidad del ASLR discutida en la subsección "Teoría" anterior), lo que hace que las sobrescrituras parciales de punteros sean mucho más potentes;
la función syslog() (que no es segura para señales asíncronas pero es llamada por el manejador de SIGALRM de sshd) de esta versión de glibc (2.34) llama internamente a __open_memstream(), que asigna con malloc() una estructura FILE en el heap, y también llama a calloc(), realloc(), y free() (lo que nos da una libertad muy necesaria).
Con una corrupción del heap como primitiva, dos estructuras FILE asignadas con malloc() en el heap, y 21 bits fijos en las direcciones de glibc, creemos que esta condición de carrera del manejador de señales es explotable en amd64 (probablemente no en ~6-8 horas, pero con suerte en menos de una semana). Solo el tiempo lo dirá.
Nota al margen: descubrimos que Ubuntu 24.04 no re-randomiza el ASLR de sus hijos sshd (solo se randomiza una vez, al arrancar); rastreamos esto hasta el parche siguiente, que desactiva el rexec_flag de sshd. Esto es generalmente una mala idea, pero en el caso particular de esta condición de carrera del manejador de señales, evita que sshd sea explotable: el syslog() dentro del manejador de SIGALRM no llama a ninguna de las funciones de malloc, porque nunca es la primera llamada a syslog().
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
La tormenta ha llegado y se ha ido
-- The Interrupters, "Good Things"
El 6 de junio de 2024, esta condición de carrera del manejador de señales fue corregida por el commit 81c1099 ("Add a facility to sshd(8) to penalise particular problematic client behaviours"), que movió el código no seguro para señales asíncronas del manejador de SIGALRM de sshd al proceso listener de sshd, donde puede manejarse de forma síncrona:
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
Debido a que esta corrección forma parte de un commit grande (81c1099), además de un commit aún más grande de defensa en profundidad (03e3de4, "Start the process of splitting sshd into separate binaries"), podría resultar difícil de retroportar. En ese caso, la condición de carrera del manejador de señales en sí puede corregirse eliminando o comentando el código no seguro para señales asíncronas de la función sshsigdie(); por ejemplo:
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
Finalmente, si sshd no puede actualizarse o recompilarse, esta condición de carrera del manejador de señales puede corregirse simplemente estableciendo LoginGraceTime a 0 en el archivo de configuración. Esto hace que sshd sea vulnerable a una denegación de servicio (el agotamiento de todas las conexiones MaxStartups), pero lo hace seguro frente a la ejecución remota de código presentada en este aviso.
Agradecemos a los desarrolladores de OpenSSH por su excelente trabajo y estrecha colaboración en este lanzamiento. También agradecemos a distros@openwall. Finalmente, dedicamos este aviso a Sophia d'Antoine.
2024-05-19: Contactamos a los desarrolladores de OpenSSH. Siguieron sucesivas iteraciones de parches y revisiones de parches.
2024-06-20: Contactamos a distros@openwall.
2024-07-01: Fecha de publicación coordinada.
| ~8KB |
| 320B |
| ~8KB |
| 320B |