Un astérisque, dix ans : la faille SSH silencieuse d'Alpine Linux
Pendant dix ans, un bug SSH d'Alpine Linux laissait entrer n'importe quel mot de passe de plus de 256 caractères. Explications sur le piège musl crypt().

Voici un nombre qui devrait vous inquiéter : 257. Pendant une dizaine d'années, taper un mot de passe de 257 caractères ou plus dans une invite SSH sur le mauvais type de système Linux ne vous faisait pas rejeter, ça vous faisait entrer. N'importe quel mot de passe. Un clavier martelé au hasard, un export base64 de vos photos de vacances, la moitié d'un message Slack collé par erreur : tant qu'il était assez long, la porte s'ouvrait. La faille SSH d'Alpine Linux, surnommée « sshole », fait partie de ces vulnérabilités qui ressemblent à une blague jusqu'à ce que vous vérifiiez les conditions, et là, on dirait un rapport d'incident.
En bref : sur OpenSSH compilé avec musl libc, c'est-à-dire Alpine, OpenWrt, Buildroot, Yocto et une part impressionnante des images Docker de base disponibles sur Internet, les comptes qui auraient dû être mathématiquement impossibles à ouvrir acceptaient n'importe quel mot de passe entre 257 et 1 024 caractères. Root. Votre utilisateur de déploiement en clé seule. L'utilisateur alpine par défaut des images officielles AWS et Azure d'Alpine, qui est livré avec un doas sans mot de passe. Le correctif est arrivé dans OpenSSH 10.6p2. Mais l'histoire de la façon dont c'est arrivé est une chaîne de petites décisions raisonnables remontant à 1980, et chaque maillon semblait correct à l'époque.
Comment fonctionne réellement la faille SSH d'Alpine Linux
Pour comprendre la collision, il faut trois éléments, chacun issu d'une base de code différente et d'une décennie différente. Premier point : la façon dont Linux stocke les mots de passe. Quand vous définissez un mot de passe, le hash atterrit dans /etc/shadow, une ligne par compte. La ligne d'un compte normal ressemble à $6$salt$hash, où $6$ désigne sha512crypt. Mais les comptes qui ne devraient jamais accepter de mot de passe (démons, utilisateurs en clé seule, root verrouillé) reçoivent à la place un marqueur :
alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::
À la connexion, sshd prend ce que vous avez tapé, le hashe avec le sel stocké, et compare la chaîne obtenue à celle du champ stocké. Même chaîne, vous entrez. L'astérisque est une valeur sentinelle : ce n'est le hash de rien, donc aucun mot de passe ne peut jamais y correspondre. C'est le mécanisme de verrouillage des comptes en clé seule depuis les débuts d'Unix. Ça fonctionne parce que la comparaison ne peut jamais réussir, sauf si la fonction de hachage elle-même peut être amenée à produire un astérisque. Ce qui nous amène au deuxième élément.
Deuxième élément : la gestion d'erreurs de musl. Traditionnellement, crypt() renvoie NULL quand il ne peut pas faire son travail, par exemple si le sel demande un algorithme absent du système. Le problème, c'est que des décennies de logiciels n'ont jamais vérifié ce NULL et le transmettaient joyeusement à strcmp, avec un segfault à la clé. En 2012, musl a donc pris un choix de conception délibéré : son crypt() ne renverrait jamais NULL. En cas d'échec, il renverrait une chaîne garantie de ne correspondre à aucun vrai hash de mot de passe, pour que les appelants défaillants voient un mauvais mot de passe au lieu de planter. La chaîne d'échec retenue était *. Trois semaines plus tard, musl a ajouté sha512crypt avec une limite d'entrée de 256 octets : le coût de hachage croît avec la longueur du mot de passe, donc les mots de passe géants sont un vecteur de déni de service. On rejette donc tout ce qui est plus long, ce qui est un échec, qui renvoie *. Le commentaire qui documente tout cela est resté dans le code source tout ce temps.
Troisième élément : sshd qui fait preuve de politesse. En 2016, la CVE-2016-6210 a montré qu'on pouvait énumérer des noms d'utilisateurs valides par la mesure des temps de réponse : les utilisateurs inexistants étaient rejetés instantanément, car il n'y avait aucun hash à calculer. La correction était élégante : pour les comptes sans hash utilisable, sshd emprunte le sel d'un autre compte et hashe quand même le mot de passe fourni, uniquement pour consommer le même temps. Un non plus lent. Le piège : ce chemin ne se déclenche pas seulement pour les utilisateurs inexistants. Il se déclenche aussi pour les vrais utilisateurs dont le champ shadow vaut *, car un astérisque n'est pas un hash utilisable. Donc sshd calcule maintenant crypt(votre_mot_de_passe, sel_emprunté) et compare le résultat à *.
/* 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 */
Tous les éléments doivent s'aligner : le champ stocké vaut *, crypt échoue, et la valeur d'échec vaut aussi *. Sur glibc, un crypt raté renvoie NULL, ce qui fait échouer la comparaison en toute sécurité. Sur les systèmes avec libxcrypt, la chaîne d'échec est *0 : un caractère de différence, et ce caractère fait toute la différence, car *0 n'est jamais égal à *. Sur musl, la valeur d'erreur et la valeur de verrouillage sont le même caractère, et strcmp ne peut pas distinguer une erreur d'une correspondance. Deux sens, un seul canal, et le récepteur ne peut pas les départager.
Une décennie de décisions parfaitement raisonnables
Ce qui rend ce bug intéressant à étudier, ce n'est pas le mécanisme, c'est que personne n'a fait quoi que ce soit d'évidemment stupide. Remontons en 1980 : à l'époque, les hashes de mots de passe étaient dans /etc/passwd, lisible par tous. Bill Joy a créé un compte appelé daemon sur lequel personne ne devrait jamais se connecter, et a mis un * à la place de son hash de mot de passe. Une sentinelle propre, en avance de plusieurs décennies. (Le champ du nom réel contenait, paraît-il, « le diable en personne ».) Unix verrouille encore les comptes exactement de cette façon.
La décision de musl en 2012 paraît aussi défendable sous tous les angles. Les appelants qui plantent sont un vrai problème, et une bibliothèque C qui transforme NULL en arme contre des décennies de code applicatif négligent se fait beaucoup d'ennemis. Renvoyer une chaîne qui ne pourra jamais correspondre, c'est une dégradation gracieuse. Limiter la longueur des mots de passe pour éviter le déni de service par hash, idem. Le correctif de timing de sshd aussi : rembourrer les réponses pour les utilisateurs non hachables est une pratique standard. Même ce qu'Alpine a fait ensuite se défend. En 2019, la CVE-2019-5021 a révélé que les images Docker d'Alpine étaient livrées avec un champ de mot de passe root vide, dans certaines configurations sans aucun mot de passe. Alpine a alors verrouillé root avec !. Mais sans PAM, sshd traite ! comme un verrouillage total, ce qui casse aussi les connexions par clé SSH, et les images cloud vivent et meurent par les connexions par clé. Donc, en 2022, Alpine 3.16 a basculé root sur * : toujours verrouillé, les clés fonctionnent, tout le monde est content. Sauf que * était exactement la chaîne que le crypt de musl pouvait être amené à produire.
Chaque étape était une réponse défendable à un vrai problème. C'est ce qui rend la chose inconfortable : vous auriez pu être présent à chaque décision et hocher la tête.
Les quasi-accidents sont presque pires. Un développeur Debian a un jour demandé à glibc d'adopter exactement le comportement * de musl ; six ans plus tard, Debian est passé à libxcrypt, dont le *0 a fermé la brèche par accident. Un fil de discussion BusyBox se plaignait que passwd bloque sur un mot de passe de 257 caractères : même astérisque, même limite, et personne dans le fil n'a dit « sshd ». Un relecteur sécurité a patché la fonction pick_salt() de sshd alors que le vrai bug se trouvait quatre lignes plus bas, quatre jours avant que quelqu'un le trouve enfin. Ce truc a été ignoré, à plusieurs reprises, par des gens qui le regardaient presque droit dans les yeux.

