
Technische Analyse und Exploit-Entwicklung für CVE-2021-3156, einen Heap-basierten Pufferüberlauf in Sudo, einschließlich drei funktionierender Exploits für lokale Privilegieneskalation auf wichtigen Linux-Distributionen.
Qualys Security Advisory
Baron Samedit: Heap-basierter Pufferüberlauf in Sudo (CVE-2021-3156)
Zusammenfassung Analyse Ausnutzung Danksagungen Zeitplan
Wir haben einen heap-basierten Pufferüberlauf in Sudo (https://www.sudo.ws/) entdeckt. Diese Schwachstelle:
ist von jedem lokalen Benutzer ausnutzbar (normale Benutzer und Systembenutzer, sudoers und Nicht-sudoers), ohne Authentifizierung (d. h., der Angreifer muss das Passwort des Benutzers nicht kennen);
wurde im Juli 2011 eingeführt (Commit 8255ed69) und betrifft alle Legacy- Versionen von 1.8.2 bis 1.8.31p2 sowie alle stabilen Versionen von 1.9.0 bis 1.9.5p1 in ihrer Standardkonfiguration.
Wir haben drei verschiedene Exploits für diese Schwachstelle entwickelt und volle Root-Rechte auf Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27) und Fedora 33 (Sudo 1.9.2) erlangt. Andere Betriebssysteme und Distributionen sind wahrscheinlich ebenfalls ausnutzbar.
Wenn Sudo ausgeführt wird, um einen Befehl im "Shell"-Modus auszuführen (shell -c command):
entweder über die Option -s, die Sudos MODE_SHELL-Flag setzt;
oder über die Option -i, die Sudos MODE_SHELL- und MODE_LOGIN_SHELL-Flags setzt;
dann schreibt parse_args() zu Beginn von Sudos main() argv um (Zeilen 609-617), indem es alle Kommandozeilenargumente verkettet (Zeilen 587-595) und alle Meta-Zeichen mit Backslashes escaped (Zeilen 590-591):
Später, in sudoers_policy_main(), verkettet set_cmnd() die Kommandozeilenargumente in einen heap-basierten Puffer "user_args" (Zeilen 864-871) und entfernt das Escaping der Meta-Zeichen (Zeilen 866-867), "für sudoers-Abgleich- und Protokollierungszwecke":
Leider gilt: Wenn ein Kommandozeilenargument mit einem einzelnen Backslash- Zeichen endet, dann:
ist bei Zeile 866 "from[0]" das Backslash-Zeichen und "from[1]" der Nullterminator des Arguments (d. h., kein Leerzeichen);
wird bei Zeile 867 "from" inkrementiert und zeigt auf den Nullterminator;
wird bei Zeile 868 der Nullterminator in den Puffer "user_args" kopiert, und "from" wird erneut inkrementiert und zeigt auf das erste Zeichen nach dem Nullterminator (d. h., außerhalb der Grenzen des Arguments);
liest und kopiert die "while"-Schleife bei den Zeilen 865-869 Zeichen außerhalb der Grenzen in den Puffer "user_args".
Mit anderen Worten: set_cmnd() ist anfällig für einen heap-basierten Puffer- überlauf, weil die Zeichen außerhalb der Grenzen, die in den Puffer "user_args" kopiert werden, in dessen Größe (berechnet in den Zeilen 852-853) nicht enthalten waren.
Theoretisch kann jedoch kein Kommandozeilenargument mit einem einzelnen Backslash-Zeichen enden: Wenn MODE_SHELL oder MODE_LOGIN_SHELL gesetzt ist (Zeile 858, eine notwendige Bedingung, um den anfälligen Code zu erreichen), dann ist MODE_SHELL gesetzt (Zeile 571) und parse_args() hat bereits alle Meta-Zeichen escaped, einschließlich Backslashes (d. h., es hat jeden einzelnen Backslash mit einem zweiten Backslash escaped).
In der Praxis jedoch sind der anfällige Code in set_cmnd() und der Escape- Code in parse_args() von geringfügig unterschiedlichen Bedingungen umgeben:
versus:
Unsere Frage lautet also: Können wir MODE_SHELL und entweder MODE_EDIT oder MODE_CHECK setzen (um den anfälligen Code zu erreichen), aber nicht das standardmäßige MODE_RUN (um den Escape-Code zu vermeiden)?
Die Antwort scheint nein zu sein: Wenn wir MODE_EDIT (Option -e, Zeile 361) oder MODE_CHECK (Option -l, Zeilen 423 und 519) setzen, dann entfernt parse_args() MODE_SHELL aus den "valid_flags" (Zeilen 363 und 424) und beendet mit einem Fehler, wenn wir ein ungültiges Flag wie MODE_SHELL angeben (Zeilen 532-533):
Aber wir haben eine Hintertür gefunden: Wenn wir Sudo als "sudoedit" statt als "sudo" ausführen, dann setzt parse_args() automatisch MODE_EDIT (Zeile 270), setzt aber "valid_flags" nicht zurück, und die "valid_flags" enthalten MODE_SHELL standardmäßig (Zeilen 127 und 249):
Folglich setzen wir, wenn wir "sudoedit -s" ausführen, sowohl MODE_EDIT als auch MODE_SHELL (aber nicht MODE_RUN), vermeiden den Escape-Code, erreichen den anfälligen Code und überlaufen den heap-basierten Puffer "user_args" durch ein Kommandozeilenargument, das mit einem einzelnen Backslash-Zeichen endet:
perl -e 'print "A" x 65536'
malloc(): corrupted top size
Aborted (core dumped)Aus Sicht eines Angreifers ist dieser Pufferüberlauf ideal:
wir kontrollieren die Größe des "user_args"-Puffers, den wir überlaufen (die Größe unserer verketteten Kommandozeilenargumente, in den Zeilen 852-854);
wir kontrollieren unabhängig die Größe und den Inhalt des Überlaufs selbst (auf unser letztes Kommandozeilenargument folgen praktischerweise unsere ersten Umgebungsvariablen, die nicht in die Größenberechnung einbezogen werden bei den Zeilen 852-853);
wir können sogar Null-Bytes in den Puffer schreiben, den wir überlaufen (jedes Kommandozeilenargument oder jede Umgebungsvariable, die mit einem einzelnen Backslash endet, schreibt ein Null-Byte in "user_args", bei den Zeilen 866-868).
Zum Beispiel weist der folgende Befehl auf einem amd64-Linux einen 24-Byte-"user_args"-Puffer zu (einen 32-Byte-Heap-Chunk) und überschreibt das Größenfeld des nächsten Chunks mit "A=a\0B=b\0" (0x00623d4200613d41), dessen fd-Feld mit "C=c\0D=d\0" (0x00643d4400633d43) und dessen bk-Feld mit "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
Weil Sudo Lokalisierungsfunktionen ganz am Anfang seiner main()-Funktion aufruft:
und Übersetzungsstrings (durch die gettext()-Funktion und das _()-Makro) an Format-String-Funktionen übergibt, wie zum Beispiel:
wollten wir zunächst halfdogs faszinierende Technik von https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ wiederverwenden und Sudos heap-basierten Pufferüberlauf in einen Format-String-Exploit verwandeln. Genauer gesagt:
bei Zeile 154, in setlocale(), malloc()en und free()en wir mehrere LC- Umgebungsvariablen (LC_CTYPE, LC_MESSAGES, LC_TIME usw.) und erzeugen dadurch kleine Lücken am Anfang von Sudos Heap (freie Fast- oder Tcache-Chunks);
bei Zeile 155 malloc()t bindtextdomain() eine struct binding, die einen dirname-Zeiger auf den Namen eines Verzeichnisses enthält, das ".mo"-Katalogdateien und damit Übersetzungsstrings enthält;
in set_cmnd() malloc()en wir den "user_args"-Puffer in eine der Lücken am Anfang von Sudos Heap und überlaufen diesen Puffer, wodurch wir den dirname-Zeiger der struct binding überschreiben;
bei Zeile 301 (zum Beispiel) lädt gettext() (durch das _()-Makro) unseren eigenen Übersetzungsstring aus dem überschriebenen dirname -- mit anderen Worten, wir kontrollieren den Format-String, der an sudo_printf() übergeben wird.
Um diese anfängliche Technik zu implementieren, schrieben wir einen rudimentären Brute-Forcer, der Sudo innerhalb von gdb ausführt, den "user_args"-Puffer überläuft und die folgenden Parameter zufällig auswählt:
die LC-Umgebungsvariablen, die wir an Sudo übergeben, und ihre Länge (wir verwenden das Gebietsschema "C.UTF-8" und hängen einen zufälligen "@modifier" an);
die Größe des "user_args"-Puffers, den wir überlaufen;
die Größe des Überlaufs selbst;
ob wir durch Sudos Authentifizierungscode gehen (-A- oder -n-Option) oder nicht (-u #realuid-Option).
Leider schlug diese anfängliche Technik fehl; unser Brute-Forcer konnte zwar den dirname-Zeiger der struct binding überschreiben:
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)
aber LC_MESSAGES war immer das standardmäßige "C"-Gebietsschema (nicht "C.UTF-8"), was die String-Übersetzung in gettext() deaktiviert (d. h., gettext() gibt den ursprünglichen Format-String zurück, nicht unseren eigenen).
Glücklicherweise erzeugte unser Brute-Forcer jedoch Dutzende einzigartiger Sudo-Abstürze und gdb-Backtraces; darunter erregten drei unsere Aufmerksamkeit, und wir haben letztendlich alle drei ausgenutzt.
Der erste Absturz, der unsere Aufmerksamkeit erregte, ist:
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
Unglaublicherweise stürzte Sudos Funktion process_hooks_getenv() ab (bei Zeile 108), weil wir direkt einen Funktionszeiger überschrieben, getenv_fn (ein Mitglied einer heap-basierten struct sudo_hook_entry):
Um diese struct sudo_hook_entry-Überschreibung auszunutzen, stellen wir fest:
der Aufruf von getenv_fn (bei Zeile 108) ist kompatibel mit einem Aufruf von execve():
. name ("SYSTEMD_BYPASS_USERDB") ist kompatibel mit dem Pfadnamen-Argument von execve();
. &val (ein Zeiger auf einen NULL-Zeiger) ist kompatibel mit dem argv von execve();
. hook->closure (ein NULL-Zeiger) ist kompatibel mit dem envp von execve();
wir können ASLR besiegen, indem wir den Funktionszeiger getenv_fn teilweise überschreiben (der auf die Funktion sudoers_hook_getenv() in der gemeinsamen Bibliothek sudoers.so zeigt); und glücklicherweise enthält der Anfang von sudoers.so einen Aufruf von execve() (oder execv()):
Folglich verfolgen wir die folgende Strategie:
Wir haben diesen ersten Exploit erfolgreich auf Ubuntu 20.04 getestet.
Der zweite Absturz, der unsere Aufmerksamkeit erregte, ist:
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)
Die glibc-Funktion nss_load_library() stürzte ab (bei Zeile 344), weil wir den Zeiger "library", ein Mitglied einer heap-basierten struct service_user, überschrieben:
Wir können diese struct service_user-Überschreibung leicht in eine beliebige Codeausführung umwandeln:
wir überschreiben ni->library mit einem NULL-Zeiger, um in den Block bei den Zeilen 330-342 einzutreten, den Absturz bei Zeile 344 zu vermeiden und in den Block bei den Zeilen 344-359 einzutreten;
wir überschreiben ni->name (ein Zeichenarray, anfänglich "systemd") mit "X/X";
die Zeilen 353-357 konstruieren den Namen einer gemeinsamen Bibliothek "libnss_X/X.so.2" (anstelle von "libnss_systemd.so.2");- In Zeile 359 laden wir unsere eigene Shared Library "libnss_X/X.so.2" aus dem aktuellen Arbeitsverzeichnis und führen unseren _init()-Konstruktor als root aus.
Wir haben diesen zweiten Exploit erfolgreich auf Ubuntu 20.04, Debian 10, und Fedora 33 getestet.
Unser dritter Exploit stammt nicht aus einem der Sudo-Abstürze, sondern aus einer beiläufigen Beobachtung: Während unserer Brute-Force-Phase erzeugte Sudo dutzende neue Verzeichnisse in unserem aktuellen Arbeitsverzeichnis (AAAAAA, AAAAAAAAA, usw.). Jedes dieser Verzeichnisse gehört root und enthält nur eine kleine Datei, die nach unserem eigenen Benutzer benannt ist: Sudos Zeitstempeldatei -- wir haben offensichtlich def_timestampdir überschrieben, den Namen von Sudos Zeitstempelverzeichnis.
Wenn wir def_timestampdir mit dem Namen eines Verzeichnisses überschreiben, das noch nicht existiert, können wir gegen Sudos ts_mkdirs() antreten, einen Symlink auf eine beliebige Datei erstellen und:
3a/ entweder chown() wir diese beliebige Datei auf Benutzer root und Gruppe root;
3b/ oder öffnen (oder erstellen) wir diese beliebige Datei als root und schreiben eine struct timestamp_entry hinein.
Wir konnten 3a/ nicht in vollständige Root-Rechte umwandeln (wenn wir zum Beispiel unsere eigene SUID-Binärdatei mit chown() auf root setzen, dann entfernt der Kernel automatisch das SUID-Bit unseres Binärprogramms). Wenn Sie, liebe Leserin oder lieber Leser, eine Lösung für dieses Problem finden, posten Sie sie bitte auf der öffentlichen oss-security-Mailingliste!
Schließlich konnten wir 3b/ in vollständige Root-Rechte umwandeln, aber wir sahen uns zunächst zwei Problemen gegenüber:
Sudos timestamp_open() löscht unseren beliebigen Symlink, wenn die Datei, auf die er zeigt, älter als die Bootzeit ist. Wir konnten dieses erste Problem lösen, indem wir eine sehr alte Zeitstempeldatei (aus der Unix-Epoche) erstellten, indem wir warteten, bis timestamp_open() sie löscht, und indem wir gegen timestamp_open() antraten, um unseren endgültigen, beliebigen Symlink zu erstellen.
Wir kontrollieren nicht den Inhalt der struct timestamp_entry, die in die beliebige Datei geschrieben wird. Nach unserem besten Wissen kontrollieren wir nur drei Bytes (eine Prozess-ID oder eine struct timespec), und wir waren nicht in der Lage, dieses Drei-Byte-Schreiben in vollständige Root-Rechte umzuwandeln. Wenn Sie, liebe Leserin oder lieber Leser, eine Lösung für dieses Problem finden, posten Sie sie bitte auf der öffentlichen oss-security-Mailingliste!
Allerdings konnten wir dieses zweite Problem umgehen, indem wir einen kleinen Fehler in Sudos timestamp_lock() ausnutzten. Wenn wir die beiden Wettläufe gegen ts_mkdirs() und timestamp_open() gewinnen und wenn unser beliebiger Symlink auf /etc/passwd zeigt, dann wird diese Datei als root geöffnet, und:
In Zeile 644 werden die ersten 0x38 Bytes von /etc/passwd ("root❌0:0:...") in eine stack-basierte struct timestamp_entry, entry, gelesen;
In Zeile 652 ist entry.type 0x783a (":x"), nicht TS_LOCKEXCL;
In den Zeilen 657 und 318 werden entry->size Bytes aus dem stack-basierten entry nach /etc/passwd geschrieben, aber entry->size ist tatsächlich 0x746f ("ot"), nicht sizeof(struct timestamp_entry).
Als Ergebnis schreiben wir den gesamten Inhalt von Sudos Stack nach /etc/passwd (einschließlich unserer Befehlszeilenargumente und unserer Umgebungsvariablen): Wir fügen einen beliebigen Benutzer in /etc/passwd ein und erhalten dadurch volle Root- Rechte. Wir haben diesen dritten Exploit erfolgreich auf Ubuntu 20.04 getestet.
Hinweis: Dieser kleine Fehler in timestamp_lock() wurde im Januar 2020 durch Commit 586b418a behoben, aber dieser Fix wurde nicht auf ältere Versionen zurückportiert.
Wir danken Todd C. Miller für seine Professionalität, seine schnelle Reaktion und die akribische Aufmerksamkeit für jedes Detail in unserem Bericht. Wir danken auch den Mitgliedern von distros@openwall.
2021-01-13: Advisory an Todd.Miller@sudo gesendet.
2021-01-19: Advisory und Patches an distros@openwall gesendet.
2021-01-26: Koordinierter Veröffentlichungstermin (18:00 Uhr UTC).