Fundierte Artikel über Technologien, die das Kommende formen.

Ein Sternchen, zehn Jahre: Alpine Linux und der SSH-Bug

Ein SSH-Bug in Alpine Linux ließ jedes Passwort über 256 Zeichen in gesperrte Konten. Wie ein musl-crypt()-Detail zehn Jahre unbemerkt blieb.

Eine Tresortür mit einer sternförmigen Öffnung, die in einem dunklen Serverraum leuchtet
Ein einziges Zeichen in einer Systemdatei entschied zwischen einem gesperrten Konto und einer offenen Tür.

Hier ist eine Zahl, die dich beunruhigen sollte: 257. Etwa zehn Jahre lang führte die Eingabe eines beliebigen Passworts mit 257 Zeichen oder mehr bei einer SSH-Anmeldung auf der falschen Art von Linux-System nicht zur Ablehnung, sondern zum Zugang. Jedes Passwort. Wild getippte Tastenfolgen, ein Base64-Dump deiner Urlaubsfotos, eine halbe Slack-Nachricht, die du versehentlich eingefügt hast: Solange es lang genug war, ging die Tür auf. Die als „sshole“ bekannt gewordene SSH-Lücke in Alpine Linux wirkt wie ein Scherz, bis man die Voraussetzungen prüft. Dann liest sie sich wie ein Incident-Report.

Die Kurzfassung: Bei OpenSSH, das gegen musl libc gebaut wurde, also Alpine, OpenWrt, Buildroot, Yocto und ein erstaunlich großer Teil der Docker-Base-Images im Netz, konnten sich Konten, bei denen ein Login eigentlich mathematisch unmöglich sein sollte, mit jedem Passwort zwischen 257 und 1.024 Zeichen anmelden. Root. Dein reiner Key-Login-Deploy-User. Der Standardbenutzer alpine in Alpines offiziellen AWS- und Azure-Images, der mit passwortlosem doas ausgeliefert wird. Der Fix ist in OpenSSH 10.6p2 enthalten. Die Geschichte, wie es dazu kam, ist aber eine Kette kleiner, vernünftiger Entscheidungen, die bis 1980 zurückreicht, und jedes einzelne Glied sah damals korrekt aus.

Wie der Alpine-Linux-SSH-Bug tatsächlich funktioniert

Um die Kollision zu verstehen, brauchst du drei Teile, die jeweils aus einer anderen Codebasis und einem anderen Jahrzehnt stammen. Erstens: wie Linux Passwörter speichert. Wenn du ein Passwort setzt, landet der Hash in /etc/shadow, eine Zeile pro Konto. Eine Zeile für ein normales Konto sieht etwa so aus: $6$salt$hash, wobei $6$ für sha512crypt steht. Konten, die nie ein Passwort akzeptieren sollten, also Daemons, reine Key-Login-Benutzer und gesperrtes root, bekommen stattdessen einen Platzhalter:

alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::

Beim Login nimmt sshd, was du eingegeben hast, hasht es mit dem gespeicherten Salt und vergleicht das Ergebnis als String mit dem gespeicherten Feld. Gleicher String, du bist drin. Das Sternchen ist ein Sentinel-Wert: Es ist kein Hash von irgendetwas, daher kann kein Passwort jemals darauf passen. Seit den frühen Unix-Tagen ist das der Sperrmechanismus für Konten mit reinem Key-Login. Er funktioniert, weil der Vergleich nie gelingen kann, außer, die Hash-Funktion selbst lässt sich dazu bringen, ein Sternchen zu erzeugen. Damit kommen wir zu Teil zwei.

