
Qualys Security Advisory
Baron Samedit: buffer overflow basato su heap in Sudo (CVE-2021-3156)
Sommario Analisi Sfruttamento Riconoscimenti Cronologia
Abbiamo scoperto un buffer overflow basato su heap in Sudo (https://www.sudo.ws/). Questa vulnerabilità:
è sfruttabile da qualsiasi utente locale (utenti normali e utenti di sistema, sudoers e non sudoers), senza autenticazione (cioè l'attaccante non ha bisogno di conoscere la password dell'utente);
è stata introdotta nel luglio 2011 (commit 8255ed69) e interessa tutte le versioni legacy dalla 1.8.2 alla 1.8.31p2 e tutte le versioni stabili dalla 1.9.0 alla 1.9.5p1, nella loro configurazione predefinita.
Abbiamo sviluppato tre exploit diversi per questa vulnerabilità e ottenuto privilegi di root completi su Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27) e Fedora 33 (Sudo 1.9.2). Probabilmente anche altri sistemi operativi e distribuzioni sono sfruttabili.
Se Sudo viene eseguito per lanciare un comando in modalità "shell" (shell -c command):
tramite l'opzione -s, che imposta il flag MODE_SHELL di Sudo;
oppure tramite l'opzione -i, che imposta i flag MODE_SHELL e MODE_LOGIN_SHELL di Sudo;
allora, all'inizio della funzione main() di Sudo, parse_args() riscrive argv (righe 609-617), concatenando tutti gli argomenti della riga di comando (righe 587-595) e facendo l'escaping di tutti i metacaratteri con backslash (righe 590-591):
Successivamente, in sudoers_policy_main(), set_cmnd() concatena gli argomenti della riga di comando in un buffer basato su heap "user_args" (righe 864-871) e rimuove l'escaping dei metacaratteri (righe 866-867), "per scopi di corrispondenza e registrazione sudoers":
Purtroppo, se un argomento della riga di comando termina con un singolo carattere backslash, allora:
alla riga 866, "from[0]" è il carattere backslash e "from[1]" è il terminatore nullo dell'argomento (cioè non un carattere spazio);
alla riga 867, "from" viene incrementato e punta al terminatore nullo;
alla riga 868, il terminatore nullo viene copiato nel buffer "user_args" e "from" viene incrementato di nuovo e punta al primo carattere dopo il terminatore nullo (cioè fuori dai limiti dell'argomento);
il ciclo "while" alle righe 865-869 legge e copia caratteri fuori dai limiti nel buffer "user_args".
In altre parole, set_cmnd() è vulnerabile a un buffer overflow basato su heap, perché i caratteri fuori dai limiti copiati nel buffer "user_args" non erano inclusi nella sua dimensione (calcolata alle righe 852-853).
In teoria, tuttavia, nessun argomento della riga di comando può terminare con un singolo carattere backslash: se MODE_SHELL o MODE_LOGIN_SHELL è impostato (riga 858, condizione necessaria per raggiungere il codice vulnerabile), allora MODE_SHELL è impostato (riga 571) e parse_args() ha già effettuato l'escaping di tutti i metacaratteri, inclusi i backslash (cioè ha fatto l'escaping di ogni singolo backslash con un secondo backslash).
In pratica, però, il codice vulnerabile in set_cmnd() e il codice di escaping in parse_args() sono circondati da condizioni leggermente diverse:
versus:
La nostra domanda, quindi, è: possiamo impostare MODE_SHELL e MODE_EDIT o MODE_CHECK (per raggiungere il codice vulnerabile) ma non il MODE_RUN predefinito (per evitare il codice di escaping)?
La risposta, a quanto pare, è no: se impostiamo MODE_EDIT (opzione -e, riga 361) o MODE_CHECK (opzione -l, righe 423 e 519), allora parse_args() rimuove MODE_SHELL dai "valid_flags" (righe 363 e 424) ed esce con un errore se specifichiamo un flag non valido come MODE_SHELL (righe 532-533):
Ma abbiamo trovato una scappatoia: se eseguiamo Sudo come "sudoedit" invece di "sudo", allora parse_args() imposta automaticamente MODE_EDIT (riga 270) ma non reimposta "valid_flags", e i "valid_flags" includono MODE_SHELL per impostazione predefinita (righe 127 e 249):
Di conseguenza, se eseguiamo "sudoedit -s", impostiamo sia MODE_EDIT che MODE_SHELL (ma non MODE_RUN), evitiamo il codice di escaping, raggiungiamo il codice vulnerabile e facciamo overflow del buffer basato su heap "user_args" tramite un argomento della riga di comando che termina con un singolo carattere backslash:
perl -e 'print "A" x 65536'
malloc(): corrupted top size
Aborted (core dumped)Dal punto di vista di un attaccante, questo buffer overflow è ideale:
controlliamo la dimensione del buffer "user_args" che mandiamo in overflow (la dimensione dei nostri argomenti della riga di comando concatenati, alle righe 852-854);
controlliamo in modo indipendente la dimensione e il contenuto dell'overflow stesso (il nostro ultimo argomento della riga di comando è opportunamente seguito dalle nostre prime variabili d'ambiente, che non sono incluse nel calcolo della dimensione alle righe 852-853);
possiamo persino scrivere byte nulli nel buffer che mandiamo in overflow (ogni argomento della riga di comando o variabile d'ambiente che termina con un singolo backslash scrive un byte nullo in "user_args", alle righe 866-868).
Ad esempio, su un Linux amd64, il seguente comando alloca un buffer "user_args" di 24 byte (un chunk heap di 32 byte) e sovrascrive il campo size del chunk successivo con "A=a\0B=b\0" (0x00623d4200613d41), il suo campo fd con "C=c\0D=d\0" (0x00643d4400633d43) e il suo 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
Poiché Sudo chiama le funzioni di localizzazione all'inizio della sua funzione main():
e passa stringhe di traduzione (attraverso la funzione gettext() e la macro _()) a funzioni che accettano stringhe di formato, come:
inizialmente abbiamo voluto riutilizzare l'affascinante tecnica di halfdog da https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ e trasformare il buffer overflow basato su heap di Sudo in un exploit di tipo format-string. Più precisamente:
alla riga 154, in setlocale(), chiamiamo malloc() e free() su diverse variabili d'ambiente LC (LC_CTYPE, LC_MESSAGES, LC_TIME, ecc.), creando così piccoli buchi all'inizio dell'heap di Sudo (chunk fast o tcache liberi);
alla riga 155, bindtextdomain() alloca con malloc() una struct binding, che contiene un puntatore dirname al nome di una directory che contiene file di catalogo ".mo" e quindi stringhe di traduzione;
in set_cmnd(), allochiamo con malloc() il buffer "user_args" in uno dei buchi all'inizio dell'heap di Sudo e facciamo overflow di questo buffer, sovrascrivendo così il puntatore dirname della struct binding;
alla riga 301 (per esempio), gettext() (attraverso la macro _()) carica la nostra stringa di traduzione dal dirname sovrascritto -- in altre parole, controlliamo la stringa di formato passata a sudo_printf().
Per implementare questa tecnica iniziale, abbiamo scritto un brute-forcer rudimentale che esegue Sudo all'interno di gdb, fa overflow del buffer "user_args" e seleziona casualmente i seguenti parametri:
le variabili d'ambiente LC che passiamo a Sudo e la loro lunghezza (usiamo la locale "C.UTF-8" e aggiungiamo un "@modifier" casuale);
la dimensione del buffer "user_args" che mandiamo in overflow;
la dimensione dell'overflow stesso;
se passare o meno attraverso il codice di autenticazione di Sudo (opzione -A o -n) o no (opzione -u #realuid).
Purtroppo, questa tecnica iniziale è fallita; il nostro brute-forcer è riuscito a sovrascrivere il puntatore dirname della 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)
ma LC_MESSAGES era sempre la locale predefinita "C" (non "C.UTF-8"), il che disabilita la traduzione delle stringhe in gettext() (cioè gettext() restituisce la stringa di formato originale, non la nostra).
Fortunatamente, però, il nostro brute-forcer ha prodotto decine di crash unici di Sudo e backtrace gdb; tra questi, tre hanno attirato la nostra attenzione e alla fine li abbiamo sfruttati tutti e tre.
Il primo crash che ha attirato la nostra attenzione è:
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
Incredibilmente, la funzione process_hooks_getenv() di Sudo è andata in crash (alla riga 108) perché abbiamo sovrascritto direttamente un puntatore a funzione, getenv_fn (un membro di una struct sudo_hook_entry basata su heap):
Per sfruttare questa sovrascrittura di struct sudo_hook_entry, notiamo che:
la chiamata a getenv_fn (alla riga 108) è compatibile con una chiamata a execve():
. name ("SYSTEMD_BYPASS_USERDB") è compatibile con l'argomento pathname di execve();
. &val (un puntatore a un puntatore NULL) è compatibile con argv di execve();
. hook->closure (un puntatore NULL) è compatibile con envp di execve();
possiamo bypassare ASLR sovrascrivendo parzialmente il puntatore a funzione getenv_fn (che punta alla funzione sudoers_hook_getenv() nella libreria condivisa sudoers.so); e fortunatamente, l'inizio di sudoers.so contiene una chiamata a execve() (o execv()):
Di conseguenza, adottiamo la seguente strategia:
Abbiamo testato con successo questo primo exploit su Ubuntu 20.04.
Il secondo crash che ha attirato la nostra attenzione è:
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 funzione nss_load_library() della glibc è andata in crash (alla riga 344) perché abbiamo sovrascritto il puntatore "library", un membro di una struct service_user basata su heap:
Possiamo facilmente trasformare questa sovrascrittura di struct service_user in un'esecuzione arbitraria di codice:
sovrascriviamo ni->library con un puntatore NULL, per entrare nel blocco alle righe 330-342, evitare il crash alla riga 344 ed entrare nel blocco alle righe 344-359;
sovrascriviamo ni->name (un array di caratteri, inizialmente "systemd") con "X/X";
le righe 353-357 costruiscono il nome di una libreria condivisa "libnss_X/X.so.2" (invece di "libnss_systemd.so.2");
------------------------------------------------------------------------- alla riga 359, carichiamo la nostra libreria condivisa "libnss_X/X.so.2" dalla directory di lavoro corrente ed eseguiamo il nostro costruttore _init() come root.
Abbiamo testato con successo questo secondo exploit su Ubuntu 20.04, Debian 10, e Fedora 33.
Il nostro terzo exploit non deriva da uno dei crash di Sudo, ma da un' osservazione casuale: durante il nostro brute-force, Sudo ha creato decine di nuove directory nella nostra directory di lavoro corrente (AAAAAA, AAAAAAAAA, ecc.). Ognuna di queste directory appartiene a root e contiene solo un piccolo file, chiamato come il nostro utente: il file timestamp di Sudo -- evidentemente abbiamo sovrascritto def_timestampdir, il nome della directory dei timestamp di Sudo.
Se sovrascriviamo def_timestampdir con il nome di una directory che non esiste già, allora possiamo fare una race contro ts_mkdirs() di Sudo, creare un symlink a un file arbitrario e:
3a/ chown() questo file arbitrario a utente root e gruppo root;
3b/ oppure aprire (o creare) questo file arbitrario come root e scrivervi una struct timestamp_entry.
Non siamo riusciti a trasformare 3a/ in pieni privilegi di root (ad esempio, se eseguiamo chown() del nostro binario SUID a root, il kernel rimuove automaticamente il bit SUID del nostro binario). Se tu, caro lettore, trovi una soluzione a questo problema, pubblicala sulla mailing list pubblica oss-security!
Alla fine, siamo riusciti a trasformare 3b/ in pieni privilegi di root, ma inizialmente abbiamo affrontato due problemi:
timestamp_open() di Sudo elimina il nostro symlink arbitrario se il file a cui punta è più vecchio del tempo di boot. Siamo riusciti a risolvere questo primo problema creando un file timestamp molto vecchio (risalente all'epoca Unix), aspettando che timestamp_open() lo elimini e facendo una race contro timestamp_open() per creare il nostro symlink arbitrario finale.
Non controlliamo il contenuto della struct timestamp_entry che viene scritta nel file arbitrario. Per quanto ne sappiamo, controlliamo solo tre byte (un process ID o una struct timespec) e non siamo riusciti a trasformare questa scrittura di tre byte in pieni privilegi di root. Se tu, caro lettore, trovi una soluzione a questo problema, pubblicala sulla mailing list pubblica oss-security!
Tuttavia, siamo riusciti ad aggirare questo secondo problema abusando di un piccolo bug in timestamp_lock() di Sudo. Se vinciamo le due race contro ts_mkdirs() e timestamp_open(), e se il nostro symlink arbitrario punta a /etc/passwd, allora questo file viene aperto come root e:
alla riga 644, i primi 0x38 byte di /etc/passwd ("root❌0:0:...") vengono letti in una struct timestamp_entry basata sullo stack, entry;
alla riga 652, entry.type è 0x783a (":x"), non TS_LOCKEXCL;
alle righe 657 e 318, entry->size byte della entry basata sullo stack vengono scritti in /etc/passwd, ma entry->size è in realtà 0x746f ("ot"), non sizeof(struct timestamp_entry).
Di conseguenza, scriviamo l'intero contenuto dello stack di Sudo in /etc/passwd (inclusi i nostri argomenti da riga di comando e le nostre variabili d'ambiente): iniettiamo un utente arbitrario in /etc/passwd e otteniamo quindi pieni privilegi di root. Abbiamo testato con successo questo terzo exploit su Ubuntu 20.04.
Nota: questo piccolo bug in timestamp_lock() è stato corretto a gennaio 2020 dal commit 586b418a, ma la correzione non è stata backportata alle versioni legacy.
Ringraziamo Todd C. Miller per la sua professionalità, la rapidità di risposta e l'attenzione meticolosa a ogni dettaglio del nostro rapporto. Ringraziamo anche i membri di distros@openwall.
2021-01-13: Advisory inviato a Todd.Miller@sudo.
2021-01-19: Advisory e patch inviati a distros@openwall.
2021-01-26: Data di rilascio coordinata (18:00 UTC).