Un asterisco, diez años: el bug SSH silencioso de Alpine
Un bug de SSH en Alpine Linux permitía entrar con cualquier contraseña de más de 256 caracteres en cuentas bloqueadas durante diez años. Así ocurrió.

Aquí tienes un número que debería preocuparte: 257. Durante unos diez años, escribir en un prompt de SSH cualquier contraseña de 257 caracteres o más en el tipo equivocado de sistema Linux no te rechazaba, sino que te dejaba entrar. Cualquier contraseña. Aporreos del teclado, un volcado en base64 de tus fotos de vacaciones, medio mensaje de Slack pegado por error: mientras fuera lo bastante larga, la puerta se abría. El fallo de SSH de Alpine Linux, conocido como «sshole», es de esas vulnerabilidades que parecen una broma hasta que compruebas las condiciones, momento en el que parecen un informe de incidente.
La versión corta: en OpenSSH compilado contra musl libc —es decir, Alpine, OpenWrt, Buildroot, Yocto y una cantidad asombrosa de las imágenes base de Docker de internet— las cuentas que se suponía que eran matemáticamente imposibles de usar para entrar aceptaban cualquier contraseña de entre 257 y 1.024 caracteres. Root. Tu usuario de despliegue solo con clave. El usuario por defecto alpine de las imágenes oficiales de Alpine para AWS y Azure, que viene con doas sin contraseña. La corrección llegó en OpenSSH 10.6p2. Pero la historia de cómo ocurrió es una cadena de pequeñas decisiones sensatas que se remontan a 1980, y cada eslabón parecía correcto en su momento.
Cómo funciona realmente el bug SSH de Alpine Linux
Para entender la colisión necesitas tres piezas, cada una propiedad de una base de código distinta y de una década diferente. Primero: cómo Linux almacena las contraseñas. Cuando estableces una contraseña, el hash se guarda en /etc/shadow, una línea por cuenta. Una línea de una cuenta normal tiene este aspecto: $6$salt$hash, donde $6$ significa sha512crypt. Pero las cuentas que nunca deberían aceptar una contraseña —demonios, usuarios solo con clave, root bloqueado— reciben un marcador en su lugar:
alice:$6$yJ3kXw9q$k2P9vLm4...:19723:0:99999:7:::
deploy:*:19723:0:99999:7:::
root:*:19723:0:99999:7:::
Al iniciar sesión, sshd toma lo que escribiste, lo hashea con la sal almacenada y compara como cadenas el resultado con el campo guardado. Misma cadena, entras. El asterisco es un centinela: no es el hash de nada, así que ninguna contraseña puede coincidir con él. Ese ha sido el mecanismo de bloqueo para las cuentas solo con clave desde los primeros días de Unix. Funciona porque la comparación nunca puede tener éxito, salvo que la propia función de hash pueda producir un asterisco. Lo que nos lleva a la pieza dos.
La pieza dos es el manejo de errores de musl. Tradicionalmente, crypt() devuelve NULL cuando no puede hacer su trabajo, por ejemplo cuando la sal pide un algoritmo que el sistema no tiene. El problema es que durante décadas el software no comprobó nunca NULL y lo pasaba alegremente a strcmp, provocando un fallo de segmentación. Así que en 2012 musl tomó una decisión de diseño deliberada: su crypt() nunca devolvería NULL. En caso de fallo devolvería una cadena garantizada de no coincidir con ningún hash de contraseña real, para que los llamadores defectuosos vieran una contraseña incorrecta en lugar de estrellarse. La cadena de fallo que eligieron fue *. Tres semanas después, musl añadió sha512crypt con un límite de entrada de 256 bytes: el coste del hash escala con la longitud de la contraseña, así que las contraseñas gigantes son un vector de denegación de servicio. Se rechaza todo lo más largo, eso es un fallo, y eso devuelve *. El comentario que lo documentaba estuvo en el código todo el tiempo.
La pieza tres es sshd siendo amable. En 2016, CVE-2016-6210 demostró que podías enumerar nombres de usuario válidos midiendo los tiempos de respuesta: los usuarios inexistentes recibían un rechazo instantáneo porque no había hash que calcular. La solución fue elegante: para las cuentas sin hash utilizable, sshd toma prestada la sal de otra cuenta y hashea de todos modos la contraseña suministrada, solo para gastar el mismo tiempo. Un no más lento. La trampa: esta ruta no solo se activa con usuarios inexistentes. También se activa con usuarios reales cuyo campo en shadow es *, porque un asterisco no es un hash utilizable. Así que ahora sshd calcula crypt(tu_contraseña, sal_prestada) y compara el resultado con *.
/* 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 */
Cada elemento tiene que alinearse: el campo almacenado es *, crypt falla, y el valor de fallo también es *. En glibc, un crypt fallido devuelve NULL, lo que hace que la comparación falle de forma segura. En sistemas con libxcrypt, la cadena de fallo es *0: un carácter de diferencia, y ese carácter es todo el juego, porque *0 nunca es igual a *. En musl, el valor de error y el valor de bloqueo son el mismo carácter, y strcmp no tiene forma de distinguir un error de una coincidencia. Dos significados, un único canal, y el receptor no puede desambiguar.
Una década de decisiones perfectamente razonables
Lo que hace que este bug merezca estudio no es el mecanismo, sino que nadie implicado hizo nada obviamente estúpido. Empecemos en 1980: cuando los hashes de contraseña estaban en /etc/passwd, legible por todos, Bill Joy creó una cuenta llamada daemon a la que nadie debería entrar nunca, y puso un * donde iría el hash de su contraseña. Un centinela limpio, adelantado décadas a su tiempo. (Según se cuenta, el campo del nombre real decía «el mismísimo diablo».) Unix sigue bloqueando las cuentas exactamente de esta manera.
La decisión de musl de 2012 también parece defendible desde cualquier ángulo. Los llamadores que se caen son un problema real, y una biblioteca C que usa NULL como arma contra décadas de código de aplicación descuidado se gana muchos enemigos. Devolver una cadena que nunca puede coincidir es una degradación elegante. También lo es limitar la longitud de la contraseña para frenar la denegación de servicio basada en hash. También lo es el arreglo de tiempos de sshd: rellenar las respuestas para usuarios no hasheables es una práctica estándar. Incluso lo es lo que Alpine hizo después. En 2019, CVE-2019-5021 reveló que las imágenes Docker de Alpine se distribuían con el campo de contraseña de root vacío, en algunas configuraciones sin contraseña alguna. Alpine bloqueó root con !. Pero sin PAM, sshd trata ! como bloqueo total, lo que también mata los inicios de sesión con clave SSH, y las imágenes en la nube viven y mueren por los inicios de sesión con clave. Así que en 2022, Alpine 3.16 pasó root a *: sigue bloqueado, las claves funcionan, todos contentos. Excepto que * era justo la cadena que el crypt de musl podía ser engañado para imprimir.
Cada paso fue una respuesta defendible a un problema real. Eso es lo incómodo: podrías haber estado en la sala en cada decisión y haber asentido.
Los casi-accidentes son casi peores. Un desarrollador de Debian pidió una vez que glibc adoptara exactamente el comportamiento de * de musl; seis años después Debian pasó a libxcrypt, cuyo *0 cerró el agujero por casualidad. Un hilo de BusyBox se quejaba de que passwd se atragantaba con una contraseña de 257 caracteres: mismo asterisco, mismo límite, y nadie en el hilo dijo «sshd». Un revisor de seguridad parcheó la función pick_salt() de sshd con el bug real cuatro líneas más abajo, cuatro días antes de que alguien lo encontrara por fin. Esto se pasó por alto, una y otra vez, por personas que lo miraban casi directamente.

