Resumen
MariaDB Connector/ODBC, el driver ODBC oficial de MariaDB, tenía un desbordamiento de búfer en la pila en sus funciones de catálogo, las que sirven para consultar metadatos (columnas, claves, índices, procedimientos, privilegios…). Al construir la cláusula WHERE de la consulta interna, el driver escapa el identificador que le pasa la aplicación dentro de un array local de tamaño fijo, sin comprobar antes cuánto mide.
Las funciones afectadas son nueve:
SQLColumnsSQLPrimaryKeysSQLStatisticsSQLSpecialColumnsSQLProceduresSQLProcedureColumnsSQLColumnPrivilegesSQLTablePrivilegesSQLForeignKeys
La décima, SQLTables, sí valida la longitud y rechaza el mismo nombre con el error HY090. Esa diferencia es la que deja claro que se trata de un olvido y no de una decisión de diseño.
El investigador lo reprodujo con el paquete odbc-mariadb 3.2.6-1.1 que distribuyen Debian y Ubuntu y con la rama master en el commit 4e175d9 (versión 3.2.10). HackerOne lo clasificó con severidad media.
Dónde está el fallo
El código vulnerable está en driver/ma_catalog.cpp, en las funciones auxiliares AddPvCondition() y AddOaCondition(). Cada una tiene una variante que escribe en un stream y otra que escribe en un búfer, y las cuatro declaran el mismo destino (líneas 35, 53, 108 y 131):
static int AddPvCondition(MADB_Dbc *dbc, void* buffer, size_t bufferLen, char* value, SQLSMALLINT len)
{
char escaped[2*NAME_LEN + 1];
/* We should probably compare to SQL_NTS, and throw error if just < 0, but I think this way it will work just fine */
if (len < 0)
{
len= (SQLSMALLINT)strlen(value);
}
len= (SQLSMALLINT)mysql_real_escape_string(dbc->mariadb, escaped, value, len);
Con NAME_LEN igual a 256, escaped ocupa 513 bytes. Ese tamaño solo es suficiente si la entrada no pasa de 256 bytes, porque mysql_real_escape_string() puede escribir hasta 2 * len + 1 bytes. Y nada limita len: llega desde AddPvOrIdCondition() y AddOaOrIdCondition() (líneas 192 y 227), que reenvían sin tocarlo el NameLength de la aplicación siempre que SQL_ATTR_METADATA_ID tenga su valor por defecto, SQL_FALSE.
El camino desde la API pública es directo. Por ejemplo, SQLColumns llama a MA_SQLColumns(), que llega a MADB_StmtColumns(), y esta pasa TableName y NameLength3 a AddPvOrIdCondition() en la línea 666, sin ninguna validación por medio. Un nombre de 1000 bytes provoca una escritura de 2001 bytes en un búfer de 513: contigua, más allá del marco de la función y con el contenido escapado que eligió quien llama.
Usar SQL_NTS (lo habitual, en lugar de medir la cadena) no evita nada: la rama len < 0 recalcula la longitud con strlen() y el desbordamiento ocurre igual.
La comprobación que falta existe en MADB_StmtTables(), en las líneas 376 a 378:
/* TODO: Here we need compare character length in fact. Comparing with both either NAME_CHAR_LEN or NAME_LEN don't make quite sense, but
if > NAME_LEN, then it is for sure too big */
if (CatalogNameLength > NAME_LEN || TableNameLength > NAME_LEN)
Qué se observa
Con el driver del paquete de la distribución, cambiando solo la longitud del identificador:
namelen=250 exit=0
namelen=256 exit=0
namelen=257 exit=0
namelen=260 exit=0
namelen=300 exit=134 *** stack smashing detected ***: terminated
namelen=400 exit=139 SIGSEGV
Compilado desde master con AddressSanitizer, la escritura queda localizada con precisión:
==15368==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 1
#0 mysql_cset_escape_slashes libmariadb/ma_charset.c:1212
#1 mysql_real_escape_string libmariadb/mariadb_lib.c:4338
#2 AddPvCondition driver/ma_catalog.cpp:60
#3 AddPvOrIdCondition driver/ma_catalog.cpp:204
#4 MADB_StmtColumns driver/ma_catalog.cpp:666
#5 MA_SQLColumns driver/ma_api_internal.cpp:584
#6 SQLColumns driver/odbc_3_api.cpp:299
Address is located in stack of thread T0 at offset 561 in frame AddPvCondition
This frame has 1 object(s):
[48, 561) 'escaped' (line 53) <== Memory access at offset 561 overflows this variable
Mientras tanto, SQLTables rechaza el mismo nombre de 1000 bytes sin problemas:
SQLSTATE=HY090 : [ma-3.2.6][11.4.12-MariaDB-ubu2404]Table and catalog names are limited to 64 chars
Depende de cómo se compile el driver
- Paquetes de Debian y Ubuntu: se compilan con
-fstack-protector-strong(nm -Dmuestra la referencia a__stack_chk_fail). Con 300 bytes el canario detecta la corrupción y el proceso aborta; con 400 o más, falla por segmentación antes de llegar a la comprobación. - Compilación desde el código fuente con las opciones del proyecto: el
CMakeLists.txtno añade-fstack-protector, así que la biblioteca resultante no tiene canario. Pasando 1400 bytes deaaSQLPrimaryKeys, el byte elegido acaba en la dirección de retorno guardada:
Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7cb585d in AddOaCondition (...) at driver/ma_catalog.cpp:161
161 }
#0 0x00007ffff7cb585d in AddOaCondition (...) at driver/ma_catalog.cpp:161
#1 0x6161616161616161 in ?? ()
#2 0x6161616161616161 in ?? ()
#3 0x6161616161616161 in ?? ()
El fallo se produce en la llave de cierre de AddOaCondition, es decir, en su ret. Si el nombre se rellena con ', el valor pasa a ser 0x275c275c275c275c (la forma escapada \' repetida) y llega a realloc() un puntero controlado por el atacante.
Pasos de reproducción
El adjunto del reporte, repro_f1_catalog_overflow.c, es un único fichero que llama a SQLTables y a SQLColumns con el mismo identificador, para ver en una sola ejecución la diferencia entre la función protegida y la que no lo está. No hace falta compilar el driver: basta con el paquete de la distribución.
- Instalar el driver y las bibliotecas ODBC, y arrancar cualquier servidor MariaDB:
apt install odbc-mariadb unixodbc unixodbc-dev
- Compilar el programa de prueba:
gcc -O0 -g -o repro_f1 repro_f1_catalog_overflow.c -lodbc
- Ejecutarlo como control con un identificador de 200 bytes. Las dos llamadas devuelven
SQL_SUCCESSy el proceso termina con código 0:
./repro_f1 "DRIVER=/usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so;SERVER=127.0.0.1;PORT=3306;UID=user;PWD=pass;DATABASE=test;SSLVERIFY=0;" 200
- Repetirlo cambiando el último argumento a
1000.SQLTablesdevuelveHY090ySQLColumnsmata el proceso, con*** stack smashing detected ***o con SIGSEGV según cómo se compilara el driver.
- Para ver la escritura atribuida al código fuente, compilar el driver desde
mastercon AddressSanitizer y apuntar la cadena de conexión a lalibmaodbc.soresultante:
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
- Para ver cómo la dirección de retorno toma los bytes de quien llama, compilar el driver desde
mastercon las opciones por defecto y ejecutar bajo gdb:
./repro_f1 "<connstring>" 1400 a
- Control de la corrección: aplicar el recorte de una línea (ver "Remediación") en los cuatro puntos de escape de
ma_catalog.cpp, recompilar y repetir los pasos 4 a 6. Todo devuelveSQL_SUCCESS.
Impacto
Corrupción de memoria en el proceso que carga el driver, provocada solo por la longitud de un identificador pasado a una función de catálogo. No hacen falta privilegios en la base de datos más allá de los que ya tenga la aplicación, ni la colaboración del servidor: el desbordamiento ocurre mientras el driver monta la consulta, antes de enviar nada.
- Con los paquetes de las distribuciones (con canario), el resultado demostrado es una caída fiable y alcanzable en remoto: cualquier aplicación a la que se pueda hacer pasar un identificador largo puede tumbarse.
- Con el driver compilado desde el código fuente con las opciones del proyecto (sin canario), la dirección de retorno guardada contiene bytes elegidos por quien llama, que es la condición previa para secuestrar el flujo de ejecución, no solo para provocar una caída.
La exposición depende de dónde salga el identificador. Una aplicación que solo usa nombres de tabla fijos no es atacable; una que reenvía un nombre o un patrón de búsqueda del usuario a una función de catálogo, sí. Y ese es precisamente el uso previsto: en la especificación ODBC, TableName y ColumnName de SQLColumns (y sus equivalentes en SQLProcedures, SQLProcedureColumns, SQLColumnPrivileges y SQLTablePrivileges) son patrones de búsqueda. Exploradores de esquemas, herramientas de informes y BI, catálogos de datos y consolas de administración son los candidatos realistas.
El investigador propuso dos valoraciones CVSS 3.1:
- Sin canario (compilación por defecto del proyecto):
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H= 8.1. - Con canario (paquetes de las distribuciones):
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H= 5.9.
La complejidad alta (AC:H) refleja que el atacante necesita una aplicación intermedia que lleve entrada no confiable hasta una función de catálogo.
Remediación
El reporte figura como resuelto. La corrección que propuso el investigador es aplicar en las cuatro funciones de escape el mismo límite que ya usa MADB_StmtTables, rechazando o recortando los identificadores de más de NAME_LEN bytes antes de escaparlos:
if (len > NAME_LEN) len= NAME_LEN;
len= (SQLSMALLINT)mysql_real_escape_string(dbc->mariadb, escaped, value, len);
Con ese cambio, todas las entradas anteriores devuelven SQL_SUCCESS y el proceso termina sin errores. Además, recomendó añadir -fstack-protector-strong a las opciones de compilación del propio proyecto.
Qué aprender de este caso
- Cuando una función como
mysql_real_escape_string()puede escribir hasta2 * len + 1bytes, el búfer de destino tiene que dimensionarse con la longitud real de la entrada, o la entrada tiene que limitarse antes. Unchar escaped[2*NAME_LEN + 1]solo es seguro si alguien garantiza quelen <= NAME_LEN. - Si en una familia de funciones parecidas una sola valida un dato (aquí
SQLTables), conviene revisar las demás: la comprobación suele estar en el punto de entrada de una y faltar en sus hermanas. Ponerla en la función auxiliar común, y no en cada llamador, evita el olvido. - En drivers y bibliotecas nativas, los argumentos de búsqueda de metadatos (nombres de tabla, columna o procedimiento) también son entrada del usuario cuando la aplicación los reenvía; merece la pena probarlos con longitudes muy por encima del límite documentado.
- No delegar la protección en quien empaqueta: si el
CMakeLists.txtdel proyecto no activa-fstack-protector-strong, quien compile desde el código fuente obtiene una biblioteca donde un desbordamiento pasa de caída a posible ejecución de código.