Teil zwei ist die Fehlerbehandlung in musl. Traditionell gibt crypt() NULL zurück, wenn es seine Aufgabe nicht erledigen kann, etwa wenn das Salt einen Algorithmus verlangt, den das System nicht kennt. Das Problem: Jahrzehntealte Software hat NULL nie geprüft und würde es fröhlich an strcmp übergeben und abstürzen. Deshalb traf musl 2012 eine bewusste Designentscheidung: Dessen crypt() gibt niemals NULL zurück. Im Fehlerfall liefert es einen String, der garantiert auf keinen echten Passwort-Hash passt, damit fehlerhafte Aufrufer ein falsches Passwort sehen statt abzustürzen. Der gewählte Fehlerstring war *. Drei Wochen später ergänzte musl sha512crypt mit einer Eingabegrenze von 256 Byte. Der Hash-Aufwand wächst mit der Passwortlänge, riesige Passwörter sind also ein Denial-of-Service-Vektor. Alles Längere wird abgelehnt, das ist ein Fehler, und der liefert *. Der Kommentar, der das dokumentiert, stand die ganze Zeit im Quellcode.

Teil drei ist ein höflicher sshd. 2016 zeigte CVE-2016-6210, dass man gültige Benutzernamen über das Timing der Antworten ermitteln konnte: Nicht existierende Benutzer wurden sofort abgelehnt, weil es keinen Hash zu berechnen gab. Die Lösung war elegant. Für Konten ohne brauchbaren Hash leiht sich sshd das Salt eines anderen Kontos und hasht das eingegebene Passwort trotzdem, nur um dieselbe Zeit zu verbrauchen. Ein langsameres Nein. Der Haken: Dieser Pfad greift nicht nur bei nicht existierenden Benutzern. Er greift auch bei echten Benutzern, deren Shadow-Feld * ist, denn ein Sternchen ist kein verwendbarer Hash. sshd berechnet also crypt(dein_passwort, geliehenes_salt) und vergleicht das Ergebnis mit *.

/* what sshd effectively does in this configuration */
const char *stored = shadow_field(user);        /* "*" for locked accounts */
const char *salt   = pick_salt(user);           /* borrowed: "*" isn't a valid hash */
char *ret = crypt(typed_password, salt);        /* musl: "*" if password > 256 bytes */
if (ret && strcmp(ret, stored) == 0)
allow_login();                              /* "*" == "*"  →  welcome in */

Alle Elemente müssen zusammenpassen: Das gespeicherte Feld ist *, crypt schlägt fehl, und der Fehlerwert ist ebenfalls *. Auf glibc gibt ein fehlgeschlagenes crypt NULL zurück, was den Vergleich sicher scheitern lässt. Auf libxcrypt-Systemen lautet der Fehlerstring *0, ein Zeichen Unterschied, und genau dieses eine Zeichen entscheidet alles, denn *0 ist nie gleich *. Auf musl sind Fehlerwert und Sperrwert dasselbe Zeichen, und strcmp kann einen Fehler nicht von einem Treffer unterscheiden. Zwei Bedeutungen, ein Kanal, und der Empfänger kann sie nicht auseinanderhalten.

Ein Jahrzehnt perfekt vernünftiger Entscheidungen

Was diesen Bug lehrreich macht, ist nicht der Mechanismus, sondern dass niemand Beteiligtes etwas offensichtlich Dummes getan hat. Fangen wir 1980 an: Damals lagen Passwort-Hashes noch in der weltlesbaren /etc/passwd. Bill Joy legte einen Account namens daemon an, in den sich niemand je einloggen sollte, und schrieb ein * an die Stelle des Passwort-Hashes. Ein sauberer Sentinel, Jahrzehnte ihrer Zeit voraus. (Das Feld für den echten Namen soll übrigens „the devil himself“ gelautet haben.) Unix sperrt Konten bis heute genau so.

Auch musls Entscheidung von 2012 wirkt aus jeder Perspektive vertretbar. Abstürzende Aufrufer sind ein echtes Problem, und eine C-Bibliothek, die NULL gegen jahrzehntealten schlampigen Anwendungscode einsetzt, macht sich viele Feinde. Ein String, der nie passen kann, ist ein eleganter Rückzug in den Notbetrieb. Ebenso die Begrenzung der Passwortlänge, um Denial-of-Service über Hashing zu verhindern. Ebenso der Timing-Fix in sshd, denn die Antworten für nicht hashbare Benutzer aufzufüllen, ist gängige Praxis. Sogar das, was Alpine danach tat, war vertretbar. 2019 zeigte CVE-2019-5021, dass Alpines Docker-Images ein leeres Root-Passwortfeld hatten, in manchen Konfigurationen gar kein Passwort. Alpine sperrte root daraufhin mit !. Ohne PAM behandelt sshd ! aber als vollständig gesperrt, was auch Key-Logins kappt, und Cloud-Images leben nun mal von Key-Logins. Also wechselte Alpine 2022 mit Version 3.16 root auf *: weiterhin gesperrt, Keys funktionieren, alle zufrieden. Nur war * genau der String, den man musls crypt zum Ausgeben verleiten konnte.

