
Dies ist ein POC, den ich für CVE-2024-6387 geschrieben habe.
Qualys-Sicherheitshinweis
regreSSHion: RCE im OpenSSH-Server auf glibc-basierten Linux-Systemen (CVE-2024-6387)
Zusammenfassung SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, von 2005)
Es braucht nur einen Vertrauensvorschuss
-- The Interrupters, "Leap of Faith"
Vorbemerkung: OpenSSH ist eine der sichersten Software der Welt; diese Schwachstelle ist ein Ausrutscher in einer ansonsten nahezu fehlerfreien Implementierung. Ihr Defense-in-Depth-Design und ihr Code sind ein Vorbild und eine Inspiration, und wir danken den Entwicklern von OpenSSH für ihre vorbildliche Arbeit.
Wir haben eine Schwachstelle (eine Signalhandler-Race-Condition) im Server von OpenSSH (sshd) entdeckt: Wenn ein Client sich nicht innerhalb von LoginGraceTime Sekunden (standardmäßig 120, bei alten OpenSSH- Versionen 600) authentifiziert, wird der SIGALRM-Handler von sshd asynchron aufgerufen, aber dieser Signalhandler ruft verschiedene Funktionen auf, die nicht async-signal-sicher sind (zum Beispiel syslog()). Diese Race-Condition betrifft sshd in seiner Standardkonfiguration.
Bei der Untersuchung stellten wir fest, dass diese Schwachstelle tatsächlich eine Regression von CVE-2006-5051 ist („Signal handler race condition in OpenSSH before 4.4 allows remote attackers to cause a denial of service (crash), and possibly execute arbitrary code“), die 2006 von Mark Dowd gemeldet wurde.
Diese Regression wurde im Oktober 2020 (OpenSSH 8.5p1) durch den Commit 752250c („revised log infrastructure for OpenSSH“) eingeführt, der versehentlich ein „#ifdef DO_LOG_SAFE_IN_SIGHAND“ aus sigdie() entfernte, einer Funktion, die direkt vom SIGALRM-Handler von sshd aufgerufen wird. Mit anderen Worten:
OpenSSH < 4.4p1 ist anfällig für diese Signalhandler-Race-Condition, wenn es nicht mit einem Backport-Patch gegen CVE-2006-5051 versehen ist oder nicht gegen CVE-2008-4109 gepatcht ist, das eine fehlerhafte Korrektur für CVE-2006-5051 war;
4.4p1 <= OpenSSH < 8.5p1 ist nicht anfällig für diese Signalhandler-Race-Condition (weil das „#ifdef DO_LOG_SAFE_IN_SIGHAND“, das durch den Patch für CVE-2006-5051 zu sigdie() hinzugefügt wurde, diese unsichere Funktion in einen sicheren _exit(1)-Aufruf verwandelt hat);
8.5p1 <= OpenSSH < 9.8p1 ist erneut anfällig für diese Signalhandler-Race-Condition (weil das „#ifdef DO_LOG_SAFE_IN_SIGHAND“ versehentlich aus sigdie() entfernt wurde).
Diese Schwachstelle ist auf glibc-basierten Linux-Systemen remote ausnutzbar, wo syslog() selbst async-signal-unsichere Funktionen aufruft (z. B. malloc() und free()): eine nicht authentifizierte Remote-Codeausführung als root, da sie den privilegierten Code von sshd betrifft, der nicht in einer Sandbox läuft und mit vollen Rechten ausgeführt wird. Wir haben keine andere libc und kein anderes Betriebssystem untersucht; OpenBSD ist jedoch bekanntermaßen nicht anfällig, da sein SIGALRM-Handler syslog_r() aufruft, eine async-signal-sicherere Version von syslog(), die 2001 von OpenBSD erfunden wurde.
Um diese Schwachstelle remote auszunutzen (unseres Wissens wurde CVE-2006-5051 zuvor noch nie erfolgreich ausgenutzt), ließen wir uns von einem visionären Paper inspirieren, „Delivering Signals for Fun and Profit“, das 2001 von Michal Zalewski veröffentlicht wurde:
https://lcamtuf.coredump.cx/signals.txt
Dennoch standen wir sofort vor drei großen Problemen:
Aus theoretischer Sicht müssen wir einen nützlichen Codepfad finden, der, wenn er zur richtigen Zeit von SIGALRM unterbrochen wird, sshd in einem inkonsistenten Zustand hinterlässt, und wir müssen diesen inkonsistenten Zustand dann im SIGALRM-Handler ausnutzen.
Aus praktischer Sicht müssen wir einen Weg finden, diesen nützlichen Codepfad in sshd zu erreichen, und unsere Chancen maximieren, ihn zur richtigen Zeit zu unterbrechen.
Aus zeitlicher Sicht müssen wir einen Weg finden, unsere Chancen, diesen nützlichen Codepfad zur richtigen Zeit zu unterbrechen, remote weiter zu erhöhen.
Um uns auf diese drei Probleme zu konzentrieren, ohne sofort gegen alle modernen Betriebssystemschutzmechanismen (insbesondere ASLR und NX) kämpfen zu müssen, entschieden wir uns, zunächst alte OpenSSH-Versionen auf i386 auszunutzen und dann, basierend auf diesen Erfahrungen, aktuelle Versionen:
Zuerst „SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3“, aus „debian-30r6-dvd-i386-binary-1_NONUS.iso“: Dies ist die erste Debian-Version, bei der Privilegientrennung standardmäßig aktiviert ist und die gegen alle kritischen Schwachstellen dieser Ära gepatcht ist (insbesondere CVE-2003-0693 und CVE-2002-0640).
Um diese Version remote auszunutzen, unterbrechen wir einen Aufruf von free() mit SIGALRM (im Public-Key-Parsing-Code von sshd), hinterlassen den Heap in einem inkonsistenten Zustand und nutzen diesen inkonsistenten Zustand während eines weiteren Aufrufs von free() im SIGALRM-Handler aus.
In unseren Experimenten dauert es im Durchschnitt ~10.000 Versuche, diese Race-Condition zu gewinnen; d. h. mit 10 Verbindungen (MaxStartups), die pro 600 Sekunden (LoginGraceTime) akzeptiert werden, dauert es im Durchschnitt etwa 1 Woche, um eine entfernte Root-Shell zu erhalten.
Zweitens „SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3“, aus „ubuntu-6.06.1-server-i386.iso“: Dies ist die letzte Ubuntu-Version, die noch anfällig für CVE-2006-5051 ist („Signal handler race condition in OpenSSH before 4.4“).
Um diese Version remote auszunutzen, unterbrechen wir einen Aufruf von pam_start() mit SIGALRM, hinterlassen eine der PAM-Strukturen in einem inkonsistenten Zustand und nutzen diesen inkonsistenten Zustand während eines Aufrufs von pam_end() im SIGALRM-Handler aus.
In unseren Experimenten dauert es im Durchschnitt ~10.000 Versuche, diese Race-Condition zu gewinnen; d. h. mit 10 Verbindungen (MaxStartups), die pro 120 Sekunden (LoginGraceTime) akzeptiert werden, dauert es im Durchschnitt etwa 1-2 Tage, um eine entfernte Root-Shell zu erhalten.
Schließlich „SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2“, aus „debian-12.5.0-i386-DVD-1.iso“: Dies ist die aktuelle stabile Debian-Version, und sie ist anfällig für die Regression von CVE-2006-5051.
Um diese Version remote auszunutzen, unterbrechen wir einen Aufruf von malloc() mit SIGALRM (im Public-Key-Parsing-Code von sshd), hinterlassen den Heap in einem inkonsistenten Zustand und nutzen diesen inkonsistenten Zustand während eines weiteren Aufrufs von malloc() im SIGALRM-Handler aus (genauer gesagt in syslog()).
In unseren Experimenten dauert es im Durchschnitt ~10.000 Versuche, diese Race-Condition zu gewinnen, also ~3-4 Stunden bei 100 Verbindungen (MaxStartups), die pro 120 Sekunden (LoginGraceTime) akzeptiert werden. Letztendlich dauert es im Durchschnitt ~6-8 Stunden, um eine entfernte Root-Shell zu erhalten, da wir die Adresse der glibc nur in der Hälfte der Fälle richtig erraten können (wegen ASLR).
Diese Forschung befindet sich noch in Arbeit:
wir haben ausschließlich virtuelle Maschinen angegriffen, keine Bare-Metal-Server, über eine weitgehend stabile Netzwerkverbindung (~10 ms Paket-Jitter);
wir sind überzeugt, dass verschiedene Aspekte unserer Exploits erheblich verbessert werden können;
wir haben mit der Arbeit an einem amd64-Exploit begonnen, der wegen des stärkeren ASLR viel schwieriger ist.
Einige Tage nachdem wir mit unserer Arbeit an amd64 begonnen hatten, bemerkten wir den folgenden Bugreport (im öffentlichen Bugzilla von OpenSSH) über einen Deadlock im SIGALRM-Handler von sshd:
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
Wir beschlossen daher, sofort die OpenSSH-Entwickler zu kontaktieren (um sie darüber zu informieren, dass dieser Deadlock durch eine ausnutzbare Schwachstelle verursacht wird), legten unsere amd64-Arbeit auf Eis und begannen mit dem Schreiben dieses Advisories.
Doch das ist nicht wie ich, ich breche aus
-- The Interrupters, "Haven't Seen the Last of Me"
Der SIGALRM-Handler dieser OpenSSH-Version ruft packet_close() auf, das buffer_free() aufruft, das xfree() und damit free() aufruft, was nicht async-signal-sicher ist:
Folglich begannen wir, den malloc-Code dieser Debian-glibc (2.2.5) zu lesen, um zu sehen, ob ein erster Aufruf von free() durch SIGALRM unterbrochen und während eines zweiten Aufrufs von free() im SIGALRM-Handler (in den Zeilen 341-344 oben) ausgenutzt werden kann. Da der malloc dieser glibc nicht gegen die von Solar Designer im Jahr 2000 entwickelte unlink()-Technik gehärtet ist, entdeckten wir schnell einen interessanten Codepfad in chunk_free() (das intern von free() aufgerufen wird):
Um diesen Codepfad auszunutzen, arrangieren wir den Heap von sshd so, dass er das folgende Layout hat (chunk_X, chunk_Y und chunk_Z sind mit malloc() belegte Speicherblöcke, und p, s, f, b sind deren prev_size-, size-, fd- und bk-Felder):
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| user data
Erstens: Wenn ein Aufruf von free(chunk_Y) durch SIGALRM nach Zeile 3246, aber vor Zeile 3251 unterbrochen wird, dann ist chunk_Y bereits als frei markiert (weil das PREV_INUSE-Bit von chunk_Z in Zeile 3246 gelöscht wird), aber es ist noch nicht in seine doppelt verkettete Liste eingehängt (in Zeile 3251): Mit anderen Worten, die fd- und bk-Zeiger von chunk_Y enthalten immer noch Benutzerdaten (vom Angreifer kontrollierte Daten).
Zweitens: Wenn (im SIGALRM-Handler) packet_close() free(chunk_X) aufruft, dann wird der Codeblock in den Zeilen 3230-3244 erreicht (weil chunk_Y als frei markiert ist) und chunk_Y wird unlink()ed (in Zeile 3241): eine sogenannte aa4bmo-Primitive (almost arbitrary 4 bytes mirrored overwrite), weil die fd- und bk-Zeiger von chunk_Y immer noch vom Angreifer kontrolliert werden. Weitere Informationen zur unlink()-Technik und der aa4bmo-Primitive:
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
Schließlich überschreiben wir mit dieser aa4bmo-Primitive den __free_hook-Funktionszeiger der glibc (diese alte Debian-Version hat weder ASLR noch NX) mit der Adresse unseres Shellcodes im Heap und erreichen so bei dem nächsten Aufruf von free() in packet_close() eine Remote-Codeausführung.
Jetzt übernehmen sie und haben die totale Kontrolle
-- The Interrupters, "Liberty"
Um diesen Angriff gegen sshd zu führen, unterbrechen wir einen Aufruf von free() im Parsing-Code von sshd für einen DSA-öffentlichen Schlüssel (d. h. Zeile 144 unten ist unser free(chunk_Y)) und nutzen ihn während eines der free()-Aufrufe in packet_close() aus (d. h. eine der Zeilen 341-344 oben ist unser free(chunk_X)):
Anfangs jedoch gelang es uns nie, diese Race-Condition zu gewinnen (d. h. den free()-Aufruf in Zeile 144 zur richtigen Zeit zu unterbrechen). Schließlich erkannten wir, dass wir unsere Chancen, dieses Rennen zu gewinnen, erheblich verbessern konnten: Der Parsing-Code für DSA-öffentliche Schlüssel erlaubt es uns, free() viermal aufzurufen (in den Zeilen 704-707 unten), und außerdem erlaubt sshd uns, sechs Benutzerauthentifizierungen zu versuchen (AUTH_FAIL_MAX); wenn einer dieser 24 free()-Aufrufe zur richtigen Zeit unterbrochen wird, erreichen wir später eine Remote-Codeausführung im SIGALRM-Handler.
Mit dieser Verbesserung gewannen wir schließlich nach etwa 1 Monat die Race-Condition: Wir waren glücklich (und tanzten einen Root-Shell-Tanz), hatten aber auch das Gefühl, dass es noch Raum für Verbesserungen gab.
Keine Sorge, warte einfach ab
-- The Interrupters, "Haven't Seen the Last of Me"
Wir implementierten daher die folgende dreiteilige Timing-Strategie:
Wir warten nicht bis zum letzten Moment, um unser (ziemlich großes) DSA-Public-Key-Paket an sshd zu senden: Stattdessen senden wir das gesamte Paket minus einem Byte (dem letzten Byte) lange vor der LoginGraceTime und senden das allerletzte Byte im allerletzten Moment, um die Auswirkungen von Netzwerkverzögerungen zu minimieren. (Und wir deaktivieren den Nagle-Algorithmus.)
Wir verfolgen die mittlere Round-Trip-Zeit (indem wir regelmäßig Pakete senden, die eine Antwort von sshd erzeugen), und verfolgen die Differenz zwischen dem Moment, in dem wir erwarten, dass unsere Verbindung von sshd geschlossen wird (im Wesentlichen der Moment, in dem wir das erste Byte des Banners von sshd empfangen, plus LoginGraceTime), und dem Moment, in dem unsere Verbindung wirklich von sshd geschlossen wird, und passen unser Timing entsprechend an (d. h. den Moment, in dem wir das letzte Byte unseres DSA-Pakets senden).
Diese Zeitdifferenzen ermöglichen es uns, Uhrsprünge (Clock Skews) und Netzwerkverzögerungen zu verfolgen, die im Laufe der Zeit vorhersagbare Muster zeigen: Wir experimentierten mit linearen und Spline-Regressionen, aber letztendlich funktionierte nichts besser, als einfach die letzte Messung wiederzuverwenden. Möglicherweise könnte Deep Learning noch bessere Ergebnisse liefern; dies bleibt dem interessierten Leser als Übung überlassen.
Wichtiger noch: Wir erhöhen unsere Chancen, diese Race-Condition zu gewinnen, weiter, indem wir unser Timing langsam durch unfreiwilliges Feedback von sshd anpassen:
Wenn wir eine Antwort (SSH2_MSG_USERAUTH_FAILURE) auf unser DSA-Public-Key-Paket erhalten, dann haben wir es zu früh gesendet (sshd hatte Zeit, unser Paket im unprivilegierten Kindprozess zu empfangen, zu parsen, an den privilegierten Kindprozess zu senden, dort zu parsen und eine Antwort den ganzen Weg zurück zu uns zu senden);
Wenn wir nicht einmal das letzte Byte unseres DSA-Pakets senden können, dann haben wir zu lange gewartet (sshd hat das SIGALRM bereits empfangen und unsere Verbindung geschlossen);
Wenn wir das letzte Byte unseres DSA-Pakets senden können und keine Antwort erhalten, bevor sshd unsere Verbindung schließt, dann war unser Timing einigermaßen genau.
Dieses Feedback ermöglicht es uns, das zu treffen, was wir das „große“ Race-Fenster nennen: Es zu treffen garantiert nicht, dass wir die Race-Condition gewinnen, aber innerhalb dieses großen Fensters befinden sich die 24 „kleinen“ Race-Fenster (innerhalb der 24 free()- Aufrufe), die, wenn sie getroffen werden, garantieren, dass wir die Race-Condition tatsächlich gewinnen.
Mit diesen Verbesserungen dauert es im Durchschnitt ~10.000 Versuche, diese Race-Condition zu gewinnen; d. h. mit 10 Verbindungen (MaxStartups), die pro 600 Sekunden (LoginGraceTime) akzeptiert werden, dauert es im Durchschnitt etwa 1 Woche, um eine entfernte Root-Shell zu erhalten.
Ich schlafe, wenn die Sonne aufgeht
-- The Interrupters, "Alien"
Der SIGALRM-Handler dieser OpenSSH-Version ruft packet_close() nicht mehr auf; außerdem nimmt die glibc dieses Ubuntu (2.3.6) beim Betreten der Funktionen der malloc-Familie immer eine obligatorische Sperre (auch wenn sie wie sshd single-threaded ist), was uns daran hindert, einen Aufruf einer der malloc-Funktionen zu unterbrechen und später während eines weiteren Aufrufs dieser Funktionen auszunutzen (sie würden immer einen Deadlock verursachen). Wir müssen eine andere Lösung finden.
CVE-2006-5051 erwähnt einen Double-Free in GSSAPI, aber GSSAPI (oder Kerberos) ist standardmäßig nicht aktiviert, also klingt das nicht sehr verlockend. Andererseits ist PAM standardmäßig aktiviert, und pam_end() wird vom SIGALRM-Handler von sshd aufgerufen (und ist natürlich nicht async-signal-sicher). Wir suchten daher nach einer PAM-Funktion, die, wenn sie zur richtigen Zeit von SIGALRM unterbrochen wird, PAMs interne Strukturen in einem inkonsistenten Zustand hinterlässt, der während pam_end() im SIGALRM-Handler ausgenutzt werden kann. Wir fanden pam_set_data():
33 int pam_set_data( 34 pam_handle_t *pamh, .. 37 void (*cleanup)(pam_handle_t *pamh, void *data, int error_status)) 38 { 39 struct pam_data *data_entry; .. 57 } else if ((data_entry = malloc(sizeof(*data_entry)))) { .. 65 data_entry->next = pamh->data; 66 pamh->data = data_entry; .. 74 data_entry->cleanup = cleanup; ------------------------------------------------------------------------Wenn diese Funktion durch SIGALRM nach Zeile 66, aber vor Zeile 74 unterbrochen wird, dann ist data_entry bereits in PAM-Strukturen (pamh) eingebunden, aber sein cleanup-Feld (ein Funktionszeiger) ist noch nicht initialisiert (da das malloc() in Zeile 57 seinen Speicher nicht initialisiert). Wenn wir cleanup kontrollieren können (durch Überbleibsel früherer Heap-Allokationen), dann können wir beliebigen Code ausführen, wenn pam_end() (innerhalb des SIGALRM-Handlers) _pam_free_data() aufruft (in Zeile 118):
Dies wäre ein äußerst einfacher Exploit gewesen; leider haben wir völlig übersehen, dass pam_set_data() nur von PAM-Modulen aufgerufen werden kann: Wenn wir es mit SIGALRM unterbrechen, dann ist pamh->caller_is immer noch _PAM_CALLED_FROM_MODULE, in welchem Fall pam_end() sofort zurückkehrt, ohne jemals _pam_free_data() aufzurufen. Zurück zum Reißbrett.
Nicht aufgeben, das ist nicht das, was wir tun
-- The Interrupters, "Title Holder"
Wir stellten fest, dass sshd in Zeile 601 unten einen Zeiger auf seinen globalen sshpam_handle-Zeiger direkt an pam_start() übergibt (das pro Verbindung einmal aufgerufen wird):
Wir beschlossen daher, pam_start() selbst zu untersuchen: Wenn es durch SIGALRM unterbrochen wird, könnte es die Struktur, auf die sshpam_handle zeigt, in einem inkonsistenten Zustand hinterlassen, der dann innerhalb des SIGALRM-Handlers ausgenutzt werden könnte, wenn "pam_end(sshpam_handle, sshpam_err)" aufgerufen wird.
In Zeile 32 setzt pam_start() sofort den sshpam_handle von sshd auf einen per calloc() allokierten Speicherblock; das ist sicher, weil calloc() diesen Speicher auf Null initialisiert. Wenn andererseits _pam_add_handler() (das von pam_start() mehrfach aufgerufen wird) durch SIGALRM nach Zeile 874, aber vor Zeile 886 unterbrochen wird, dann wird eine per malloc() allokierte Struktur in pamh eingebunden, aber ihr next-Feld ist noch nicht initialisiert. Wenn wir next kontrollieren können (durch Überbleibsel früherer Heap-Allokationen), dann können wir während des Aufrufs von pam_end() (innerhalb des SIGALRM-Handlers) einen beliebigen Zeiger an free() übergeben, und zwar in Zeile 1020 (und Zeile 1017) unten:
Da das malloc der glibc dieses Ubuntu bereits gegen die alte unlink()-Technik gehärtet ist, beschlossen wir, unser beliebiges free() in den House of Mind (Fastbin-Version) des Malloc Maleficarum umzuwandeln: Wir free()en unseren eigenen NON_MAIN_ARENA-Chunk, lassen unsere Fake-Arena auf das .got.plt von sshd zeigen (das sshd dieses Ubuntu hat ASLR, aber kein PIE), und überschreiben den Eintrag von _exit() mit der Adresse unseres Shellcodes im Heap (der Heap dieses Ubuntu ist standardmäßig noch ausführbar). Weitere Informationen zum Malloc Maleficarum:
https://seclists.org/bugtraq/2005/Oct/118
Ich habe alles auf die harte Tour gelernt
-- The Interrupters, "The Hard Way"
Um diesen Angriff gegen sshd zu starten, standen wir zunächst vor drei Problemen:
Der House of Mind verlangt, dass wir den Zeiger auf unsere Fake-Arena an Adresse 0x08100000 im Heap speichern; aber können wir Angreifer-kontrollierte Daten an einer so hohen Adresse speichern? Da sshd pam_start() ganz am Anfang der Benutzerauthentifizierung aufruft, kontrollieren wir nichts außer dem Benutzernamen selbst; glücklicherweise erlaubt uns ein Benutzername mit einer Länge von ~128 KB (kürzer als DEFAULT_MMAP_THRESHOLD), unsere eigenen Daten an Adresse 0x08100000 zu speichern.
Das Größenfeld unseres Fake-NON_MAIN_ARENA-Chunks darf nicht zu groß sein (um die Sicherheitsprüfungen von free() zu bestehen); d. h., es muss Nullbytes enthalten. Aber unser langer Benutzername ist eine nullterminierte Zeichenkette, die keine Nullbytes enthalten kann; glücklicherweise erinnerten wir uns daran, dass _pam_free_handlers_aux() die Strukturen, die es freigibt, auf Null setzt (Zeile 1019 oben): Wir "patchen" das Größenfeld unseres Fake-Chunks daher mit einem solchen memset(0) und free()en es erst dann.
Wir müssen mehrere Aufrufe von free() (in den Zeilen 1017 und 1020 oben) überleben, bevor das free() unseres Fake-NON_MAIN_ARENA-Chunks erfolgt. Wir verwandeln diese free()-Aufrufe in No-ops, indem wir sie auf Fake-IS_MMAPPED-Chunks zeigen lassen: free() ruft munmap_chunk() auf, das munmap() aufruft, was fehlschlägt, weil diese Fake-IS_MMAPPED-Chunks falsch ausgerichtet sind; praktisch ein No-op, weil assert()-Fehlschläge in der glibc dieses Ubuntu nicht erzwungen werden.
Schließlich erlaubt uns unser langer Benutzername auch, das potenziell nicht initialisierte next-Feld von 20 verschiedenen Strukturen zu kontrollieren (durch Überbleibsel temporärer Kopien unseres langen Benutzernamens), weil pam_start() _pam_add_handler() mehrfach aufruft; d. h., unser großes Race-Fenster enthält 20 kleine Race-Fenster.
Dieselben Tricks, die sie zuvor benutzt haben
-- The Interrupters, "Divide Us"
Für diesen Angriff gegen Ubuntu 6.06.1 haben wir einfach die Timing-Strategie wiederverwendet, die wir gegen Debian 3.0r6 verwendet haben: Es braucht durchschnittlich ~10.000 Versuche, um die Race-Condition zu gewinnen, und mit 10 Verbindungen (MaxStartups), die pro 120 Sekunden (LoginGraceTime) akzeptiert werden, dauert es durchschnittlich ~1-2 Tage, um eine entfernte Root-Shell zu erhalten.
Hinweis: Da die glibc dieses Ubuntu beim Betreten der Funktionen der malloc-Familie immer eine zwingende Sperre erwirbt, könnte ein unglücklicher Angreifer alle 10 MaxStartups-Verbindungen in einen Deadlock bringen, bevor er eine Root-Shell erhält; wir haben nicht versucht, dieses Problem zu umgehen, weil unser ultimatives Ziel ohnehin war, eine moderne OpenSSH-Version auszunutzen.
Jetzt bist du bereit, nimm die Dämonen frontal in Angriff
-- The Interrupters, "Be Gone"
Der SIGALRM-Handler dieser OpenSSH-Version ruft weder packet_close() noch pam_end() auf; tatsächlich ruft er nur eine interessante Funktion auf, syslog():
Unsere zwei Schlüsselfragen sind also: Ruft das syslog() der glibc dieses Debian (2.36) async-signal-unsichere Funktionen wie malloc() und free() auf? Und wenn ja, erwirbt diese glibc immer noch eine zwingende Sperre beim Betreten der Funktionen der malloc-Familie?
Hinweis: Da wir bei diesen malloc()-Allokationen nichts kontrollieren (weder ihre Reihenfolge, noch ihre Größen, noch ihre Inhalte), betrachteten wir das "rce" in Zeile 166 als ein dringend benötigtes gutes Omen.
Und glücklicherweise für uns ist die Antwort auf unsere zweite Frage nein; seit Oktober 2017 nehmen die malloc-Funktionen der glibc keine Sperre mehr vor, wenn der Prozess single-threaded ist (wie sshd):
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
Darüber hinaus leidet diese Debian-Version unter der ASLR-Schwäche, die in den folgenden großartigen Blogbeiträgen beschrieben wird (von Justin Miller bzw. Mathias Krause):
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
Konkret wird bei sshd auf i386 jede Speicherzuordnung normal randomisiert (das PIE von sshd, der Heap, die meisten Bibliotheken, der Stack), aber die glibc selbst wird immer entweder an Adresse 0xb7200000 oder an Adresse 0xb7400000 abgebildet; mit anderen Worten, wir können die Adresse der glibc in der Hälfte der Fälle korrekt erraten (ein kleiner Preis für die Überwindung von ASLR). In unserem Exploit nehmen wir an, dass die glibc an Adresse 0xb7400000 abgebildet ist, weil das etwas häufiger ist als 0xb7200000.
Unsere nächste Frage ist: Welche Codepfade innerhalb der malloc-Funktionen der glibc hinterlassen, wenn sie durch SIGALRM zum richtigen Zeitpunkt unterbrochen werden, den Heap in einem inkonsistenten Zustand, der während eines der malloc()-Aufrufe innerhalb des SIGALRM-Handlers ausnutzbar ist?
Wir fanden mehrere interessante (und überraschende!) Codepfade, aber derjenige, den wir gewählt haben, betrifft nur relative Größen, nicht absolute Adressen (anders als beispielsweise verschiedene Codepfade innerhalb von unlink_chunk()); dieser Unterschied könnte sich für einen zukünftigen amd64-Exploit als entscheidend erweisen. Dieser Codepfad innerhalb von malloc() teilt einen großen freien Chunk (victim) in zwei kleinere Chunks; der erste Chunk wird an den Aufrufer von malloc() zurückgegeben (in Zeile 4345) und der zweite Chunk (remainder) wird in eine unsortierte Liste freier Chunks eingebunden (in den Zeilen 4324-4327):
Wenn dieser Codepfad durch SIGALRM nach Zeile 4327, aber vor Zeile 4339 unterbrochen wird, dann ist der Remainder-Chunk dieser Teilung bereits in die unsortierte Liste freier Chunks eingebunden (Zeilen 4324-4327), aber sein Größenfeld (mchunk_size) ist noch nicht initialisiert (Zeile 4339).
Wenn wir sein Größenfeld kontrollieren können (durch Überbleibsel früherer Heap-Allokationen), dann können wir diesen Remainder-Chunk größer machen und ihn mit anderen Heap-Chunks überlappen lassen, und dadurch Heap-Speicher korrumpieren, wenn dieser vergrößerte, überlappende Remainder-Chunk schließlich malloc()iert und beschrieben wird (innerhalb des SIGALRM-Handlers).
Unsere letzte Frage ist also: Da wir bei den malloc()-Aufrufen innerhalb des SIGALRM-Handlers nichts kontrollieren, was können wir im Heap überschreiben, um beliebige Codeausführung zu erreichen, bevor sshd _exit() aufruft (in sshsigdie())?
Da __tzfile_read() (innerhalb des SIGALRM-Handlers) eine FILE-Struktur im Heap malloc()iert (in Zeile 166 oben), und da FILE-Strukturen auf eine lange Geschichte des Missbrauchs für beliebige Codeausführung zurückblicken, beschlossen wir, unsere Heap-Korruption auf diese FILE-Struktur zu richten. Das ist jedoch leichter gesagt als getan: Unsere Heap-Korruption ist sehr begrenzt, und FILE-Strukturen wurden im Laufe der Jahre erheblich gehärtet (zum Beispiel durch IO_validate_vtable() und PTR_DEMANGLE()).
Schließlich entwickelten wir die folgende Technik (die spezifisch für die i386-glibc zu sein scheint -- die amd64-glibc scheint _vtable_offset überhaupt nicht zu verwenden):
Mit unserer begrenzten Heap-Korruption überschreiben wir das _vtable_offset-Feld (ein einzelnes vorzeichenbehaftetes Zeichen) der FILE-Struktur von __tzfile_read();
Die libio-Funktionen der glibc suchen daher nach dem Vtable-Zeiger dieser FILE-Struktur (ein Zeiger auf ein Array von Funktionszeigern) an einem Offset ungleich Null (unser überschriebenes _vtable_offset), statt am Standard-Offset Null;
Wir (Angreifer) können diesen Fake-Vtable-Zeiger leicht kontrollieren (durch Überbleibsel früherer Heap-Allokationen), weil die FILE-Struktur um diesen Offset herum von fopen() nicht explizit initialisiert wird;
Um die Sicherheitsprüfungen der glibc zu bestehen, muss unser Fake-Vtable-Zeiger irgendwo in den Abschnitt __libc_IO_vtables zeigen: Wir beschlossen, ihn auf die Vtable für Breitzeichen-Streams, _IO_wfile_jumps, zu richten (d. h. auf 0xb761b740, da wir annehmen, dass die glibc an Adresse 0xb7400000 abgebildet ist);
Infolgedessen ruft __fread_unlocked() (in Zeile 186 oben) _IO_wfile_underflow() auf (statt _IO_file_underflow()), das einen Funktionszeiger (__fct) aufruft, der im Grunde aus einer Struktur stammt, deren Zeiger (_codecvt) ein weiteres Feld der FILE-Struktur ist;
Wir (Angreifer) können diesen _codecvt-Zeiger leicht kontrollieren (durch Überbleibsel früherer Heap-Allokationen, weil dieses Feld der FILE-Struktur von fopen() nicht explizit initialisiert wird), was uns auch erlaubt, den __fct-Funktionszeiger zu kontrollieren.
Zusammenfassend können wir durch das Überschreiben eines einzigen Bytes (_vtable_offset) der von fopen() malloc()ierten FILE-Struktur unseren eigenen __fct-Funktionszeiger aufrufen und während __fread_unlocked() beliebigen Code ausführen.
Ich wollte es perfekt, ohne eine einzige Falte
-- The Interrupters, "In the Mirror"
Um diesen Angriff gegen das privilegierte Kind von sshd zu starten, stellen wir uns zunächst das folgende Heap-Layout vor (die "XXX"s sind "Barriere"-Chunks, die es uns erlauben, Löcher im Heap zu erzeugen; zum Beispiel kleine, speicherleckende Chunks):
---|----------------------------------------------|---|------------|---
| XXX | large hole | XXX | small hole | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | large allocated chunk | free remainder chunk | XXX | small hole | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
aber wenn dieses malloc() durch SIGALRM nach Zeile 4327, aber vor Zeile 4339 unterbrochen wird, dann ist der Remainder-Chunk dieser Teilung bereits in die unsortierte Liste freier Chunks eingebunden, aber sein Größenfeld ist unter unserer Kontrolle (durch Überbleibsel früherer Heap-Allokationen), und dieser künstlich vergrößerte Remainder-Chunk überlappt mit dem folgenden kleinen Loch:---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | real remainder chunk |XXX| small hole |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| artificially enlarged remainder chunk
wenn der SIGALRM-Handler syslog() und damit __tzfile_read() aufruft, allokiert fopen() das kleine Loch per malloc() für seine FILE-Struktur, und __fread_unlocked() allokiert per malloc() einen 4KB-Lesepuffer und teilt dadurch den vergrößerten Rest-Chunk in zwei Teile (den 4KB-Lesepuffer und einen kleinen Rest-Chunk):
---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<-------| 4KB read buffer remainder
wir überschreiben daher Teile der FILE-Struktur mit dem internen Header dieses kleinen Rest-Chunks: genauer gesagt überschreiben wir das _vtable_offset des FILE mit dem dritten Byte des bk-Felds dieses Headers, einem Zeiger auf die unsortierte Liste freier Chunks, 0xb761d7f8 (d.h., wir überschreiben _vtable_offset mit 0x61);
dann ruft __fread_unlocked(), wie im Unterabschnitt „Theorie“ erläutert, _IO_wfile_underflow() auf (anstelle von _IO_file_underflow()), das unseren eigenen __fct-Funktionszeiger (über unseren eigenen _codecvt- Zeiger) aufruft und unseren beliebigen Code ausführt.
Hinweis: Wir haben noch nicht erklärt, wie man zuverlässig von einem kontrollierten _codecvt-Zeiger zu einem kontrollierten __fct-Funktionszeiger gelangt; das werden wir tun, aber wir müssen zuerst ein dringenderes Problem lösen.
Tatsächlich haben wir aus unserer Arbeit an älteren OpenSSH-Versionen gelernt, dass wir diese Signal-Handler-Race-Condition nie gewinnen werden, wenn unser großes Race-Fenster nur ein kleines Race-Fenster enthält. Folglich haben wir die folgende Strategie umgesetzt, basierend auf dem folgenden Heap-Layout:
---|------------|---|------------|---|------------|---|------------|---
| XXX | large hole 1 | XXX | small hole 1 | XXX | large hole 2 | XXX | small hole 2 | ... |
|---|---|---|---|---|---|---|---|---|
Das letzte Paket, das wir an sshd senden (kurz vor der Zustellung von SIGALRM), zwingt sshd, die folgende Sequenz von malloc()- Aufrufen auszuführen: malloc(~4KB), malloc(304), malloc(~4KB), malloc(304) usw.
1/ Unser erstes malloc(~4KB) teilt das große Loch 1 in zwei Teile:
wenn diese erste Teilung zur richtigen Zeit durch SIGALRM unterbrochen wird, dann allokiert das fopen() im SIGALRM-Handler das kleine Loch 1 per malloc() für seine FILE-Struktur, und wir erreichen beliebige Codeausführung wie oben erläutert;
wenn nicht, dann allokieren wir das kleine Loch 1 selbst mit unserem ersten malloc(304), und:
2/ Unser zweites malloc(~4KB) teilt das große Loch 2 in zwei Teile:
wenn diese zweite Teilung zur richtigen Zeit durch SIGALRM unterbrochen wird, dann allokiert das fopen() im SIGALRM-Handler das kleine Loch 2 per malloc() für seine FILE-Struktur, und wir erreichen beliebige Codeausführung wie oben erläutert;
wenn nicht, dann allokieren wir das kleine Loch 2 selbst mit unserem zweiten malloc(304) usw.
Wir konnten 27 Paare solcher großen und kleinen Löcher im Heap von sshd erzeugen (28 würden PACKET_MAX_SIZE, 256KB, überschreiten): Unser großes Race-Fenster enthält nun 27 kleine Race-Fenster! Dieses komplexe Heap-Layout zu erreichen, war extrem mühsam und zeitaufwändig, aber die zwei Höhepunkte sind:
Um dieses Heap-Layout zuverlässig zu erreichen, senden wir fünf verschiedene Public-Key- Pakete an sshd (Pakete a/ bis d/ können lange vor SIGALRM gesendet werden; der größte Teil von Paket e/ kann ebenfalls lange vor SIGALRM gesendet werden, aber sein allerletztes Byte muss im allerletzten Moment gesendet werden):
a/ Wir führen für eine Reihe von Tcache-Chunks malloc() und free() aus, um sicherzustellen, dass die Heap-Allokationen, die wir nicht kontrollieren, in diesen Tcache- Chunks landen und unser sorgfältig geplantes Heap-Layout nicht beeinträchtigen.
b/ Wir führen für Chunks verschiedener Größen malloc() und free() aus, um unsere 27 Paare aus großen und kleinen Löchern (sowie die entsprechenden „Barriere“-Chunks) zu erzeugen.
c/ Wir führen für ~4KB-Chunks und 320B-Chunks malloc() und free() aus, um:
den Fake-Header (das große Größenfeld) unseres potenziell vergrößerten Rest-Chunks in die Mitte unserer großen Löcher zu schreiben;
den Fake-Footer unseres potenziell vergrößerten Rest-Chunks an das Ende unserer kleinen Löcher zu schreiben (um die Sicherheitsprüfungen der glibc zu bestehen);
unsere Fake-Vtable- und _codecvt-Zeiger in unsere kleinen Löcher (die potenzielle FILE-Strukturen sind) zu schreiben.
d/ Wir führen für einen sehr großen String (fast 256KB) malloc() und free() aus, um sicherzustellen, dass unsere großen und kleinen Löcher aus der unsortierten Liste freier Chunks entfernt und in ihre jeweiligen Malloc-Bins gelegt werden.
e/ Wir zwingen sshd, unsere finale Sequenz von malloc()-Aufrufen auszuführen (malloc(~4KB), malloc(304), malloc(~4KB), malloc(304) usw.), um unsere 27 kleinen Race-Fenster zu öffnen.
Aufmerksame Leser mögen bemerkt haben, dass wir das Problem von _codecvt (wörtlich und im übertragenen Sinne) immer noch nicht angegangen sind. Tatsächlich ist _codecvt ein Zeiger auf eine Struktur (_IO_codecvt), die einen Zeiger auf eine Struktur (__gconv_step) enthält, die den __fct-Funktionszeiger enthält, der es uns ermöglicht, beliebigen Code auszuführen. Um __fct zuverlässig über _codecvt zu kontrollieren, zeigen wir _codecvt einfach auf einen der Malloc-Bins der glibc, der praktischerweise einen Zeiger auf einen unserer freien Chunks im Heap enthält, der unseren eigenen __fct-Funktionszeiger auf beliebigen glibc- Code enthält (alle diese glibc-Adressen sind uns bekannt, da wir annehmen, dass die glibc an der Adresse 0xb7400000 abgebildet ist).
Uns läuft die Zeit davon
-- The Interrupters, „As We Live“
Als wir diesen dritten Exploit implementierten, wurde klar, dass wir die Timing-Strategie, die wir gegen die zwei älteren OpenSSH-Versionen verwendet hatten, nicht einfach wiederverwenden konnten: Wir gewannen diese neue Race-Condition nie. Schließlich verstanden wir, warum:
sshd benötigt lange (~10ms), um unseren fünften und letzten Public Key (Paket e/ oben) zu parsen; mit anderen Worten, unser großes Race-Fenster ist zu groß (unsere 27 kleinen Race-Fenster sind wie Nadeln im Heuhaufen).
Das kürzlich eingeführte user_specific_delay() (OpenSSH 7.8p1) verzögert sshds Antwort auf unser letztes Public-Key-Paket um bis zu ~9ms und zerstört daher unsere feedbackbasierte Timing-Strategie.
Als Ergebnis entwickelten wir eine völlig andere Timing-Strategie:
Von Zeit zu Zeit senden wir unser letztes Public-Key-Paket mit einem kleinen Fehler, der eine Fehlerantwort erzeugt (Zeilen 138-142 unten), direkt vor dem Aufruf von sshkey_from_blob(), das unseren Public Key parst;
Von Zeit zu Zeit senden wir unser letztes Public-Key-Paket mit einem weiteren kleinen Fehler, der eine Fehlerantwort erzeugt (Zeilen 151-155 unten), direkt nach dem Aufruf von sshkey_from_blob(), das unseren Public Key parst;
Die Differenz zwischen diesen beiden Antwortzeiten ist die Zeit, die sshd benötigt, um unseren letzten Public Key zu parsen, und dies erlaubt uns, die Übertragung unserer letzten Pakete präzise zu timen (um sicherzustellen, dass sshd genügend Zeit hat, unseren Public Key im unprivilegierten Kindprozess zu parsen, ihn an den privilegierten Kindprozess zu senden und dort mit dem Parsen zu beginnen, bevor SIGALRM zugestellt wird).
Mit dieser Strategieänderung braucht es durchschnittlich ~10.000 Versuche, um die Race-Condition zu gewinnen; d.h. mit 100 akzeptierten Verbindungen (MaxStartups) pro 120 Sekunden (LoginGraceTime) dauert es durchschnittlich ~3-4 Stunden, um die Race-Condition zu gewinnen, und ~6-8 Stunden, um eine entfernte Root-Shell zu erhalten (wegen ASLR).
Was ist dein Plan für morgen?
-- The Interrupters, „Take Back the Power“
Wir haben uns entschieden, Rocky Linux 9 (ein Derivat von Red Hat Enterprise Linux 9), von „Rocky-9.4-x86_64-minimal.iso“, aus zwei Gründen anzugreifen:
Seine OpenSSH-Version (8.7p1) ist anfällig für diese Signal-Handler-Race-Condition und seine glibc ist immer auf einem Vielfachen von 2MB abgebildet (wegen der im vorherigen Unterabschnitt „Theorie“ diskutierten ASLR-Schwäche), was partielle Pointer-Overwrites viel mächtiger macht;
Die syslog()-Funktion (die nicht async-signal-sicher ist, aber vom SIGALRM-Handler von sshd aufgerufen wird) dieser glibc-Version (2.34) ruft intern __open_memstream() auf, das per malloc() eine FILE-Struktur im Heap allokiert, und ruft außerdem calloc(), realloc() und free() auf (was uns dringend benötigte Freiheit verschafft).
Mit einer Heap-Korruption als Primitive, zwei per malloc() im Heap allokierten FILE-Strukturen und 21 festen Bits in den glibc-Adressen glauben wir, dass diese Signal-Handler-Race-Condition auf amd64 ausnutzbar ist (wahrscheinlich nicht in ~6-8 Stunden, aber hoffentlich in weniger als einer Woche). Nur die Zeit wird es zeigen.
Randnotiz: Wir haben entdeckt, dass Ubuntu 24.04 die ASLR seiner sshd-Kindprozesse nicht neu randomisiert (sie wird nur einmal zur Boot-Zeit randomisiert); wir haben dies auf den untenstehenden Patch zurückgeführt, der sshds rexec_flag deaktiviert. Das ist im Allgemeinen eine schlechte Idee, aber im besonderen Fall dieser Signal-Handler-Race-Condition verhindert es, dass sshd ausnutzbar ist: Das syslog() im SIGALRM-Handler ruft keine der malloc-Funktionen auf, weil es nie der allererste Aufruf von syslog() ist.
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
Der Sturm ist gekommen und gegangen
-- The Interrupters, „Good Things“
Am 6. Juni 2024 wurde diese Signal-Handler-Race-Condition durch Commit 81c1099 („Add a facility to sshd(8) to penalise particular problematic client behaviours“) behoben, der den async-signal-unsicheren Code aus dem SIGALRM-Handler von sshd in den Listener-Prozess von sshd verschoben hat, wo er synchron behandelt werden kann:
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
Da diese Behebung Teil eines großen Commits (81c1099) ist, zusätzlich zu einem noch größeren Defense-in-Depth-Commit (03e3de4, „Start the process of splitting sshd into separate binaries“), könnte sich ein Backport als schwierig erweisen. In diesem Fall kann die Signal-Handler-Race-Condition selbst behoben werden, indem der async-signal-unsichere Code aus der sshsigdie()-Funktion entfernt oder auskommentiert wird; zum Beispiel:
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
Schließlich kann diese Signal-Handler-Race-Condition, wenn sshd nicht aktualisiert oder neu kompiliert werden kann, einfach behoben werden, indem LoginGraceTime in der Konfigurationsdatei auf 0 gesetzt wird. Das macht sshd anfällig für einen Denial-of-Service (die Erschöpfung aller MaxStartups-Verbindungen), schützt es aber vor der in diesem Advisory vorgestellten Remote-Codeausführung.
Wir danken den OpenSSH-Entwicklern für ihre herausragende Arbeit und die enge Zusammenarbeit an diesem Release. Wir danken außerdem der distros@openwall. Schließlich widmen wir dieses Advisory Sophia d'Antoine.
2024-05-19: Wir kontaktierten die OpenSSH-Entwickler. Es folgten aufeinanderfolgende Iterationen von Patches und Patch-Reviews.
2024-06-20: Wir kontaktierten die distros@openwall.
2024-07-01: Koordinierter Veröffentlichungstermin.
| ~8KB |
| 320B |
| ~8KB |
| 320B |