Votre infrastructure est-elle touchée par le bug OpenSSH de musl ?
La bonne nouvelle, c'est que sshole nécessite un ensemble complet de conditions avant de se déclencher. Il suffit qu'une seule manque pour être en sécurité. Vous êtes exposé si toutes ces conditions sont vraies :
- Votre sshd est compilé avec musl : Alpine, OpenWrt, Buildroot, Yocto, ou une image Docker dérivée de
alpineavecopenssh-serverinstallé. - sshd ne passe pas par PAM : le openssh-server d'Alpine ne le fait pas par défaut, ou vous avez défini
UsePAM no. Le chemin de code de PAM n'appelle jamais crypt pour un compte*. - L'authentification par mot de passe est activée. C'est le cas par défaut.
- Au moins un compte a un
*brut dans/etc/shadow: utilisateurs en clé seule, comptes adossés à LDAP, root sur Alpine. - Au moins un autre compte a un vrai hash de mot de passe, donc il y a un sel à emprunter.
Si les cinq conditions sont réunies, chaque compte * de la machine accepte n'importe quel mot de passe de 257 à 1 024 caractères. La configuration cauchemar, ce sont les images officielles AWS et Azure d'Alpine : l'utilisateur alpine par défaut est en clé seule (donc *) et dispose d'un doas sans mot de passe. Une connexion avec un long mot de passe n'y est pas une élévation de privilèges, c'est une porte d'entrée directe vers root. Une atténuation mérite d'être saluée pour avoir gardé le silence si longtemps : PermitRootLogin d'OpenSSH refuse les connexions par mot de passe pour root depuis 2015, donc la cible la plus évidente était partiellement protégée par défaut. Rien ne protégeait les utilisateurs de déploiement et les comptes de service.
Si vous êtes sous Debian, Ubuntu, Fedora ou tout autre système basé sur glibc, vous êtes tranquille. Tout ce qui passe par PAM : pareil. Dropbear : pareil. OpenBSD : pareil. Une mise en garde pour les sceptiques : les premières tentatives de reproduction par la communauté ont été incohérentes, car les cinq conditions sont faciles à manquer une à une. Quelqu'un teste l'image Alpine officielle, ne réalise pas que le champ de root vaut ! et non *, échoue, et passe à autre chose. Considérez « je n'arrive pas à reproduire sur mon portable » comme une absence totale de preuve pour la production. Vérifiez les conditions, pas les impressions.
La vraie cause profonde : la signalisation in-bande et les chaînes magiques
On est tenté de ranger sshole dans la catégorie « cas limite bizarre de libc » et de passer à autre chose. Le mode de défaillance est plus ancien et plus général que ça : la signalisation in-bande. Le caractère * a été assigné à deux rôles. Dans /etc/shadow, il signifie « aucun mot de passe n'existe pour ce compte », et dans le crypt de musl il signifie « le hachage a échoué ». Deux messages partageant un seul canal, sans moyen pour le récepteur de les distinguer. Les valeurs sentinelles intégrées dans des données ordinaires sont la façon dont les systèmes finissent par s'exploiter eux-mêmes. NULL était un signal d'erreur acceptable (malgré les appelants fragiles). Une exception est mieux. Un Result<T, E> qui force l'appelant à gérer explicitement l'échec est le mieux. La règle pratique, celle que je me ferais tatouer si j'étais développeur système : pas de chaînes magiques. Si vous en avez absolument besoin, choisissez quelque chose qui ne peut entrer en collision avec aucune valeur du domaine. Le *0 de libxcrypt bat le * de musl d'exactement un octet, et cet octet fait la différence entre une bizarrerie et une CVE.
Il y a une deuxième leçon sur les cales de compatibilité silencieuses. La décision de musl de renvoyer un * gracieux existe pour empêcher les logiciels défaillants de planter, ce qui signifie qu'elle empêche aussi que ces logiciels défaillants soient repérés comme tels. Un segfault est moche, mais c'est aussi un signal : quelqu'un ouvre un bug, quelqu'un corrige l'appelant. Avalez l'erreur, et l'erreur s'accumule en silence pendant une décennie. Je suis de plus en plus convaincu que c'est la ligne de fracture la plus profonde en conception de systèmes : l'ingénierie qui résiste aux catastrophes tend à échouer bruyamment et tôt, alors que l'ingénierie optimisée pour la fluidité accumule une dette cachée jusqu'à ce que la facture arrive d'un coup.
Et cela devrait faire tomber un mythe rassurant : celui selon lequel un code ancien et très déployé est plus fiable parce qu'il a été éprouvé. L'ancienneté ne répartit pas l'examen de façon égale, elle le concentre sur les chemins chauds et laisse les chemins froids se fossiliser. Tout le monde audite la cryptographie. Presque personne n'audite la colle banale où sshd parle à libc des mots de passe, parce que ce code « fonctionne depuis toujours ». L'intersection de deux configurations minoritaires (les systèmes musl, et parmi eux ceux qui n'utilisent pas PAM) est exactement l'endroit où un bug peut vivre tranquillement pendant dix ans.
Corriger le bug sshole et vérifier vos journaux
Le correctif est la partie facile : OpenSSH 10.6p2 ferme la brèche. Sur Alpine, c'est une seule commande. Si vous ne pouvez pas mettre à jour aujourd'hui, l'atténuation est tout aussi directe et totalement efficace : désactivez l'authentification par mot de passe. Pas de vérification de mot de passe, pas de bug. Franchement, faites les deux, car l'authentification par clé était la bonne réponse avant ce bug et le restera une fois que tout le monde aura patché. Cela fait écho à une leçon vue dans d'autres débats patch-contre-atténuation : quand l'atténuation est peu coûteuse et totale, ne soyez pas malins.
# 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

