Resumen
El fallo está en el cliente, no en el servidor: mariadb-connector-r2dbc (versión 1.4.1, HEAD a82d265, y según el investigador todas las versiones publicadas) implementa el plugin de autenticación Parsec, que deriva la clave a partir de la contraseña con PBKDF2-HMAC-SHA512. El número de rondas no lo elige el cliente: lo calcula como 1024 << iterations, donde iterations es un byte que llega en la respuesta ext-salt del servidor.
La única comprobación es que ese byte no supere 20. Con iterations = 20, el cliente tiene que calcular 1024 << 20 = 1.073.741.824 rondas antes de terminar el handshake, lo que supone unos 25 minutos de un núcleo de CPU por cada intento de conexión.
Es una regresión: el commit b7766de cambió el límite de > 3 (máximo de 8.192 rondas) a > 20.
El código afectado está en src/main/java/org/mariadb/r2dbc/authentication/standard/ParsecPasswordPlugin.java:
64 if (buf.readableBytes() > 2) {
65 firstByte = buf.readByte();
66 iterations = buf.readByte(); // fully server-controlled
67 }
68
69 if (firstByte != 0x50 || iterations > 20) { // only upper bound
70 throw new R2dbcNonTransientResourceException(...);
71 }
...
104 PBEKeySpec spec = new PBEKeySpec(password, salt, 1024 << iterations, 256);
105 SecretKey key = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512").generateSecret(spec);
Varias condiciones hacen que el ataque sea fácil de montar:
- El plugin
parsecestá registrado enMETA-INF/servicesy es el servidor quien elige el plugin durante el handshake. restrictedAuthvalenullpor defecto, así que el cliente no tiene ninguna lista de plugins permitidos y ejecutaparsecsiempre que el servidor lo pida.- Todo ocurre antes de completar la autenticación.
- El conector usa por defecto
sslMode=DISABLE, es decir, texto plano, de modo que un atacante con posición de MITM en la red puede reescribir ese byte.
Pasos de reproducción
Entorno usado: JDK 21 y el propio proyecto compilado con mvn -o -DskipTests package, que genera r2dbc-mariadb-1.4.1.jar.
Prueba 1: servidor MariaDB falso de extremo a extremo (RogueParsecServer.java, adjunto al reporte original). El servidor habla el protocolo de MariaDB hasta el intercambio ext-salt de Parsec y envía un byte iterations elegido por el atacante; mide cuánto tiempo pasa el MariadbConnectionFactory.create() real calculando PBKDF2. La única diferencia entre la ejecución de control y la de ataque es ese byte.
- Compilar y lanzar el servidor falso:
javac -cp r2dbc-mariadb-1.4.1.jar:reactor-core.jar:r2dbc-spi.jar RogueParsecServer.java
java -Djava.security.egd=file:/dev/urandom -cp .:r2dbc-mariadb-1.4.1.jar:reactor-core.jar:r2dbc-spi.jar:reactive-streams.jar:netty-*.jar RogueParsecServer
- Comparar la salida del caso de control (
iterations=3) con el de ataque (iterations=15):
[neg control] ext-salt iterations=3 (1024<<3 = 8192 rounds):
server saw client stuck 160 ms ; client create() returned in 1148 ms
[attack] ext-salt iterations=15 (1024<<15 = 33554432 rounds):
server saw client stuck 47286 ms ; client create() returned in 47604 ms
at the maximum allowed iterations=20 this is 1024<<20 = 1073741824 rounds -> ~25 min.
(real driver logs "Authentication success" -- the full handshake completes after the delay)
Con un solo byte distinto, la conexión quedó bloqueada unos 47 segundos frente a 160 ms, 296 veces más. El driver acaba registrando "Authentication success": no falla, simplemente tarda.
Prueba 2: medición directa del coste (ParsecPoc.java, también adjunto). Ejecuta el mismo cálculo de las líneas 104-105 con distintos exponentes y confirma que cada unidad más duplica el tiempo:
iterations rounds wall_ms
3 8192 231 <- negative control (old cap > 3)
10 1048576 1518
13 8388608 11745
15 33554432 45113
extrapolation to iterations=20 (rounds=1073741824): ~1503360 ms (~25.1 min)
Llamando al ParsecPasswordPlugin.next() real con iterations=13, el cálculo tardó 11.753 ms, 81 veces más que los 145 ms del control con iterations=3.
Impacto
- Un servidor MariaDB malicioso, o un atacante en medio de la conexión, puede dejar un núcleo de CPU del cliente ocupado unos 25 minutos por cada intento de conexión, sin credenciales válidas y tantas veces como quiera.
- Si la aplicación usa un pool de conexiones que reintenta las conexiones fallidas, el efecto se multiplica hasta agotar la CPU de toda la aplicación y provocar una denegación de servicio sostenida.
- La amplificación es enorme: un byte elegido por el atacante obliga a calcular unos 1.070 millones de rondas de PBKDF2-HMAC-SHA512.
- Al ser
sslMode=DISABLEel valor por defecto, no hace falta controlar el servidor: basta con poder manipular el tráfico de red.
En HackerOne el reporte figura con severidad media. El investigador cita como precedente un reporte similar aceptado sobre mariadb-connector-nodejs (#3835450, también de severidad media).
Remediación
El reporte figura como resuelto en HackerOne. La corrección que propone el investigador es volver a poner un límite pequeño y fijo al exponente de iteraciones (por ejemplo, rechazar iterations > 3), como antes del commit b7766de. El reporte no detalla qué cambio exacto se aplicó finalmente.
Qué aprender de este caso
- En cualquier cliente de base de datos, revisa los plugins de autenticación que leen parámetros de coste (rondas de PBKDF2, factores de scrypt o bcrypt, tamaños de sal) de la respuesta del servidor: si el exponente o el número de rondas viene del otro extremo sin un tope bajo, el servidor decide cuánta CPU gastas tú.
- Cuando un valor se usa como desplazamiento (
1024 << n), el límite hay que pensarlo en términos del resultado, no del byte: pasar de> 3a> 20parece un cambio pequeño y multiplica el coste máximo por más de 130.000. - Comprueba qué plugins de autenticación acepta el cliente si el servidor los pide: con una lista de permitidos vacía por defecto (
restrictedAuth = null), cualquier plugin registrado enMETA-INF/servicespasa a ser superficie de ataque. - Al usar este tipo de conectores, activar TLS con verificación del servidor y fijar los plugins permitidos limita el ataque a quien ya controle el servidor; con
sslMode=DISABLEbasta con estar en la red.