Jeder Schritt war eine vertretbare Antwort auf ein echtes Problem. Genau das macht es so unangenehm: Du hättest bei jeder einzelnen Entscheidung dabei sein und zustimmend nicken können.

Die Beinahe-Treffer sind fast noch schlimmer. Ein Debian-Entwickler bat glibc einmal, musls genaues *-Verhalten zu übernehmen; sechs Jahre später wechselte Debian zu libxcrypt, dessen *0 das Loch zufällig schloss. Ein BusyBox-Thread beklagte, dass passwd an einem 257 Zeichen langen Passwort scheiterte. Gleiches Sternchen, gleiches Limit, und niemand im Thread sagte „sshd“. Ein Security-Reviewer patchte pick_salt() in sshd, während der eigentliche Bug vier Zeilen darunter lag, vier Tage bevor ihn endlich jemand fand. Diese Sache wurde immer wieder übersehen, von Leuten, die fast direkt draufschauten.

Dominosteine aus verschiedenen Materialien fallen in einer Kette über einen dunklen Tisch
Vier Jahrzehnte kleiner, vernünftiger Entscheidungen, die jeweils die nächste umwerfen.

Ist deine Infrastruktur vom musl-OpenSSH-Bug betroffen?

Die gnädige Seite von sshole ist, dass es ein volles Blatt an Voraussetzungen braucht, bevor es auszahlt. Fehlt auch nur eine, bist du sicher. Du bist gefährdet, wenn alle diese Bedingungen erfüllt sind:

  • Dein sshd ist gegen musl gebaut: Alpine, OpenWrt, Buildroot, Yocto oder ein Docker-Image, das von alpine abstammt und openssh-server installiert hat.
  • sshd läuft nicht über PAM. Das ist beim openssh-server von Alpine standardmäßig so, oder du hast UsePAM no gesetzt. Der PAM-Codepfad ruft für ein Konto mit * nie crypt auf.
  • Passwort-Authentifizierung ist aktiviert. Standardmäßig ist sie das.
  • Mindestens ein Konto hat ein nacktes * in /etc/shadow: reine Key-Login-Benutzer, LDAP-gestützte Konten, root auf Alpine.
  • Mindestens ein anderes Konto hat einen echten Passwort-Hash, also gibt es ein Salt zum Ausleihen.

Treffen alle fünf zu, akzeptiert jedes *-Konto auf der Kiste jedes Passwort mit 257 bis 1.024 Zeichen. Der Albtraum sind Alpines offizielle AWS- und Azure-Images: Der Standardbenutzer alpine ist reiner Key-Login (also *) und hat passwortloses doas. Ein Login mit langem Passwort ist dort keine Rechteerweiterung, sondern eine Hintertür direkt zu root. Eine Schutzmaßnahme verdient Anerkennung dafür, dass das so lange unbemerkt blieb: Die PermitRootLogin-Option von OpenSSH verweigert Passwort-Logins für root seit 2015, das naheliegendste Ziel war also standardmäßig teilweise abgeschirmt. Die Deploy-Benutzer und Service-Konten waren dagegen ungeschützt.

Wenn du auf Debian, Ubuntu, Fedora oder etwas anderem mit glibc läufst, bist du in Sicherheit. Alles, was über PAM läuft: sicher. Dropbear: sicher. OpenBSD: sicher. Ein Hinweis für die Skeptiker: Frühe Reproduktionsversuche aus der Community waren uneinheitlich, weil die fünf Bedingungen leicht einzeln übersehen werden. Jemand testet das offizielle Alpine-Image, merkt nicht, dass das Root-Feld ! statt * ist, scheitert und hakt es ab. Betrachte „bei mir auf dem Laptop nicht reproduziert“ als überhaupt keinen Beleg für Produktion. Prüfe die Bedingungen, nicht das Bauchgefühl.

