Una contraseña incorrecta debería producir un rechazo. Pero ¿qué ocurre si el componente que la verifica devuelve un error y la aplicación interpreta ese error como una coincidencia válida?
Ese es el problema que hace interesante SSHHole, CVE-2026-123158, divulgado el 5 de noviembre de 2026. La investigación de Özgün Kültekin, de Wakefield, describe una combinación de decisiones históricas que permitía eludir la autenticación por contraseña en determinadas configuraciones de OpenSSH. No se rompió un algoritmo criptográfico: se aceptó como prueba de identidad un resultado que no lo era. Investigación original de SSHHole.
OpenSSH implementa SSH, utilizado para acceder de forma segura a máquinas remotas y transferir archivos. Su servidor, sshd, incluye distintas rutas de autenticación. OpenSSH publicó 10.6p2 ese mismo día para evitar este bypass en sistemas Linux con musl y PAM deshabilitado. La fecha de publicación de la corrección no debe confundirse con el momento del descubrimiento privado; las notas reconocen al investigador, pero no establecen esa fecha. Notas oficiales de OpenSSH.
Vamos a utilizar el caso para estudiar un problema más amplio: cómo representar, interpretar y probar los resultados de una verificación de seguridad. Después construiremos un laboratorio pequeño en TypeScript, sin instalar OpenSSH vulnerable ni realizar conexiones contra terceros.
Identificar, autenticar y autorizar no son lo mismo
Estas tres decisiones suelen aparecer juntas, pero responden preguntas distintas:
| Operación | Pregunta | Ejemplo |
|---|---|---|
| Identificación | ¿Qué identidad afirmas tener? | Enviar un nombre de usuario. |
| Autenticación | ¿Qué evidencia demuestra esa identidad? | Verificar una contraseña o una credencial firmada. |
| Autorización | ¿Qué puede hacer esa identidad sobre este recurso? | Permitir que un supervisor apruebe una solicitud concreta. |
Un authentication bypass, o evasión de autenticación, ocurre cuando se obtiene acceso sin satisfacer la comprobación de identidad requerida. La aplicación puede incluso registrar una sesión aparentemente normal: que el código haya tomado la rama de éxito no significa que la credencial fuera válida.
Imagina un verificador que responde «no pude comprobarlo». Si la aplicación traduce eso como «no dijo que fuera incorrecto, así que lo acepto», ha convertido la ausencia de una prueba en una prueba positiva.
Tampoco basta con comprobar que un token tiene la estructura esperada. En un JWT firmado hay que verificar la firma y las condiciones pertinentes, como emisor, audiencia y expiración; decodificar un JWT no equivale a validarlo. Y una identidad correctamente autenticada todavía necesita autorización por recurso.
Un valor de error puede parecer un dato válido
Un valor centinela es un valor reservado para comunicar un estado especial dentro de una interfaz. No es necesariamente un error de diseño, pero su significado debe quedar claro para quien lo recibe.
| Valor | Uso posible | Supuesto peligroso |
|---|---|---|
NULL en C o null en JavaScript | Ausencia de resultado. | «Si no lanzó una excepción, funcionó». |
'' | Campo vacío o resultado ausente. | Reemplazar dos ausencias por '' y aceptar su igualdad. |
'*' | Marcador especial, según el contrato. | Tratar cualquier cadena devuelta como un hash. |
0, 1, -1 | Código de retorno. | Interpretarlo como booleano sin leer qué significa cada código. |
false | Rechazo explícito. | Suponer que todo lo distinto de false representa éxito. |
NULL y null pertenecen a lenguajes y modelos diferentes; aquí se parecen por su papel de representar ausencia, no porque sean intercambiables.
Esta comparación didáctica devuelve true:
const storedMarker = '*';
const computationError = '*';
const incorrectlyAccepted = storedMarker === computationError;El operador de igualdad hizo lo correcto. La decisión de seguridad no: ninguno de los operandos demostraba una autenticación válida. Cambiar == por === no corrige un contrato semántico equivocado.
El problema también aparece con valores predeterminados: si un identificador esperado y otro recibido están ausentes, compararlos mediante (received ?? '') === (expected ?? '') transforma dos ausencias en una coincidencia. Primero hay que establecer que ambos valores son válidos para la operación; después tiene sentido compararlos.
SSHHole: cómo encajaron las piezas
El campo de contraseña no siempre contiene un hash
En sistemas que lo utilizan, /etc/shadow almacena información de contraseñas y caducidad de cuentas. Su lectura debe estar restringida. El campo correspondiente puede contener un hash, pero también marcadores de estado. Un * no es una contraseña vacía ni un hash válido: debería impedir iniciar sesión mediante una contraseña UNIX, aunque pueden existir otros mecanismos de acceso. Un campo vacío tiene otra semántica. Manual de shadow(5).
La función histórica crypt() calcula una representación de contraseña utilizando un parámetro que selecciona el algoritmo y su salt. El salt introduce un valor específico en el cálculo; no es una clave secreta. Los prefijos $5$ y $6$ identifican las familias SHA-256-crypt y SHA-512-crypt: no estamos hablando de aplicar una sola vez SHA-256 o SHA-512 al texto. El manual documenta NULL como retorno de error y advierte que una distribución puede utilizar libxcrypt en lugar de la implementación de glibc. No hay que asumir un contrato idéntico para todas esas variantes. Manual de crypt(3).
No conviene trasladar esta interfaz histórica a una aplicación nueva como receta. Para ese problema ya vimos cómo almacenar y verificar contraseñas con Argon2id.
Una señal de error que coincidía con un marcador
musl y glibc son implementaciones distintas de la biblioteca estándar de C. No basta con saber que una máquina ejecuta Linux para inferir qué comportamiento tendrá una función, qué biblioteca proporciona crypt() o si la autenticación pasa por PAM, el sistema de módulos de autenticación.
En agosto de 2012, musl cambió el manejo de ciertos errores de crypt() para evitar devolver un puntero nulo a programas que podían usarlo sin comprobarlo. La intención era devolver una cadena que no coincidiera con un hash válido. Esa intención dependía de que el llamador distinguiera un hash de un marcador. Cambio de musl del 9 de agosto de 2012.
Ese mes también se añadieron las implementaciones SHA-256-crypt y SHA-512-crypt con un límite de 256 bytes de contraseña para acotar el coste de cálculo. En las rutas implicadas, superar ese límite devuelve *. Es una propiedad de esas implementaciones y su manejo de errores, no una debilidad criptográfica de SHA-256. Implementación de musl de agosto de 2012.
La mitigación de enumeración aportó otra condición
En 2016, OpenSSH mitigó CVE-2016-6210, un problema de enumeración de usuarios mediante diferencias de tiempo. Una ruta para usuarios inexistentes podía ejecutar un hash distinto o fallar muy rápido. El cambio buscaba seleccionar parámetros compatibles con los empleados por cuentas reales; inicialmente consultaba la cuenta root. Además, el código pasaba a elegir parámetros alternativos cuando no recibía un campo utilizable como salt. Cambio original de OpenSSH de julio de 2016.
En el recorrido descrito por SSHHole, un campo *, demasiado corto para proporcionar esos parámetros, acaba utilizando los de otra cuenta. Si el cálculo falla y devuelve también *, la comparación contra el campo almacenado coincide. El investigador menciona además la relevancia de un intento previo dentro de la secuencia: el estado entre llamadas también forma parte del análisis, no solo una comparación aislada. Análisis original.
Una cronología corta permite ver el problema de composición:
| Momento | Decisión o acontecimiento | Supuesto que importa |
|---|---|---|
| Agosto de 2012 | musl evita ciertos retornos nulos y limita la longitud en SHA-crypt. | Un marcador de error no debería aceptarse como resultado válido. |
| Julio de 2016 | OpenSSH ajusta el cálculo para reducir diferencias de tiempo entre cuentas. | Calcular algo parecido para igualar costes no debe habilitar el acceso. |
| Noviembre de 2026 | Se divulga SSHHole y se publica OpenSSH 10.6p2. | La validación debe considerar el contrato entre componentes. |
Qué configuraciones estaban afectadas
El aviso de Alpine exige la combinación de OpenSSH vulnerable, ruta sin PAM —o UsePAM no—, PasswordAuthentication yes, una cuenta objetivo con * y otra cuenta local con hash $5$ o $6$. El rango implicado es 257 a 1024 bytes. Además, las políticas de acceso deben permitir entrar a esa cuenta. Un contenedor sin sshd o la ruta con PAM habilitado no quedan afectados por este mecanismo. Aviso oficial de Alpine.
No es correcto extender ese resultado a todos los servidores Linux, a toda instalación de SSH o a cualquier contraseña larga. El mantenedor de musl señala que Dropbear no está afectado. Para una máquina concreta hay que identificar biblioteca, compilación, configuración y parches, no deducir su exposición únicamente del nombre de la distribución. Aviso de musl.
Qué cambió la corrección
El parche de OpenSSH añade una condición explícita: el campo almacenado debe tener al menos la longitud mínima prevista para una representación de hash antes de aceptar la igualdad. Con ello, el marcador de un carácter deja de ser candidato a una autenticación válida. También ajusta la gestión de copias y memoria de esos valores. La constante de cuatro caracteres introducida ahí no es una recomendación de longitud mínima de contraseña para nuestras aplicaciones. Parche oficial.
Hay correcciones retroportadas: Alpine lista, por ejemplo, 10.3_p1-r2 para 3.24 y 10.2_p1-r1 para 3.23. Por eso una versión inferior a 10.6p2 no demuestra por sí sola que falte el parche. Actualizar requiere también reiniciar sshd. La mitigación temporal de deshabilitar contraseñas exige comprobar antes el acceso por clave y conservar una sesión de recuperación. Versiones y procedimiento de Alpine.
La atribución de la causa raíz tampoco es unánime. OpenSSH la sitúa fuera de sshd, aunque la evita allí; Rich Felker sostiene que se trata de un uso incorrecto por el llamador. Son posiciones de sus mantenedores. La lección de ingeniería que extraemos es que la seguridad depende del contrato completo, aunque cada componente tenga una justificación para su comportamiento. OpenSSH, musl.
Laboratorio TypeScript: aceptar solo un éxito explícito
El siguiente ejemplo es independiente de OpenSSH. No implementa contraseñas ni JWT: prueba cómo nuestra aplicación interpreta el resultado de un adaptador de verificación.
El contrato ficticio del adaptador es:
{ verified: true, subject: 'user_demo' }: credencial verificada e identidad obtenida por el servidor.false: credencial rechazada.- Excepción: no pudo realizarse la comprobación.
- Cualquier otro resultado: incumplimiento del contrato, que tampoco concede acceso.
El adaptador debe ejecutarse en el servidor y utilizar un verificador real. No puede devolver lo que envíe el cliente en un campo verified, ni limitarse a decodificar un token. Los mocks de este laboratorio solo permiten probar nuestras decisiones ante cada respuesta.
Preparación
Utiliza Node.js 24 y pnpm. En una carpeta nueva, crea este package.json:
{
"name": "auth-validation-lab",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
}
}Instala las versiones utilizadas en el ejemplo:
pnpm add -D -E vitest@5.1.0 typescript@7.0.2 @types/node@24.19.2Añade tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["*.ts"]
}vitest run ejecuta las pruebas una vez; la comprobación de TypeScript se hace por separado. Guía oficial de Vitest.
La versión vulnerable y su reemplazo
Guarda este archivo como auth.ts:
import { Buffer } from 'node:buffer';
export type Verify = (token: string) => Promise<unknown>;
export type AuthResult =
| { status: 'authenticated'; subject: string }
| { status: 'rejected' }
| { status: 'unavailable' };
// Intencionalmente vulnerable: únicamente para comparar en el laboratorio.
export async function authenticateUnsafe(token: string, verify: Verify) {
try {
const result = await verify(token);
return result !== false;
} catch {
return true;
}
}
export async function authenticate(
token: unknown,
verify: Verify,
): Promise<AuthResult> {
if (
typeof token !== 'string' ||
token.trim().length === 0 ||
Buffer.byteLength(token, 'utf8') > 4096
) {
return { status: 'rejected' };
}
try {
const result = await verify(token);
if (result === false) {
return { status: 'rejected' };
}
if (
typeof result !== 'object' ||
result === null ||
!('verified' in result) ||
result.verified !== true ||
!('subject' in result) ||
typeof result.subject !== 'string' ||
!/^user_[a-z0-9]{1,32}$/.test(result.subject)
) {
return { status: 'unavailable' };
}
return { status: 'authenticated', subject: result.subject };
} catch {
return { status: 'unavailable' };
}
}La versión vulnerable contiene dos fallos distintos. result !== false acepta null, undefined, '*' y objetos inesperados; el catch permite entrar cuando el proveedor falla. El contrato real queda sustituido por una suposición demasiado amplia.
La versión corregida comprueba la entrada, espera al verificador y admite únicamente el estado de éxito documentado. unknown obliga a inspeccionar el resultado en runtime: una anotación de TypeScript no hace que una respuesta externa sea válida. Es el mismo límite que explicamos al hablar de validación con Zod.
El máximo de 4096 bytes y el formato user_... son decisiones de este laboratorio, no requisitos universales para tokens o identificadores. El tamaño debe corresponder al protocolo real; también debe limitarse la petición antes de cargarla completa en memoria. No truncamos la credencial ni la modificamos antes de verificarla.
AuthResult es una unión discriminada: status identifica qué variante existe y subject solo aparece en la autenticada. Esto reduce estados contradictorios como { success: true, error: 'timeout' }, aunque no sustituye la comprobación runtime de los datos. Uniones discriminadas en TypeScript.
Fail-open y fail-closed: qué hacer cuando no puedes verificar
Fail-open permite continuar cuando falla el control. En nuestro ejemplo, devolver true desde el catch hace que una caída del proveedor se transforme en acceso.
Fail-closed no concede acceso si no pudo demostrar que se cumplieron las condiciones. No obliga a confundir una avería con una contraseña incorrecta: podemos conservar estados internos distintos y mostrar respuestas externas apropiadas.
No se pudo mostrar el diagrama. Puedes consultar su definición.
flowchart TD
A[Recibir credencial] --> B{Entrada válida}
B -->|No| R[Rechazar sin crear sesión]
B -->|Sí| C[Ejecutar verificador]
C --> D{Resultado}
D -->|Éxito explícito| E[Identidad autenticada]
E --> F[Comprobar autorización por recurso]
D -->|Rechazo| R
D -->|Error o contrato inesperado| U[No conceder acceso y registrar incidente]
Una capa HTTP podría responder con rechazo de autenticación ante rejected y con un error temporal de servicio ante unavailable, sin crear una sesión en ninguno de los dos casos. Esa distinción no debe revelar si existe una cuenta concreta: los errores públicos y los tiempos de respuesta necesitan revisión conjunta. OWASP recomienda mensajes genéricos para evitar enumeración de usuarios. Authentication Cheat Sheet.
Cerrar el acceso ante un error protege esa decisión, pero puede degradar disponibilidad. El diseño necesita tiempos máximos de espera en el adaptador, cancelación cuando la dependencia la soporte, límites de concurrencia y recuperación operacional. No implementamos esos controles de transporte en una función cuyo verificador es un mock local. Tampoco proponemos dejar pasar a todos para conservar disponibilidad.
Un modo degradado, si existe, debe definir de antemano qué acciones públicas siguen disponibles y qué evidencia vigente se exige para las demás. No debería surgir accidentalmente de un catch.
Registrar el fallo sin registrar la credencial
Conviene registrar el tipo de resultado, un identificador de petición generado por el servidor, el adaptador utilizado y un código interno acotado. Evita volcar el token, la contraseña, Authorization, cookies, cuerpos completos o excepciones de terceros sin sanitizar: una excepción también puede contener secretos. Restringe el acceso y la retención de esos registros. Logging Cheat Sheet.
El cliente no necesita recibir el stack trace para saber que hubo un fallo temporal. Los detalles útiles para investigar deben permanecer del lado del servidor, con el tratamiento adecuado. Error Handling Cheat Sheet.
Pruebas negativas: comprobar lo que nunca debe aceptarse
Guarda el siguiente archivo como auth.test.ts. Usa respuestas ficticias, sin red ni credenciales reales:
import { describe, expect, test, vi } from 'vitest';
import { authenticate, authenticateUnsafe } from './auth.js';
const validResult = { verified: true, subject: 'user_demo' };
const unexpectedResults = [
null,
undefined,
'',
'*',
true,
'true',
{},
{ verified: 'true', subject: 'user_demo' },
{ verified: true, subject: '' },
{ verified: true, subject: 'admin' },
];
describe('contrato de autenticación', () => {
test.each(unexpectedResults)('no acepta el resultado inesperado %j', async (value) => {
const verify = vi.fn().mockResolvedValue(value);
await expect(authenticateUnsafe('token-ficticio', verify)).resolves.toBe(true);
await expect(authenticate('token-ficticio', verify)).resolves.toEqual({
status: 'unavailable',
});
});
test('una excepción no concede acceso', async () => {
const verify = vi.fn().mockRejectedValue(new Error('proveedor no disponible'));
await expect(authenticateUnsafe('token-ficticio', verify)).resolves.toBe(true);
await expect(authenticate('token-ficticio', verify)).resolves.toEqual({
status: 'unavailable',
});
});
test('acepta únicamente un resultado de éxito explícito', async () => {
const verify = vi.fn().mockResolvedValue(validResult);
await expect(authenticate('token-ficticio', verify)).resolves.toEqual({
status: 'authenticated',
subject: 'user_demo',
});
});
test('distingue una credencial rechazada de una avería', async () => {
const verify = vi.fn().mockResolvedValue(false);
await expect(authenticate('token-ficticio', verify)).resolves.toEqual({
status: 'rejected',
});
});
test.each([null, undefined, 42, '', ' ', 'a'.repeat(4097), 'á'.repeat(2049)])(
'rechaza entradas inválidas sin invocar el verificador: %j',
async (token) => {
const verify = vi.fn().mockResolvedValue(validResult);
await expect(authenticate(token, verify)).resolves.toEqual({ status: 'rejected' });
expect(verify).not.toHaveBeenCalled();
},
);
test.each(['a'.repeat(4096), 'á'.repeat(2048)])(
'el límite se cuenta en bytes UTF-8',
async (token) => {
const verify = vi.fn().mockResolvedValue(false);
await expect(authenticate(token, verify)).resolves.toEqual({ status: 'rejected' });
expect(verify).toHaveBeenCalledWith(token);
},
);
});Ejecuta:
pnpm typecheck
pnpm testEn la ejecución local del ejemplo, la comprobación de tipos terminó sin errores y pasaron las 22 pruebas. Las pruebas comparativas pasan porque confirman dos cosas: la función vulnerable acepta respuestas que debería rechazar y su reemplazo no las acepta. No significa que la primera sea segura.
test.each permite ejecutar una misma propiedad sobre varios casos y los mocks controlan qué devuelve o lanza la dependencia. Las aserciones asíncronas se esperan con await. API oficial de Vitest.
La cadena 'á'.repeat(2049) tiene menos de 4096 caracteres, pero supera 4096 bytes en UTF-8. Por eso las pruebas de límites necesitan definir la unidad. Las pruebas de 4096 bytes confirman que el verificador sí recibe la entrada; no que una cadena de ese tamaño sea una credencial válida.
Para limpiar, elimina únicamente la carpeta de este laboratorio. No crea servicios, contenedores, cuentas ni archivos del sistema.
Lo que los mocks no demuestran
Estas pruebas cubren nuestra interpretación del contrato. No prueban la firma de un JWT, la verificación de una contraseña, la disponibilidad de un proveedor ni el comportamiento de OpenSSH.
Antes de producción, añade pruebas con el adaptador real: credencial válida, expirada, firma incorrecta, clave desconocida, emisor o audiencia incorrectos cuando correspondan, dependencia indisponible y respuesta inesperada. Repite las pruebas tras actualizar bibliotecas y cuando cambie la plataforma o la configuración de autenticación.
También importa probar secuencias: un rechazo seguido de una comprobación válida, errores repetidos o cambios de configuración. Un estado almacenado entre llamadas puede invalidar las conclusiones de un test que siempre empieza desde cero.
El fuzz testing amplía la búsqueda generando entradas variadas —tipos, tamaños, codificaciones y combinaciones— para detectar comportamientos no previstos. Una propiedad útil aquí sería: «ninguna entrada ni respuesta inesperada puede producir authenticated». Los casos encontrados deben convertirse en regresiones reproducibles. La tabla de casos anterior es testing dirigido, no una campaña de fuzzing; en este laboratorio no ejecutamos una.
Diseñar el contrato, no solo la comparación
Un diseño de autenticación necesita decisiones explícitas en varios límites:
- Entrada: acepta únicamente tipos, tamaños y formatos definidos. No conviertas silenciosamente una ausencia en una credencial por defecto.
- Verificador: utiliza la función de verificación de la biblioteca, con sus parámetros de seguridad. No reemplaces Argon2 por una comparación casera ni la verificación de JWT por una lectura del payload.
- Resultado: separa éxito, rechazo y error. Documenta qué devuelve y qué lanza cada dependencia, también en límites y configuraciones no habituales.
- Sesión y permisos: crea la sesión solo después del éxito y aplica mínimo privilegio. Autenticarse no otorga automáticamente acceso a todos los recursos.
- Operación: protege secretos, limita intentos y costes, observa fallos y prepara una recuperación que no desactive controles sin una decisión revisada.
Estas son recomendaciones de diseño, no una afirmación de cumplimiento normativo. Tampoco las 22 pruebas constituyen una auditoría completa. Los controles complementarios de una API se desarrollan en cómo proteger una API REST en Node.js.
SSHHole muestra por qué revisar componentes por separado no basta. Evitar un crash, acotar un cálculo costoso y reducir diferencias de tiempo son objetivos razonables; una integración puede combinar sus resultados de forma insegura. Un cambio de seguridad merece revisar no solo lo que elimina, sino también qué supuestos nuevos exige a sus llamadores.
Que una vulnerabilidad se haya divulgado ahora tampoco establece cuánto tiempo fue conocida por terceros ni demuestra explotación previa. El historial permite reconstruir código y condiciones; no convierte las hipótesis sobre su permanencia oculta en hechos observados.
La próxima revisión empieza por las ramas de error
En una revisión real, empieza por las funciones que crean sesiones o deciden si una petición continúa. Para cada una, pregunta qué ocurre si la dependencia devuelve null, un formato inesperado, un rechazo o una excepción. Sigue esos resultados hasta la decisión final: un control correcto puede perderse varias capas después.
Documenta el contrato y añade una prueba de regresión por cada ambigüedad encontrada. Después revisa las versiones y avisos de las dependencias, prueba las configuraciones desplegadas y asigna responsables para investigar anomalías. Interpretar un reporte de dependencias ayuda, pero no reemplaza comprobar cómo utiliza tu aplicación esa biblioteca.
La propiedad que buscamos conservar es concreta: solo una verificación válida y explícita puede producir una identidad autenticada. Los rechazos y los errores pueden necesitar respuestas distintas; ninguno debe convertirse por accidente en permiso para entrar.
Referencias
Información del caso contrastada el 6 de noviembre de 2026. Los avisos de distribución pueden incorporar nuevas revisiones y aclaraciones.
- Investigación original: SSHHole.
- Registro CVE-2026-123158 en CVEProject.
- OpenSSH 10.6p2: notas de publicación y parche de autenticación.
- Alpine: aviso, condiciones y versiones corregidas.
- musl: aviso de su mantenedor, manejo de errores de 2012 e implementaciones SHA-crypt.
- OpenSSH: mitigación de CVE-2016-6210.
- shadow(5) y crypt(3).
- OWASP Authentication Cheat Sheet, Error Handling y Logging.
- TypeScript: uniones discriminadas.
- Vitest: instalación y API de pruebas.
