
Analyse technique et développement d'exploit pour CVE-2021-3156, un débordement de tampon basé sur le tas dans Sudo, y compris trois exploits fonctionnels pour l'élévation de privilèges locale sur les principales distributions Linux.
Qualys Security Advisory
Baron Samedit : Débordement de tampon basé sur le tas dans Sudo (CVE-2021-3156)
Résumé Analyse Exploitation Remerciements Chronologie
Nous avons découvert un débordement de tampon basé sur le tas dans Sudo (https://www.sudo.ws/). Cette vulnérabilité :
est exploitable par tout utilisateur local (utilisateurs normaux et utilisateurs système, sudoers et non-sudoers), sans authentification (c'est-à-dire que l'attaquant n'a pas besoin de connaître le mot de passe de l'utilisateur) ;
a été introduite en juillet 2011 (commit 8255ed69), et affecte toutes 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.
Nous avons développé trois exploits différents pour cette vulnérabilité, et obtenu les privilèges root complets sur Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27), et Fedora 33 (Sudo 1.9.2). D'autres systèmes d'exploitation et distributions sont probablement aussi exploitables.
Si Sudo est exécuté pour lancer une commande en mode "shell" (shell -c commande) :
soit via l'option -s, qui définit le drapeau MODE_SHELL de Sudo ;
soit via l'option -i, qui définit les drapeaux MODE_SHELL et MODE_LOGIN_SHELL de Sudo ;
alors, au début de main() de Sudo, parse_args() réécrit argv (lignes 609-617), en concaténant tous les arguments de la ligne de commande (lignes 587-595) et en échappant tous les métacaractères avec des backslashes (lignes 590-591) :
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" (lignes 864-871) et déséchappe les métacaractères (lignes 866-867), "à des fins de correspondance et de journalisation sudoers" :
Malheureusement, si un argument de ligne de commande se termine par un seul caractère backslash, alors :
à la ligne 866, "from[0]" est le caractère backslash, et "from[1]" est le terminateur nul de l'argument (c'est-à-dire pas un espace) ;
à la ligne 867, "from" est incrémenté et pointe vers le terminateur nul ;
à la ligne 868, le terminateur nul est copié dans le tampon "user_args", et "from" est à nouveau incrémenté et pointe vers le premier caractère après le terminateur nul (c'est-à-dire hors des limites de l'argument) ;
la boucle "while" aux lignes 865-869 lit et copie des caractères hors limites dans le tampon "user_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 (calculée aux lignes 852-853).
En théorie, cependant, aucun argument de ligne de commande ne peut se terminer par un seul caractère backslash : si MODE_SHELL ou MODE_LOGIN_SHELL est défini (ligne 858, une condition nécessaire pour atteindre le code vulnérable), alors MODE_SHELL est défini (ligne 571) et parse_args() a déjà échappé tous les métacaractères, y compris les backslashes (c'est-à-dire qu'il a échappé chaque backslash unique avec un second backslash).
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 :
vs :
Notre question, alors, est : pouvons-nous définir MODE_SHELL et soit MODE_EDIT soit 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 définissons MODE_EDIT (option -e, ligne 361) ou MODE_CHECK (option -l, lignes 423 et 519), alors parse_args() supprime MODE_SHELL des "valid_flags" (lignes 363 et 424) et se termine avec une erreur si nous spécifions un drapeau non valide tel que MODE_SHELL (lignes 532-533) :
Mais nous avons trouvé une faille : si nous exécutons Sudo en tant que "sudoedit" au lieu de "sudo", alors parse_args() définit automatiquement MODE_EDIT (ligne 270) mais ne réinitialise pas "valid_flags", et les "valid_flags" incluent MODE_SHELL par défaut (lignes 127 et 249) :
Par conséquent, si nous exécutons "sudoedit -s", alors nous définissons à 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 caractère backslash :
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 :
nous contrôlons la taille du tampon "user_args" que nous débordons (la taille de nos arguments de ligne de commande concaténés, aux lignes 852-854) ;
nous contrôlons indépendamment la taille et le contenu du débordement lui-même (notre dernier argument de ligne de commande est commodément suivi par nos premiers variables d'environnement, qui ne sont pas incluses dans le calcul de taille aux lignes 852-853) ;
nous pouvons même écrire des octets nuls dans le tampon que nous débordons (chaque argument de ligne de commande ou variable d'environnement qui se termine par un seul backslash écrit un octet nul dans "user_args", aux lignes 866-868).
Par exemple, sur un Linux amd64, la commande suivante alloue un tampon "user_args" de 24 octets (un chunk de tas de 32 octets) et écrase le champ size du chunk 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) :
--|--------+--------+--------+--------|--------+--------+--------+--------+-- | | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.| --|--------+--------+--------+--------|--------+--------+--------+--------+-- taille <---- tampon user_args ----> taille fd bk
Parce que Sudo appelle des fonctions de localisation au tout début de sa fonction main() :
et passe des chaînes de traduction (via la fonction gettext() et la macro _()) à des fonctions de format de chaîne telles que :
nous avons initialement voulu réutiliser la technique fascinante de halfdog de https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ et transformer le débordement de tampon basé sur le tas de Sudo en un exploit de format de chaîne. Plus précisément :
à la ligne 154, dans setlocale(), nous malloquons et libérons plusieurs variables d'environnement LC (LC_CTYPE, LC_MESSAGES, LC_TIME, etc), créant ainsi des petits trous au tout début du tas de Sudo (petits blocs libres fast ou tcache) ;
à la ligne 155, bindtextdomain() malloque un struct binding, qui contient un pointeur dirname vers le nom d'un répertoire qui contient des fichiers catalogue ".mo" et donc des chaînes de traduction ;
dans set_cmnd(), nous malloquons le tampon "user_args" dans l'un des trous au début du tas de Sudo, et débordons ce tampon, écrasant ainsi le pointeur dirname du struct binding ;
à la ligne 301 (par exemple), gettext() (via la macro _()) charge notre propre chaîne de traduction depuis le dirname écrasé -- en d'autres termes, nous contrôlons la chaîne de format qui est passée à sudo_printf().
Pour implémenter cette technique initiale, nous avons écrit un brute-forcer rudimentaire qui exécute Sudo dans gdb, déborde le tampon "user_args", et sélectionne aléatoirement les paramètres suivants :
les variables d'environnement LC que nous passons à Sudo, et leur longueur (nous utilisons la locale "C.UTF-8" et ajoutons un "@modifier" aléatoire) ;
la taille du tampon "user_args" que nous débordons ;
la taille du débordement lui-même ;
si nous passons par le code d'authentification de Sudo (option -A ou -n) ou non (option -u #realuid).
Malheureusement, cette technique initiale a échoué ; notre brute-forcer a pu écraser le pointeur dirname du struct 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)
mais LC_MESSAGES était toujours la locale "C" par défaut (pas "C.UTF-8"), ce qui désactive la traduction de chaîne dans gettext() (c'est-à-dire que gettext() renvoie la chaîne de format originale, pas la nôtre).
Heureusement, cependant, notre brute-forcer a produit des dizaines de crashs uniques de Sudo et des backtraces gdb ; parmi ceux-ci, trois ont attiré notre attention, et nous avons finalement exploité les trois.
Le premier crash qui a attiré notre attention est :
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
Incroyablement, la fonction process_hooks_getenv() de Sudo a crashé (à la ligne 108) parce que nous avons directement écrasé un pointeur de fonction, getenv_fn (un membre d'un struct sudo_hook_entry basé sur le tas) :
Pour exploiter cet écrasement de struct sudo_hook_entry, nous notons que :
l'appel à getenv_fn (à la ligne 108) est compatible avec un appel à execve() :
. name ("SYSTEMD_BYPASS_USERDB") est compatible avec l'argument pathname de execve() ;
. &val (un pointeur vers un pointeur NULL) est compatible avec l'argument argv de execve() ;
. hook->closure (un pointeur NULL) est compatible avec l'argument envp de execve() ;
nous pouvons vaincre l'ASLR en écrasant partiellement le pointeur de fonction getenv_fn (qui pointe vers la fonction sudoers_hook_getenv() dans la bibliothèque partagée sudoers.so) ; et heureusement, le début de sudoers.so contient un appel à execve() (ou execv()) :
Par conséquent, nous adoptons la stratégie suivante :
Nous avons testé avec succès ce premier exploit sur Ubuntu 20.04.
Le deuxième crash qui a attiré notre attention est :
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 fonction nss_load_library() de glibc a crashé (à la ligne 344) parce que nous avons écrasé le pointeur "library", un membre d'un struct service_user basé sur le tas :
Nous pouvons facilement transformer cet écrasement de struct service_user en une exécution de code arbitraire :
nous écrasons ni->library avec un pointeur NULL, pour entrer dans le bloc aux lignes 330-342, éviter le crash à la ligne 344, et entrer dans le bloc aux lignes 344-359 ;
nous écrasons ni->name (un tableau de caractères, initialement "systemd") avec "X/X" ;
les lignes 353-357 construisent le nom d'une bibliothèque partagée "libnss_X/X.so.2" (au lieu de "libnss_systemd.so.2") ;- à la ligne 359, nous chargeons notre propre bibliothèque partagée « libnss_X/X.so.2 » depuis le répertoire de travail courant et exécutons notre constructeur _init() en tant que root.
Nous avons testé avec succès cette seconde exploitation sur Ubuntu 20.04, Debian 10 et Fedora 33.
Notre troisième exploitation ne provient pas d’un plantage de Sudo, mais d’une observation fortuite : lors de notre brute‑force, Sudo créait des dizaines de nouveaux répertoires dans notre répertoire de travail courant (AAAAAA, AAAAAAAAA, etc.). Chacun de ces répertoires appartient à root et ne contient qu’un seul petit fichier, nommé d’après notre propre utilisateur : le fichier d’horodatage de Sudo — nous avons manifestement écrasé def_timestampdir, le nom du répertoire des horodatages de Sudo.
Si nous écrasons def_timestampdir avec le nom d’un répertoire qui n’existe pas encore, alors nous pouvons faire la course contre ts_mkdirs() de Sudo, créer un lien symbolique vers un fichier arbitraire, et :
3a/ soit chown() ce fichier arbitraire en root:root ;
3b/ soit ouvrir (ou créer) ce fichier arbitraire en tant que root, et y écrire un struct timestamp_entry.
Nous n’avons pas réussi à transformer 3a/ en privilèges root complets (par exemple, si nous chown() notre propre binaire SUID en root, le noyau supprime automatiquement le bit SUID de notre binaire). Si vous, cher lecteur, trouvez une solution à ce problème, veuillez la publier sur la liste de diffusion publique oss‑security !
Finalement, nous avons réussi à transformer 3b/ en privilèges root complets, mais nous avons d’abord rencontré deux problèmes :
timestamp_open() de Sudo supprime notre lien symbolique arbitraire si le fichier pointé est plus ancien que l’heure de démarrage. Nous avons pu résoudre ce premier problème en créant un fichier d’horodatage très ancien (depuis l’époque Unix), en attendant que timestamp_open() le supprime, puis en faisant la course contre timestamp_open() pour créer notre lien symbolique arbitraire final.
Nous ne contrôlons pas le contenu du struct timestamp_entry qui est écrit dans le fichier arbitraire. À notre connaissance, nous ne contrôlons que trois octets (un ID de processus ou un struct timespec), et nous n’avons pas réussi à transformer cette écriture de trois octets en privilèges root complets. Si vous, cher lecteur, trouvez une solution à ce problème, veuillez la publier sur la liste de diffusion publique oss‑security !
Cependant, nous avons pu contourner ce second problème en abusant d’un bogue mineur dans timestamp_lock() de Sudo. Si nous gagnons les deux courses contre ts_mkdirs() et timestamp_open(), et si notre lien symbolique arbitraire pointe vers /etc/passwd, alors ce fichier est ouvert en tant que root, et :
à la ligne 644, les premiers 0x38 octets de /etc/passwd (« root❌0:0:... ») sont lus dans un struct timestamp_entry basé sur la pile, entry ;
à la ligne 652, entry.type vaut 0x783a (« :x »), pas TS_LOCKEXCL ;
aux lignes 657 et 318, entry->size octets de l’entrée basée sur la pile sont écrits dans /etc/passwd, mais entry->size vaut en réalité 0x746f (« ot »), pas sizeof(struct timestamp_entry).
En conséquence, nous écrivons l’intégralité du contenu de la pile de Sudo dans /etc/passwd (y compris nos arguments de ligne de commande et nos variables d’environnement) : nous injectons un utilisateur arbitraire dans /etc/passwd et obtenons ainsi les privilèges root complets. Nous avons testé avec succès cette troisième exploitation sur Ubuntu 20.04.
Remarque : ce bogue mineur dans timestamp_lock() a été corrigé en janvier 2020 par le commit 586b418a, mais cette correction n’a pas été rétro‑portée vers les versions héritées.
Nous remercions Todd C. Miller pour son professionnalisme, sa réactivité et son attention méticuleuse à chaque détail de notre rapport. Nous remercions également les membres de distros@openwall.
2021-01-13 : Avis envoyé à Todd.Miller@sudo.
2021-01-19 : Avis et correctifs envoyés à distros@openwall.
2021-01-26 : Date de publication coordonnée (18 h 00 UTC).