Die eigentliche Ursache: In-Band-Signalisierung und Magic Strings

Es ist verlockend, sshole unter „seltsamer libc-Sonderfall“ abzulegen und weiterzuziehen. Der Fehlermodus ist aber älter und allgemeiner: In-Band-Signalisierung. Das Zeichen * musste zwei Aufgaben übernehmen. In /etc/shadow bedeutet es „für dieses Konto existiert kein Passwort“, in musls crypt bedeutet es „Hashing ist fehlgeschlagen“. Zwei Nachrichten teilen sich einen Kanal, und der Empfänger kann sie nicht unterscheiden. Sentinel-Werte, die in gewöhnlichen Daten stecken, sind der Weg, wie sich Systeme selbst ausnutzbar machen. NULL war als Fehlersignal akzeptabel (abstürzende Aufrufer hin oder her). Eine Exception ist besser. Am besten ist ein Result<T, E>, das den Aufrufer zwingt, Fehler explizit zu behandeln. Die praktische Regel, die ich jedem Systemprogrammierer auf den Unterarm tätowieren würde: keine Magic Strings. Wenn du unbedingt einen brauchst, wähle etwas, das mit keinem Wert der Problemdomäne kollidieren kann. libxcrypts *0 schlägt musls * um genau ein Byte, und dieses Byte ist der Unterschied zwischen einer Eigenart und einer CVE.

Es gibt eine zweite Lektion über stille Kompatibilitäts-Shims. Musls Entscheidung, * graceful zurückzugeben, soll verhindern, dass kaputte Software abstürzt, und genau das verhindert auch, dass kaputte Software überhaupt als kaputt auffällt. Ein Segfault ist hässlich, aber er ist auch ein Signal: Jemand meldet den Bug, jemand korrigiert den Aufrufer. Verschluckst du den Fehler, wächst der Fehler still über ein Jahrzehnt. Ich bin zunehmend überzeugt, dass dies die tiefste Trennlinie im Systemdesign ist: Technik, die Katastrophen widersteht, scheitert tendenziell laut und früh, während Technik, die auf Reibungslosigkeit optimiert ist, versteckte Schulden anhäuft, bis die Rechnung auf einmal kommt.

Außerdem sollte sie einen beruhigenden Mythos entkräften: dass alter, weit verbreiteter Code vertrauenswürdiger sei, weil er bewährt ist. Alter verteilt die Aufmerksamkeit nicht gleichmäßig. Er konzentriert sie auf die heißen Pfade und lässt die kalten verknöchern. Alle prüfen die Kryptografie. Fast niemand prüft den langweiligen Kleber, über den sshd mit libc über Passwörter spricht, weil dieser Code angeblich „schon immer funktioniert hat“. Die Schnittmenge zweier Minderheitenkonfigurationen, musl-Systeme und darin wiederum die ohne PAM, ist genau der Ort, an dem ein Bug zehn Jahre ungestört leben kann.

Wie du den sshole-Bug behebst und deine Logs prüfst

Der Patch ist der einfache Teil: OpenSSH 10.6p2 schließt das Loch. Auf Alpine ist das ein einziger Befehl. Wenn du heute nicht updaten kannst, ist die Gegenmaßnahme genauso schlicht und völlig wirksam: Schalte die Passwort-Authentifizierung aus. Keine Passwortprüfung, kein Bug. Ehrlich gesagt solltest du beides tun, denn reine Key-Authentifizierung war schon vor diesem Bug die richtige Antwort und bleibt es auch, nachdem alle gepatcht haben. Das erinnert an eine Lektion aus anderen Debatten über Patchen versus Abmildern: Wenn die Gegenmaßnahme günstig und vollständig ist, werde nicht clever.