¿Está tu infraestructura afectada por el bug de OpenSSH en musl?
La gracia salvadora de sshole es que necesita que se cumplan todas las condiciones para explotarse. Si falla una, estás a salvo. Estás expuesto si todas estas son ciertas:
- Tu sshd está compilado contra musl: Alpine, OpenWrt, Buildroot, Yocto, o una imagen Docker derivada de
alpineconopenssh-serverinstalado. - sshd no pasa por PAM: el openssh-server de Alpine no lo hace por defecto, o has configurado
UsePAM no. La ruta de código de PAM nunca llama a crypt para una cuenta*. - La autenticación por contraseña está habilitada. Lo está por defecto.
- Al menos una cuenta tiene un
*desnudo en/etc/shadow: usuarios solo con clave, cuentas respaldadas por LDAP, root en Alpine. - Al menos otra cuenta tiene un hash de contraseña real, así que hay una sal que tomar prestada.
Si se cumplen las cinco, cada cuenta * de la máquina acepta cualquier contraseña de 257 a 1.024 caracteres. La pesadilla es la configuración de las imágenes oficiales de Alpine para AWS y Azure: el usuario por defecto alpine solo admite claves (es decir, *) y tiene doas sin contraseña. Un inicio de sesión con contraseña larga ahí no es una escalada de privilegios, es una puerta directa a root. Una mitigación merece crédito por haber mantenido esto en silencio tanto tiempo: PermitRootLogin de OpenSSH ha rechazado los inicios de sesión con contraseña para root desde 2015, así que el objetivo más obvio estaba parcialmente protegido por defecto. Nada protegía a los usuarios de despliegue ni a las cuentas de servicio.
Si usas Debian, Ubuntu, Fedora o cualquier otra distro basada en glibc, estás bien. Todo lo que pase por PAM: bien. Dropbear: bien. OpenBSD: bien. Una advertencia para los escépticos: los primeros intentos de la comunidad por reproducir esto han sido inconsistentes, porque las cinco condiciones son fáciles de pasar por alto una a una. Alguien prueba la imagen oficial de Alpine, no se da cuenta de que el campo de root es ! y no *, falla y sigue adelante. Trata «no lo he reproducido en mi portátil» como ninguna evidencia sobre producción. Comprueba las condiciones, no las sensaciones.
La causa raíz real: señalización in-band y cadenas mágicas
Es tentador archivar sshole bajo «caso raro de libc» y seguir adelante. El modo de fallo es más antiguo y más general que eso: la señalización in-band. El carácter * se reclutó para dos funciones: en /etc/shadow significa «no existe contraseña para esta cuenta», y en el crypt de musl significa «el hash falló». Dos mensajes compartiendo un mismo canal, sin forma de que el receptor los distinga. Los valores centinela incrustados en datos ordinarios son la forma en que los sistemas acaban explotándose a sí mismos. NULL era una señal de error aceptable (a pesar de los llamadores frágiles). Una excepción es mejor. Un Result<T, E> que obliga al llamador a gestionar el fallo explícitamente es lo mejor. La regla práctica, la que tatuaría en cada programador de sistemas: nada de cadenas mágicas. Si de verdad necesitas una, elige algo que no pueda colisionar con ningún valor del dominio del problema: el *0 de libxcrypt supera al * de musl por exactamente un byte, y ese byte es la diferencia entre un quirk y un CVE.
Hay una segunda lección sobre las capas de compatibilidad silenciosas. La decisión del * elegante de musl existe para evitar que el software roto se caiga, lo que significa que impide que el software roto llegue a notarse como roto. Un segfault es feo, pero también es una señal: alguien abre un bug, alguien arregla al llamador. Tragarse el error y el error se acumula en silencio durante una década. Cada vez estoy más convencido de que esta es la división más profunda en el diseño de sistemas: la ingeniería que resiste la catástrofe tiende a fallar en voz alta y pronto, mientras que la ingeniería que optimiza la suavidad acumula deuda oculta hasta que la factura llega toda de golpe.
Y debería desmontar un mito reconfortante: que el código antiguo y muy desplegado es más confiable porque ha sido probado en batalla. La edad no reparte el escrutinio de forma uniforme: lo concentra en las rutas calientes y deja que las frías se fosilicen. Todo el mundo audita la criptografía. Casi nadie audita el pegamento aburrido donde sshd habla con libc sobre contraseñas, porque ese código «ha funcionado siempre». La intersección de dos configuraciones minoritarias —sistemas musl y, dentro de ellos, los que no usan PAM— es exactamente el tipo de lugar donde un bug puede vivir sin ser molestado durante diez años.
Cómo corregir el bug sshole y revisar tus logs
El parche es la parte fácil: OpenSSH 10.6p2 cierra el agujero. En Alpine, es un solo comando. Si no puedes actualizar hoy, la mitigación es igual de directa y totalmente eficaz: desactiva la autenticación por contraseña. Sin comprobación de contraseña, no hay bug. Sinceramente, deberías hacer ambas cosas, porque la autenticación solo por clave ya era la respuesta correcta antes de este bug y lo seguirá siendo cuando todo el mundo haya parcheado. Recuerda una lección que hemos visto en otros debates entre parchear o mitigar: cuando la mitigación es barata y total, no te pongas listo.
# 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

