Resumen
El fallo está en la librería cliente, no en el servidor: MariaDB Connector/C++ confiaba ciegamente en lo que el servidor le contaba sobre el tamaño de cada campo. Un servidor malicioso, o alguien en medio de la conexión (MITM), podía hacer que la aplicación cliente leyera memoria del heap más allá de un búfer, con dos consecuencias: fuga de memoria del proceso dentro de los valores de columna y caída del cliente.
El problema aparece en la ruta del protocolo binario (sentencias preparadas en el servidor), en src/protocol/capi/BinRowProtocolCapi.cpp. El investigador lo confirmó con AddressSanitizer y con una compilación release sin ASAN sobre el commit c57f17f (versión 1.0.8 en desarrollo).
Dónde se rompe
Hay dos medidas distintas que el conector mezcla:
- Al preparar la sentencia (COM_STMT_PREPARE), el servidor declara la longitud de cada columna. El conector reserva el búfer de cada columna con ese tamaño y ya no lo cambia (
BinRowProtocolCapi.cpp:55-73):
length = columnInfo->getLength(); // server-declared column length
bind.back().buffer_length =
columnInfo->getColumnType().binarySize() != 0 ?
columnInfo->getColumnType().binarySize() :
getLengthMaxFieldSize(); // == declared column length for string/blob types
bind.back().buffer = new uint8_t[bind.back().buffer_length]; // line 69: small allocation
bind.back().length = &bind.back().length_value;
- Al recibir cada fila, libmariadb copia como mucho
buffer_lengthbytes en ese búfer, pero enlength_valueanota la longitud completa que envió el servidor, sin recortarla (libmariadb/ma_stmt_codec.c:599):
// convert_froma_string: copies MIN(len, buffer_length) bytes but reports full length
*r_param->length = (ulong)len; // full server field length, uncapped
La capa C++ toma length_value como si fuera la cantidad de bytes legibles del búfer y nunca aplica MIN(length_value, buffer_length):
// setPosition BinRowProtocolCapi.cpp:107-108
length = bind[index].length_value; // server field length, uncapped
fieldBuf.wrap(static_cast<char*>(bind[index].buffer), length); // wraps small buffer with large length
// cacheCurrentRow BinRowProtocolCapi.cpp:1716
rowDataCache.emplace_back(static_cast<const char*>(b.buffer),
b.length_value); // copies length_value bytes from buffer_length-sized allocation
Si el servidor declara una columna pequeña y luego manda un valor más largo, todo lo que sobra se lee de la memoria contigua del heap.
Para llegar a esta ruta basta con useServerPrepStmts=true o con cualquier llamada a procedimiento almacenado mediante prepareCall(...), que siempre crea un ServerSidePreparedStatement sin importar esa opción (MariaDbProcedureStatement.cpp:59,83).
El investigador señala además que un commit anterior, CONCPP-152, ya había endurecido el caso length_value == 0, pero no el de length_value > buffer_length.
Pasos de reproducción
Entorno: Linux x86-64, conector compilado desde el commit c57f17f con AddressSanitizer. El investigador adjuntó un servidor falso (rogue_server.py) y un cliente de prueba (poc_client.cpp).
- Arrancar el servidor falso. Implementa solo lo necesario del protocolo: saludo inicial, los SELECT de sesión, un COM_STMT_PREPARE que declara una columna de texto de longitud 4 y un COM_STMT_EXECUTE que devuelve una fila binaria con un campo de 40000 bytes:
python3 rogue_server.py --port 13399 --col-length 4 --field-len 40000 --once &
- Conectar el cliente compilado con ASAN:
./poc_client 127.0.0.1 13399
ASAN detecta la lectura fuera de límites dentro de executeQuery(), en la ruta normal con resultados en búfer:
==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 40000
#1 sql::CArray<char>::CArray(const char*, unsigned long) CArrayImp.h:68
#5 BinRowProtocolCapi::cacheCurrentRow(...) BinRowProtocolCapi.cpp:1716
#6 SelectResultSetCapi::cacheCompleteLocally() SelectResultSetCapi.cpp:2039
#11 BasePrepareStatement::executeQuery() BasePrepareStatement.cpp:1441
0x...494 is located 0 bytes after 4-byte region [0x...490,0x...494)
allocated by thread T0 here:
#1 BinRowProtocolCapi::BinRowProtocolCapi(...) BinRowProtocolCapi.cpp:69
- Prueba de control: repetir con un servidor "honesto", donde la longitud declarada coincide con la del campo:
python3 rogue_server.py --port 13401 --col-length 40000 --field-len 40000 --once &
./poc_client 127.0.0.1 13401
[client] got string, length=40000
[client] done (no crash) # exit 0, zero ASAN errors
Sin discrepancia entre ambas longitudes no hay error, lo que demuestra que la causa es exactamente esa diferencia.
Comportamiento en una compilación release (sin ASAN)
- Con columna declarada de 4 bytes y campo de 64, la aplicación recibe 64 bytes: los 4 reales y 60 de heap ajeno, con punteros visibles:
[client] first 64 bytes (hex): 41414141 5a770000 201b3f81 5a770000 5868fb03 16610000 ...
^^^^^^^^ 4 bytes stored ^^^^^^^^^^^^^ adjacent process heap (heap pointers visible)
- Con un campo de 500000000 bytes, el proceso aborta y deja un volcado de memoria (código de salida 134).
Impacto
- Fuga de información: la memoria del heap contigua al búfer acaba dentro del valor de la columna que la aplicación recibe como un string normal. En un servidor de aplicaciones con pool de conexiones, esa memoria puede contener datos de otras peticiones o usuarios, credenciales o punteros que sirven para saltarse ASLR.
- Denegación de servicio: como el atacante elige
length_value(hasta 4 GB), puede forzar una copia muy lejos del búfer y tumbar el proceso cliente de forma fiable. - Requisitos: que el cliente se conecte a un servidor malicioso o a una conexión interceptada y lea resultados por el protocolo binario (
useServerPrepStmts=trueo cualquier procedimiento almacenado). No hacen falta credenciales ni privilegios; basta con una sola fila manipulada.
El reporte figura en HackerOne con severidad media y como resuelto. El investigador cita como precedente aceptado un caso similar en connector-nodejs (DoS desde servidor malicioso, reporte #3835450, severidad media).
Qué aprender de este caso
- En cualquier conector o cliente de protocolo binario, compara el tamaño con el que se reservó cada búfer de resultado con la longitud que se usa luego para leerlo: si la longitud viene del paquete de cada fila y la reserva de los metadatos del PREPARE, hay sitio para este fallo.
- Al envolver APIs de C que devuelven "longitud real" separada de "bytes copiados" (como
lengthenMYSQL_BIND), recorta siempre conMIN(length, buffer_length)antes de crear vistas o copias; un valor mayor indica truncado y hay que volver a pedir el dato, no leer de más. - Las librerías cliente también tienen que tratar al servidor como entrada no confiable: un MITM o un servidor comprometido controla longitudes y metadatos, así que merece la pena probarlas con servidores falsos que mientan en esos campos.
- Si un parche corrige un caso límite de una longitud (aquí
length_value == 0), revisa en el mismo punto los demás casos (mayor que el búfer, valores extremos) antes de darlo por cerrado.