# patch: openssh 10.6p2 or later
apk upgrade openssh
# can't patch yet? passwords off, keys only
echo "PasswordAuthentication no" > /etc/ssh/sshd_config.d/00-no-passwords.conf
rc-service sshd restart
Eine Laterne beleuchtet kaskadierende Logausdrucke im Dunkeln, eine Zeile leuchtet rot
Irgendwo in zehn Jahren Auth-Logs hat vielleicht ein sehr langes Passwort ein gesperrtes Konto geöffnet.

Dann die schwierigere Frage: War schon jemand schneller? Zehn Jahre sind eine lange Verweildauer. Du suchst nach erfolgreichen Passwort-Authentifizierungen für Konten, die nie ein Passwort hatten: reine Key-Login-Deploy-Benutzer, Service-Konten, LDAP-Identitäten, root auf Alpine-Rechnern. Erfolgreiche Logins für diese Konten über die Methode password sollten extrem selten sein. Jeder Treffer ist ein Großalarm. Unangenehme Wahrheit: sshd protokolliert den Erfolg, nicht die Passwortlänge, daher verschwindet ein Großteil der direkten Evidenz schon beim Schreiben. Und wie wir schon behandelt haben, haben Authentifizierungs-Logs in der Cloud blinde Flecken selbst an guten Tagen. Genau dort liegen die sshd-Logs kurzlebiger VMs auf Instanzebene. Durchsuche, was du hast. Geh davon aus, dass es unvollständig ist.

Was zehn stille Jahre uns lehren sollten

Die Entdeckung selbst verdient so viel Aufmerksamkeit wie der Bug. Der Forscher, der sshole fand, starrte nicht auf die Kryptografie. Er sah sich die langweilige Grenze an, an der OpenSSH mit libc spricht, baute sshd in jeder Konfiguration, die ihm einfiel (glibc, musl, PAM, ohne PAM, mit shadow, ohne shadow), und schickte rund 17.600 fehlerhafte Logins durch die Matrix. Seltsame Hashes, kaputte Konten, viel zu lange Passwörter. Jeder Build war sich bei jedem Login einig, außer einem: musl ohne PAM. Der sagte Ja zu Dingen, zu denen er Nein hätte sagen müssen. Das ist kein Glücksfund, sondern Testabdeckung. Differential Testing über Konfigurationsmatrizen ist der Weg, Bugs zu finden, die in Schnittmengen stecken, und genau die Art erschöpfender, unermüdlicher Arbeit, bei der Agenten-Schwärme erstaunlich gut sind. Das nächste sshole wird nicht zehn stille Jahre bekommen.

Die offene Frage, die Betreiber nachts wachhalten sollte: Sind zehn Jahre wirklich vergangen, ohne dass es jemand bemerkte, oder hat es die Falschen bemerkt? Allein der versehentliche Fall zählt: Füge den falschen Zwischenablageninhalt in eine Passwortabfrage ein, eine halbe Slack-Unterhaltung, einen kompletten SSH-Private-Key, irgendetwas über 257 Zeichen. Auf einer verwundbaren Kiste öffnet sich ein gesperrtes Konto dann einfach. Wenn du nie beim Einfügen in ein Passwortfeld danebengegriffen hast, bist du sorgfältiger als alle, mit denen ich je gearbeitet habe. Irgendjemand, irgendwo, hat sich in eine Kiste eingeloggt, in die er gar nicht wollte, und das Terminal leise wieder geschlossen. Vielleicht viele.

Also: patchen, ja. Passwörter abschalten, unbedingt. Aber dann nimm dir eine Stunde und suche in deinen eigenen Systemen nach Zeichen, die zwei Aufgaben übernehmen: die Sentinel-Werte, die stillen Fallbacks, die „kann nie passieren“-Zweige, die Kompatibilitäts-Shims, an die sich niemand mehr erinnert, dass er sie geschrieben hat. Jede ausgereifte Codebasis hat davon. Sie sind genau deshalb unsichtbar, weil sie nie ein Problem verursacht haben, das jemand sehen konnte. Irgendwo in deinem Stack steht gerade ein Sternchen, das leise zwei Dinge gleichzeitig bedeutet. Finde es, bevor es jemand mit einem sehr langen Passwort tut.