Luego viene la pregunta más difícil: ¿alguien llegó primero? Diez años es mucho tiempo de permanencia. Lo que buscas son autenticaciones por contraseña exitosas para cuentas que nunca tuvieron contraseña: usuarios de despliegue solo con clave, cuentas de servicio, identidades LDAP, root en máquinas Alpine. Los inicios de sesión exitosos en esas cuentas mediante el método password deberían ser prácticamente inexistentes; cualquier resultado es una emergencia máxima. La incómoda verdad es que sshd registra el éxito, no la longitud de la contraseña, así que buena parte de la evidencia directa se evapora al escribirse. Y como ya hemos comentado, el registro de autenticación en la nube tiene puntos ciegos incluso en un buen día: los logs de sshd a nivel de instancia en VMs efímeras son justo donde viven. Revisa lo que tengas. Asume que está incompleto.
Lo que deberían enseñarnos diez años silenciosos
El descubrimiento en sí merece tanta atención como el bug. El investigador que encontró sshole no miró la criptografía: miró el límite aburrido donde OpenSSH habla con libc, compiló sshd en todas las configuraciones que se le ocurrieron (glibc, musl, PAM, sin PAM, shadow, sin shadow) y lanzó unos 17.600 inicios de sesión malformados contra la matriz. Hashes raros, cuentas rotas, contraseñas demasiado largas. Todas las compilaciones coincidieron en cada inicio de sesión salvo una: musl sin PAM. Esa dijo que sí a cosas que no debía. Eso no es un hallazgo afortunado, es cobertura. Las pruebas diferenciales sobre matrices de configuración son la forma de encontrar los bugs que se esconden en las intersecciones, y es exactamente el tipo de trabajo exhaustivo e incansable en el que los enjambres de agentes resultan inquietantemente buenos. El próximo sshole no tendrá diez años tranquilos.
La pregunta abierta que debería quitarles el sueño a los operadores es si esos diez años pasaron de verdad sin que nadie lo notara, o si lo notaron las personas equivocadas. Piensa solo en el caso accidental: pegar el contenido equivocado del portapapeles en un prompt de contraseña (medio hilo de Slack, una clave privada SSH entera, cualquier cosa de más de 257 caracteres) y, en una máquina vulnerable, una cuenta bloqueada simplemente se abre. Si nunca has pegado algo por error en un campo de contraseña, eres más cuidadoso que cualquiera con quien haya trabajado. Alguien, en algún sitio, ha entrado en una máquina en la que no quería entrar y ha cerrado la terminal en silencio. Quizá muchos alguien.
Así que parchea, sí. Desactiva las contraseñas, por supuesto. Pero luego dedica una hora a buscar en tus propios sistemas los caracteres que hacen dos trabajos: los valores centinela, los fallbacks silenciosos, las ramas de «esto nunca puede pasar», las capas de compatibilidad que nadie recuerda haber escrito. Toda base de código madura las tiene. Son invisibles precisamente porque nunca han causado un problema que alguien pudiera ver. En algún lugar de tu stack, ahora mismo, hay un asterisco significando dos cosas a la vez en silencio. Búscalo antes de que alguien con una contraseña muy larga lo haga por ti.


