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

RCE con pocos privilegios en MariaDB: use-after-free en SYS_REFCURSOR encadenado con una lectura fuera de límites en ST_Area

Un usuario autenticado con permisos básicos sobre una sola base de datos logra ejecutar comandos del sistema operativo como el proceso mariadbd encadenando un use-after-free en SYS_REFCURSOR con una lectura de memoria fuera de límites en ST_Area.

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

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; un FETCH posterior 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ón ST_Area). Un MULTIPOLYGON manipulado hace que el motor lea más allá del buffer de la geometría y mezcle esos bytes en el DOUBLE que 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:

  1. Fuga (fallo 2): ST_Area dobla qwords del heap fuera de límites dentro de su resultado DOUBLE, 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 de mariadbd. Es el único canal dentro del proceso, con pocos privilegios, que devuelve un puntero crudo al cliente, imprescindible frente a ASLR + PIE + Full RELRO.
  2. 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..c31 apuntan a bytes del atacante.
  3. Escritura y cambio de privilegios: el despacho virtual de FETCH c16 ejecuta un gadget de escritura arbitraria que modifica el Security_context::master_access de la sesión para añadir el privilegio FILE.
  4. Ejecución de código: con FILE en la sesión, el ataque escribe una UDF y la invoca; cuando el plugin_dir no es escribible, en su lugar encadena una llamada a execlp que 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_spvars al que apunta result), o bien volver a asignar el puntero result de 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, un std::vector, cualquier buffer que haga realloc al 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 y area() 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 cualquier end - cursor asignado a un uint como sospechoso y verifica que cursor <= end antes 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".