Resumen
El fallo está en el cliente, no en el servidor: en MariaDB Connector/ODBC, el driver que cargan las aplicaciones para hablar con MariaDB por ODBC. Las funciones SQLGetDescRec y SQLGetDescRecW reciben un descriptor (SQLHDESC), pero al empezar limpian su registro de error con la macro pensada para sentencias. Es una confusión de tipos (CWE-843): el driver trata un objeto MADB_Desc como si fuera un MADB_Stmt, que es más grande y tiene el campo de error en otra posición.
El error está en driver/odbc_3_api.cpp, líneas 855 y 874:
SQLRETURN SQL_API SQLGetDescRec(SQLHDESC DescriptorHandle, ...)
{
CHECK_STMT_CLEAR_ERROR(DescriptorHandle);
return MADB_DescGetRec(DescriptorHandle, ...);
Justo encima, SQLGetDescField (línea 824) y SQLGetDescFieldW (línea 837) hacen lo correcto con el mismo parámetro: usan CHECK_DESC_CLEAR_ERROR.
Por qué acaba escribiendo fuera de la memoria reservada
CHECK_STMT_CLEAR_ERROR termina en MA_ClearError(SQL_HANDLE_STMT, handle), que ejecuta MADB_CLEAR_ERROR(&((MADB_Stmt*)Handle)->Error) en driver/ma_api_internal.cpp:58. Según la información de depuración del driver compilado:
sizeof(MADB_Desc) = 720
offsetof(MADB_Desc, Error) = 96
offsetof(MADB_Stmt, Error) = 192
offsetof(MADB_Error, SqlErrorMsg) = 24
offsetof(MADB_Error, SqlState) = 537
Es decir, el driver interpreta desc + 192 como una estructura MADB_Error. La macro (driver/ma_error.h:166) hace dos escrituras:
#define MADB_CLEAR_ERROR(a) do { \
(a)->SqlState[0]= (a)->SqlState[1]= (a)->SqlState[2]= (a)->SqlState[3]= (a)->SqlState[4]= '0'; \
(a)->SqlErrorMsg[(a)->PrefixLen]= 0; \
- Desbordamiento fijo. Los cinco bytes de
SqlStatecaen endesc + 729adesc + 733, más allá de los 720 bytes reservados. Ocurre en todas las llamadas, sin que el servidor intervenga. - Escritura con desplazamiento controlado por el servidor.
PrefixLense lee dedesc + 192, que en realidad está dentro del bufferError.SqlErrorMsgdel propio descriptor, en el índice 72. Ese buffer lo rellenaMADB_DescInit(driver/ma_desc.cpp:96) medianteMADB_PutErrorPrefixcon"[ma-<version>][" + mysql_get_server_info() + "]". El prefijo fijo ocupa 12 bytes, así que si el servidor anuncia una versión de 61 bytes o más, los bytes 72-79 los decide él, y se usan como elsize_tdonde se escribe un cero.
Además, con un DSN por defecto el driver no exige TLS: solo cifra si se fija alguna opción SSL explícitamente (driver/ma_connection.cpp:778). En una conexión en claro, quien esté en medio puede manipular el saludo inicial (handshake).
El investigador lo confirmó sobre master en el commit 4e175d9 (MARIADB_ODBC_VERSION 03.02.0010), contra MariaDB 11.4.12 y unixODBC 2.3.14, sin modificar el directorio driver/.
Pasos de reproducción
- Compilar el driver desde
master(4e175d9) con AddressSanitizer:
cmake .. -DCMAKE_C_FLAGS="-fsanitize=address -g" -DCMAKE_CXX_FLAGS="-fsanitize=address -g" -DWITH_SSL=OPENSSL -DWITH_EXTERNAL_ZLIB=OFF -DWITH_UNIT_TESTS=OFF && make
- Conectar a cualquier servidor MariaDB con un DSN sin cifrado, por ejemplo:
DRIVER=<built libmaodbc.so>;SERVER=127.0.0.1;PORT=3306;UID=root;PWD=...;DATABASE=test;SSLVERIFY=0;
- Reservar un descriptor explícito y consultar un registro:
SQLAllocHandle(SQL_HANDLE_DESC, dbc, &desc);
SQLGetDescRec(desc, 1, name, sizeof(name), &namelen, &type, &subtype, &length, &prec, &scale, &nullable);
AddressSanitizer informa de un desbordamiento de heap en escritura, 13 bytes después del bloque de 720 bytes:
==15407==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1
#0 MA_ClearError driver/ma_api_internal.cpp:58
#1 SQLGetDescRec driver/odbc_3_api.cpp:855
0x7305ae4e185d is located 13 bytes after 720-byte region [0x7305ae4e1580,0x7305ae4e1850)
allocated by MADB_DescInit driver/ma_desc.cpp:92
- Repetir el paso 3 con una compilación normal, sin sanitizer, contra el mismo servidor legítimo: el bloque vecino del heap queda corrupto y glibc aborta el proceso (código de salida 134).
- Arrancar el MITM del investigador (
mitm_version.py, unas 100 líneas, que solo reescribe la cadena de versión del handshake y reenvía el resto sin tocarlo) y repetir el paso 4 apuntando el DSN al puerto 14001:
mitm_version.py 14001 3306 100
Con una versión de 100 bytes cuyos ocho bytes en el índice 72 son MMMMMMMM, el proceso cae así:
Program received signal SIGSEGV
MA_ClearError (...) at driver/ma_api_internal.cpp:58
=> 0x7ffff7ca11a8 <MA_ClearError+152>: mov BYTE PTR [rsi+rax*1+0xd8],0x0
rax 0x4d4d4d4d4d4d4d4d
rsi 0x5555555e53f0
rsi es el descriptor, 0xd8 (216) es offsetof(MADB_Stmt,Error) + offsetof(MADB_Error,SqlErrorMsg), y rax son exactamente los ocho bytes que puso el MITM.
- Control: cambiar solo las dos macros a
CHECK_DESC_CLEAR_ERRORendriver/odbc_3_api.cpp:855y:874, recompilar y repetir los pasos 3 a 5. AddressSanitizer no informa de nada y el driver normal, a través del mismo MITM, termina con código 0.
Impacto
Corrupción de memoria en el proceso cliente que carga el driver:
- Cualquier aplicación que llame a
SQLGetDescRecoSQLGetDescRecWescribe 13 bytes fuera del bloque en cada llamada. En una compilación normal basta para que glibc aborte el proceso, incluso con un servidor legítimo. - Un servidor malicioso, o alguien en medio de una conexión sin TLS, elige el desplazamiento de una segunda escritura (un byte a cero) relativa al descriptor. Como esos bytes tienen que ser caracteres válidos dentro de una cadena de versión, los desplazamientos posibles son grandes y poco precisos: lo demostrado es una caída provocada en remoto con corrupción de memoria, no una escritura dirigida a un punto concreto.
- No hace falta autenticarse en el servidor real, porque el handshake se procesa antes de que termine la autenticación.
El investigador lo valoró con CVSS 3.1 AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:H = 6.5 (Media); la complejidad alta refleja las dos condiciones previas: que la aplicación use SQLGetDescRec y, para la escritura influida por el servidor, una posición en la red o un servidor hostil. El reporte se envió el 26 de julio de 2026, quedó resuelto y se divulgó el 4 de noviembre de 2026.
Remediación
La corrección propuesta, y la que el investigador probó en su control, es usar CHECK_DESC_CLEAR_ERROR en SQLGetDescRec y SQLGetDescRecW, igual que en SQLGetDescField y SQLGetDescFieldW. Con ese cambio desaparecen las dos escrituras indebidas.
Qué aprender de este caso
- En APIs con manejadores opacos (
SQLHANDLE,void*), compara cada función con sus vecinas que reciben el mismo tipo de manejador: aquíSQLGetDescFieldusaba la macro de descriptor ySQLGetDescRec, unas líneas más abajo, la de sentencia. Ese tipo de incoherencia se encuentra con un simplegrepde las macros de comprobación. - Cuando un cast a otra estructura no falla de forma visible, mira los
offsetof: un campo que en el tipo real cae dentro de un buffer rellenado con datos externos (aquí, la versión del servidor) convierte un error de tipos en una escritura con desplazamiento controlado. - Las rutas de limpieza de errores también escriben en memoria; compilar con AddressSanitizer y ejercitar funciones poco usadas, como las de descriptores explícitos, saca a la luz fallos que una compilación normal solo muestra como caídas aleatorias.
- En un cliente de base de datos, no exigir TLS por defecto amplía el atacante posible de "servidor malicioso" a "cualquiera en la red": conviene que los DSN de producción fuercen el cifrado y la verificación del certificado.