
Aviso de seguridad de Qualys
Baron Samedit: Desbordamiento de búfer basado en montón en Sudo (CVE-2021-3156)
Resumen Análisis Explotación Agradecimientos Cronología
Descubrimos un desbordamiento de búfer basado en montón en Sudo (https://www.sudo.ws/). Esta vulnerabilidad:
es explotable por cualquier usuario local (usuarios normales y usuarios del sistema, sudoers y no sudoers), sin autenticación (es decir, el atacante no necesita conocer la contraseña del usuario);
se introdujo en julio de 2011 (commit 8255ed69) y afecta a todas las versiones heredadas de 1.8.2 a 1.8.31p2 y a todas las versiones estables de 1.9.0 a 1.9.5p1, en su configuración predeterminada.
Desarrollamos tres exploits diferentes para esta vulnerabilidad y obtuvimos privilegios de root completos en Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27) y Fedora 33 (Sudo 1.9.2). Otros sistemas operativos y distribuciones probablemente también sean explotables.
Si se ejecuta Sudo para lanzar un comando en modo "shell" (shell -c comando):
ya sea mediante la opción -s, que establece el flag MODE_SHELL de Sudo;
o mediante la opción -i, que establece los flags MODE_SHELL y MODE_LOGIN_SHELL de Sudo;
entonces, al comienzo de main() de Sudo, parse_args() reescribe argv (líneas 609-617), concatenando todos los argumentos de la línea de comandos (líneas 587-595) y escapando todos los metacaracteres con barras invertidas (líneas 590-591):
Más adelante, en sudoers_policy_main(), set_cmnd() concatena los argumentos de la línea de comandos en un búfer basado en montón "user_args" (líneas 864-871) y desescapa los metacaracteres (líneas 866-867), "para fines de coincidencia y registro de sudoers":
Desafortunadamente, si un argumento de la línea de comandos termina con un solo carácter de barra invertida, entonces:
en la línea 866, "from[0]" es el carácter de barra invertida, y "from[1]" es el terminador nulo del argumento (es decir, no es un carácter de espacio);
en la línea 867, "from" se incrementa y apunta al terminador nulo;
en la línea 868, el terminador nulo se copia al búfer "user_args", y "from" se incrementa de nuevo y apunta al primer carácter después del terminador nulo (es decir, fuera de los límites del argumento);
el bucle "while" en las líneas 865-869 lee y copia caracteres fuera de los límites al búfer "user_args".
En otras palabras, set_cmnd() es vulnerable a un desbordamiento de búfer basado en montón, porque los caracteres fuera de los límites que se copian al búfer "user_args" no se incluyeron en su tamaño (calculado en las líneas 852-853).
En teoría, sin embargo, ningún argumento de la línea de comandos puede terminar con un solo carácter de barra invertida: si MODE_SHELL o MODE_LOGIN_SHELL está establecido (línea 858, una condición necesaria para alcanzar el código vulnerable), entonces MODE_SHELL se establece (línea 571) y parse_args() ya escapó todos los metacaracteres, incluidas las barras invertidas (es decir, escapó cada barra invertida con una segunda barra invertida).
En la práctica, sin embargo, el código vulnerable en set_cmnd() y el código de escape en parse_args() están rodeados por condiciones ligeramente diferentes:
versus:
Nuestra pregunta, entonces, es: ¿podemos establecer MODE_SHELL y ya sea MODE_EDIT o MODE_CHECK (para alcanzar el código vulnerable) pero no el MODE_RUN predeterminado (para evitar el código de escape)?
La respuesta, al parecer, es no: si establecemos MODE_EDIT (opción -e, línea 361) o MODE_CHECK (opción -l, líneas 423 y 519), entonces parse_args() elimina MODE_SHELL de los "valid_flags" (líneas 363 y 424) y sale con un error si especificamos un flag no válido como MODE_SHELL (líneas 532-533):
Pero encontramos una escapatoria: si ejecutamos Sudo como "sudoedit" en lugar de "sudo", entonces parse_args() establece automáticamente MODE_EDIT (línea 270) pero no restablece los "valid_flags", y los "valid_flags" incluyen MODE_SHELL de forma predeterminada (líneas 127 y 249):
En consecuencia, si ejecutamos "sudoedit -s", establecemos tanto MODE_EDIT como MODE_SHELL (pero no MODE_RUN), evitamos el código de escape, alcanzamos el código vulnerable y desbordamos el búfer basado en montón "user_args" mediante un argumento de la línea de comandos que termina con un solo carácter de barra invertida:
perl -e 'print "A" x 65536'
malloc(): corrupted top size
Aborted (core dumped)Desde el punto de vista de un atacante, este desbordamiento de búfer es ideal:
controlamos el tamaño del búfer "user_args" que desbordamos (el tamaño de nuestros argumentos concatenados de la línea de comandos, en las líneas 852-854);
controlamos de forma independiente el tamaño y el contenido del desbordamiento en sí (a nuestro último argumento de la línea de comandos le siguen convenientemente nuestras primeras variables de entorno, que no se incluyen en el cálculo del tamaño de las líneas 852-853);
incluso podemos escribir bytes nulos en el búfer que desbordamos (todo argumento de la línea de comandos o variable de entorno que termine con una sola barra invertida escribe un byte nulo en "user_args", en las líneas 866-868).
Por ejemplo, en un Linux amd64, el siguiente comando asigna un búfer "user_args" de 24 bytes (un chunk de montón de 32 bytes) y sobrescribe el campo size del siguiente chunk con "A=a\0B=b\0" (0x00623d4200613d41), su campo fd con "C=c\0D=d\0" (0x00643d4400633d43), y su campo bk con "E=e\0F=f\0" (0x00663d4600653d45):
--|--------+--------+--------+--------|--------+--------+--------+--------+-- | | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.| --|--------+--------+--------+--------|--------+--------+--------+--------+-- size <---- user_args buffer ----> size fd bk
Debido a que Sudo llama a funciones de localización al comienzo mismo de su función main():
y pasa cadenas de traducción (a través de la función gettext() y la macro _()) a funciones de cadena de formato como:
inicialmente queríamos reutilizar la fascinante técnica de halfdog de https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ y transformar el desbordamiento de búfer basado en montón de Sudo en un exploit de cadena de formato. Más precisamente:
en la línea 154, en setlocale(), asignamos con malloc() y liberamos con free() varias variables de entorno LC (LC_CTYPE, LC_MESSAGES, LC_TIME, etc.), creando así pequeños agujeros al comienzo mismo del montón de Sudo (chunks fast o tcache libres);
en la línea 155, bindtextdomain() asigna con malloc() una estructura binding, que contiene un puntero dirname al nombre de un directorio que contiene archivos de catálogo ".mo" y, por lo tanto, cadenas de traducción;
en set_cmnd(), asignamos con malloc() el búfer "user_args" en uno de los agujeros al comienzo del montón de Sudo, y desbordamos este búfer, sobrescribiendo así el puntero dirname de la estructura binding;
en la línea 301 (por ejemplo), gettext() (a través de la macro _()) carga nuestra propia cadena de traducción desde el dirname sobrescrito -- en otras palabras, controlamos la cadena de formato que se pasa a sudo_printf().
Para implementar esta técnica inicial, escribimos un brute-forcer rudimentario que ejecuta Sudo dentro de gdb, desborda el búfer "user_args" y selecciona aleatoriamente los siguientes parámetros:
las variables de entorno LC que pasamos a Sudo, y su longitud (usamos la configuración regional "C.UTF-8" y añadimos un "@modifier" aleatorio);
el tamaño del búfer "user_args" que desbordamos;
el tamaño del desbordamiento en sí;
si pasamos por el código de autenticación de Sudo (opción -A o -n) o no (opción -u #realuid).
Desafortunadamente, esta técnica inicial falló; nuestro brute-forcer fue capaz de sobrescribir el puntero dirname de la estructura binding:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6e0dde1ea9 in __dcigettext (domainname=domainname@entry=0x7f6e0d9cc020 "sudoers", msgid1=msgid1@entry=0x7f6e0d9cc014 "user NOT in sudoers", msgid2=msgid2@entry=0x0, plural=plural@entry=0, n=n@entry=0, category=5) at dcigettext.c:619
=> 0x7f6e0dde1ea9 <__dcigettext+1257>: cmpb $0x2f,(%rax)
pero LC_MESSAGES siempre era la configuración regional "C" predeterminada (no "C.UTF-8"), lo que desactiva la traducción de cadenas en gettext() (es decir, gettext() devuelve la cadena de formato original, no la nuestra).
Afortunadamente, sin embargo, nuestro brute-forcer produjo docenas de fallos únicos de Sudo y backtraces de gdb; entre estos, tres llamaron nuestra atención, y finalmente explotamos los tres.
El primer fallo que llamó nuestra atención es:
Program received signal SIGSEGV, Segmentation fault.
0x000056291a25d502 in process_hooks_getenv (name=name@entry=0x7f4a6d7dc046 "SYSTEMD_BYPASS_USERDB", value=value@entry=0x7ffc595cc240) at ../../src/hooks.c:108
=> 0x56291a25d502 <process_hooks_getenv+82>: callq *0x8(%rbx)
rbx 0x56291c1df2b0 94734565372592
Increíblemente, la función process_hooks_getenv() de Sudo falló (en la línea 108) porque sobrescribimos directamente un puntero a función, getenv_fn (un miembro de una estructura sudo_hook_entry basada en montón):
Para explotar esta sobrescritura de struct sudo_hook_entry, observamos que:
la llamada a getenv_fn (en la línea 108) es compatible con una llamada a execve():
. name ("SYSTEMD_BYPASS_USERDB") es compatible con el argumento pathname de execve();
. &val (un puntero a un puntero NULL) es compatible con argv de execve();
. hook->closure (un puntero NULL) es compatible con envp de execve();
podemos derrotar a ASLR sobrescribiendo parcialmente el puntero a función getenv_fn (que apunta a la función sudoers_hook_getenv() en la biblioteca compartida sudoers.so); y afortunadamente, el comienzo de sudoers.so contiene una llamada a execve() (o execv()):
En consecuencia, adoptamos la siguiente estrategia:
Probamos con éxito este primer exploit en Ubuntu 20.04.
El segundo fallo que llamó nuestra atención es:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344
=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)
La función nss_load_library() de glibc falló (en la línea 344) porque sobrescribimos el puntero "library", un miembro de una estructura service_user basada en montón:
Podemos transformar fácilmente esta sobrescritura de struct service_user en una ejecución arbitraria de código:
sobrescribimos ni->library con un puntero NULL, para entrar en el bloque de las líneas 330-342, evitar el fallo en la línea 344 y entrar en el bloque de las líneas 344-359;
sobrescribimos ni->name (un array de caracteres, inicialmente "systemd") con "X/X";
las líneas 353-357 construyen el nombre de una biblioteca compartida "libnss_X/X.so.2" (en lugar de "libnss_systemd.so.2");- en la línea 359, cargamos nuestra propia biblioteca compartida "libnss_X/X.so.2" desde el directorio de trabajo actual y ejecutamos nuestro constructor _init() como root.
Probamos con éxito este segundo exploit en Ubuntu 20.04, Debian 10 y Fedora 33.
Nuestro tercer exploit no se deriva de uno de los fallos de Sudo, sino de una observación casual: durante nuestra fuerza bruta, Sudo creó docenas de nuevos directorios en nuestro directorio de trabajo actual (AAAAAA, AAAAAAAAA, etc.). Cada uno de estos directorios pertenece a root y contiene solo un pequeño archivo, con el nombre de nuestro propio usuario: el archivo timestamp de Sudo -- evidentemente sobrescribimos def_timestampdir, el nombre del directorio de timestamps de Sudo.
Si sobrescribimos def_timestampdir con el nombre de un directorio que no existe todavía, entonces podemos competir contra ts_mkdirs() de Sudo, crear un enlace simbólico a un archivo arbitrario y:
3a/ bien hacer chown() de este archivo arbitrario a usuario root y grupo root;
3b/ bien abrir (o crear) este archivo arbitrario como root, y escribir una struct timestamp_entry en él.
No pudimos transformar 3a/ en privilegios root completos (por ejemplo, si hacemos chown() de nuestro propio binario SUID a root, entonces el kernel elimina automáticamente el bit SUID de nuestro binario). Si usted, querido lector, encuentra una solución a este problema, ¡por favor publíquela en la lista de correo pública oss-security!
Finalmente, pudimos transformar 3b/ en privilegios root completos, pero inicialmente nos enfrentamos a dos problemas:
timestamp_open() de Sudo elimina nuestro enlace simbólico arbitrario si el archivo al que apunta es más antiguo que el tiempo de arranque. Pudimos resolver este primer problema creando un archivo timestamp muy antiguo (desde la época de Unix), esperando a que timestamp_open() lo elimine, y compitiendo contra timestamp_open() para crear nuestro enlace simbólico arbitrario final.
No controlamos el contenido de la struct timestamp_entry que se escribe en el archivo arbitrario. Hasta donde sabemos, solo controlamos tres bytes (un ID de proceso o una struct timespec), y no pudimos transformar esta escritura de tres bytes en privilegios root completos. Si usted, querido lector, encuentra una solución a este problema, ¡por favor publíquela en la lista de correo pública oss-security!
Sin embargo, pudimos sortear este segundo problema abusando de un pequeño fallo en timestamp_lock() de Sudo. Si ganamos las dos competiciones contra ts_mkdirs() y timestamp_open(), y si nuestro enlace simbólico arbitrario apunta a /etc/passwd, entonces este archivo se abre como root, y:
en la línea 644, los primeros 0x38 bytes de /etc/passwd ("root❌0:0:...") se leen en una struct timestamp_entry basada en la pila, entry;
en la línea 652, entry.type es 0x783a (":x"), no TS_LOCKEXCL;
en las líneas 657 y 318, se escriben entry->size bytes de la entry basada en la pila en /etc/passwd, pero entry->size es en realidad 0x746f ("ot"), no sizeof(struct timestamp_entry).
Como resultado, escribimos el contenido completo de la pila de Sudo en /etc/passwd (incluyendo nuestros argumentos de línea de comandos y nuestras variables de entorno): inyectamos un usuario arbitrario en /etc/passwd y, por lo tanto, obtenemos privilegios root completos. Probamos con éxito este tercer exploit en Ubuntu 20.04.
Nota: este pequeño fallo en timestamp_lock() se corrigió en enero de 2020 mediante el commit 586b418a, pero esta corrección no se trasladó a las versiones heredadas.
Agradecemos a Todd C. Miller por su profesionalismo, rápida respuesta y meticulosa atención a cada detalle de nuestro informe. También agradecemos a los miembros de distros@openwall.
2021-01-13: Aviso enviado a Todd.Miller@sudo.
2021-01-19: Aviso y parches enviados a distros@openwall.
2021-01-26: Fecha de publicación coordinada (6:00 PM UTC).