Reste la question plus difficile : quelqu'un est-il arrivé le premier ? Dix ans, c'est une longue période d'inactivité. Ce que vous cherchez, ce sont les authentifications par mot de passe réussies pour des comptes qui n'en ont jamais eu : utilisateurs de déploiement en clé seule, comptes de service, identités LDAP, root sur les machines Alpine. Les connexions réussies pour ces comptes via la méthode password devraient être extrêmement rares ; le moindre résultat est une alerte maximale. La vérité inconfortable, c'est que sshd journalise le succès, pas la longueur du mot de passe, donc une grande partie de la preuve directe disparaît à l'écriture. Et comme nous l'avons déjà vu, la journalisation de l'authentification dans le cloud a des angles morts même par beau temps : les journaux sshd au niveau des instances sur des VM éphémères sont précisément là où ils se cachent. Fouillez ce que vous avez. Partez du principe qu'il est incomplet.
Ce que dix années silencieuses devraient nous apprendre
La découverte elle-même mérite autant d'attention que le bug. Le chercheur qui a trouvé sshole n'a pas fixé la cryptographie : il a regardé la frontière banale où OpenSSH parle à libc, a compilé sshd dans toutes les configurations qu'il a pu imaginer (glibc, musl, PAM, sans PAM, shadow, sans shadow), et a envoyé environ 17 600 connexions malformées sur cette matrice. Hashes bizarres, comptes cassés, mots de passe bien trop longs. Chaque build s'accordait sur chaque connexion sauf un : musl sans PAM. Celui-là disait oui à des choses qu'il n'aurait pas dû accepter. Ce n'est pas une découverte chanceuse ; c'est de la couverture de test. Les tests différentiels sur des matrices de configuration sont la façon de trouver les bugs qui se cachent aux intersections, et c'est exactement le type de travail exhaustif et infatigable dans lequel les essaims d'agents se révèlent étonnamment efficaces. Le prochain sshole n'aura pas dix années tranquilles.
La question ouverte qui devrait empêcher les opérateurs de dormir est de savoir si dix ans se sont vraiment écoulés sans que personne ne le remarque, ou si ce sont les mauvaises personnes qui l'ont remarqué. Pensez au seul cas accidentel : coller le mauvais contenu du presse-papiers dans une invite de mot de passe (la moitié d'un fil Slack, une clé SSH privée entière, n'importe quoi de plus de 257 caractères), et sur une machine vulnérable, un compte verrouillé s'ouvre tout simplement. Si vous n'avez jamais raté un collage dans un champ de mot de passe, vous êtes plus prudent que quiconque avec qui j'ai travaillé. Quelqu'un, quelque part, s'est connecté à une machine où il ne voulait pas aller, puis a discrètement fermé le terminal. Peut-être beaucoup de quelqu'uns.
Alors, oui, patchez. Désactivez les mots de passe, absolument. Mais ensuite prenez une heure pour chercher dans vos propres systèmes les caractères qui remplissent deux rôles : les valeurs sentinelles, les replis silencieux, les branches « ça ne peut jamais arriver », les cales de compatibilité dont personne ne se souvient avoir écrit. Toute base de code mature en contient. Elles sont invisibles précisément parce qu'elles n'ont jamais causé de problème que quelqu'un ait pu voir. Quelque part dans votre pile, il y a en ce moment un astérisque qui signifie discrètement deux choses à la fois. Allez le trouver avant que quelqu'un avec un très long mot de passe ne le fasse.


