Reports
MEDIA[CORRUPCIÓN DE MEMORIA]#3920633

MariaDB Connector/ODBC: SqlwcsOctetLen mide mal el UTF-16, lee fuera del búfer y envía memoria del cliente al servidor

En la versión POSIX de MariaDB Connector/ODBC, SqlwcsOctetLen confunde caracteres normales como «Ø» o «这» con surrogates UTF-16. Lee más allá del final de la cadena y manda esos bytes de memoria al servidor dentro de la consulta SQL.

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 fallo es una lectura fuera de límites (CWE-125) en mariadb-connector-odbc, el driver ODBC de MariaDB, y solo afecta a la versión para sistemas POSIX (Linux con unixODBC, por ejemplo). Está en la función SqlwcsOctetLen (driver/ma_platform_posix.cpp:121). Esa función calcula cuántos bytes ocupa una cadena ancha (UTF-16) antes de convertirla al juego de caracteres de la conexión.

Para saber si cada unidad de código empieza un par sustituto (surrogate) de 4 bytes, la función llama a mb_charlen de Connector/C. Ese helper está pensado para recorrer la cadena byte a byte, y su macro trunca el argumento a 8 bits:

#define UTF16_HIGH_HEAD(x)  ((((uchar) (x)) & 0xFC) == 0xD8)

static uint mysql_mbcharlen_utf16(unsigned int utf16)
{
  return UTF16_HIGH_HEAD(utf16) ? 4 : 2;
}

SqlwcsOctetLen, en cambio, le pasa un SQLWCHAR completo de 16 bits:

/* CharLen < 0 - treat as NTS */
SQLINTEGER SqlwcsOctetLen(const SQLWCHAR *str, SQLINTEGER *CharLen)
{
  SQLINTEGER result= 0, inChars= *CharLen;

  if (str)
  {
    while (inChars > 0 || (inChars < 0 && *str))
    {
      result+= DmUnicodeCs->mb_charlen(*str);
      --inChars;
      str+= DmUnicodeCs->mb_charlen(*str)/sizeof(SQLWCHAR);
    }
  }

Como la macro solo mira el byte bajo, toda unidad de la forma 0xNND8–0xNNDB se toma por el inicio de un surrogate. La función suma 4 bytes y avanza el puntero dos unidades, aunque solo haya descontado un carácter. Son 4 de cada 256 puntos de código (1024 en total), y entre ellos hay caracteres muy corrientes:

  • U+8FD9 (这, «esto»), uno de los caracteres chinos más frecuentes.
  • U+4ED8 (付, «pagar») y U+52D9 (務, «servicio» en japonés).
  • U+00D8–U+00DB (Ø, Ù, Ú, Û), habituales en nombres nórdicos y en cualquier texto pasado a mayúsculas.

La comprobación también falla en el sentido contrario: un surrogate real como U+D83D tiene el byte bajo 0x3D, así que se trata como un carácter normal de 2 bytes.

En la práctica, el puntero se sale del búfer que dio la aplicación y la función devuelve un número de bytes inflado. MADB_ConvertFromWChar le pasa ese número directamente a iconv como longitud de entrada:

    /* PtrLength is in characters. MADB_ConvertString(iconv) needs bytes */
    PtrOctetLen= SqlwcsOctetLen(Ptr, &PtrLength);
    AscLen= (PtrLength + (mustBeNullTerminated ? 1 : 0))*cc->cs_info->char_maxlen;
  }

  if (!(AscStr = (char *)MADB_CALLOC(AscLen)))
    return NULL;

  AscLen= MADB_ConvertString((char*)Ptr, &PtrOctetLen, DmUnicodeCs, AscStr, &AscLen, cc->cs_info, Error);

El búfer de salida (AscLen) se dimensiona con el número de caracteres correcto, pero la entrada (PtrOctetLen) es la inflada. Por eso la lectura no tiene límite, mientras que lo que acaba en la consulta sí lo tiene: entre 12 y 16 bytes por sentencia en la configuración que se midió.

Con SQL_NTS (cadena terminada en NUL) hay un efecto más. Si el carácter problemático va justo antes del terminador, el puntero salta por encima del NUL, la condición *str nunca lo ve y el recorrido sigue por la memoria que haya detrás.

MADB_ConvertFromWChar es la conversión que usan todas las funciones anchas del driver, así que el fallo llega al servidor por SQLPrepareW, SQLExecDirectW (driver/ma_api_internal.cpp:2057 y similares), SQLDriverConnectW y las funciones de catálogo anchas.

Versiones afectadas: la rama principal en 4e175d9 (VERSION 03.02.0010) y las 3.2.x publicadas, incluido el paquete odbc-mariadb 3.2.6-1.1. Windows no se ve afectado por este defecto, porque driver/ma_platform_win32.cpp tiene su propio MADB_ConvertFromWChar y no usa esta función.

Pasos de reproducción

  1. Instala el driver tal como se distribuye (odbc-mariadb 3.2.6-1.1) o compílalo desde el commit 4e175d9. No hace falta sanitizer ni ninguna opción de compilación especial.
  2. Compila el reproductor que se adjuntó al reporte:

`` gcc -O0 -g -o poc_h1_repro poc_h1_repro.c -lodbc ``

  1. Ejecútalo contra cualquier instancia de MariaDB:

`` ./poc_h1_repro "DRIVER=/usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so;SERVER=127.0.0.1;PORT=3306;UID=user;PWD=pass;DATABASE=db;" ``

  1. Caso B (terminada en NUL). El programa construye una cadena ancha de 10 caracteres, SELECT 'x seguido de U+00D8, y la ejecuta con SQLExecDirectW y SQL_NTS. Detrás del terminador deja en memoria la marca LEAKED_PAST_NUL, que en ningún momento pide enviar.
  2. Comprueba la fila que devuelve el servidor. Ocupa 19 bytes:

`` x\xC3\x98\0LEAKED_PAST_NUL ``

El \0 es el terminador de la aplicación. Todo lo que va detrás es memoria del cliente que el driver leyó más allá del final de la cadena, convirtió y envió, y que el servidor devolvió.

  1. Caso A (longitud explícita). Lo mismo con una longitud de 32 caracteres y texto chino normal. El mensaje de error del servidor reproduce la marca, prueba de que pasó por la red. También se puede comprobar en el log general de consultas del servidor:

`` SET GLOBAL general_log=1; ``

La sentencia registrada contiene bytes que la aplicación nunca proporcionó.

  1. Vista de seguridad de memoria. Compila con -fsanitize=address y ejecuta poc/poc_wcs_octetlen.c, que reserva exactamente 32 SQLWCHAR y llama a SQLPrepareW(stmt, buf, 32). AddressSanitizer informa de un heap-buffer-overflow READ of size 2 en SqlwcsOctetLen (driver/ma_platform_posix.cpp:129), justo 0 bytes después de la reserva de 64 bytes. Con contenido ASCII, la misma llamada no da ningún aviso.
  2. Control con el arreglo. Sustituye las llamadas a mb_charlen por una comprobación directa de la unidad de código, unit >= 0xD800 && unit <= 0xDBFF ? 4 : 2, recompila y repite. El aviso de AddressSanitizer desaparece y el servidor recibe solo los caracteres declarados. El script poc/run_poc.sh del adjunto hace toda la secuencia, también este paso.

Impacto

El driver lee memoria del proceso de la aplicación fuera de límites y la envía al servidor de base de datos dentro de la sentencia SQL.

  • Confidencialidad (baja): cada sentencia filtra una cantidad acotada (12-16 bytes en lo que se midió), pero se repite en todas las que lleven un carácter problemático. Una aplicación que trabaje con texto chino, japonés o nórdico va dejando salir memoria contigua del heap mientras funcione. En un cliente de base de datos esa memoria suele contener otras sentencias, filas ya leídas o credenciales que se guardan para reconectar.
  • Integridad (baja): el servidor no ejecuta la sentencia que compuso la aplicación. En el caso B se pidió una consulta de 10 caracteres y volvió un valor de 19 bytes.
  • Disponibilidad (ninguna): no se consiguió ningún cierre con la versión publicada, aunque con una lectura sin límite es posible que ocurra con una disposición de memoria desfavorable.

No hace falta un servidor malicioso, ni privilegios, ni una configuración rara: basta con contenido normal de los usuarios. Un atacante que meta alguno de los 1024 caracteres afectados en un campo que la aplicación envía (nombre, dirección, comentario, búsqueda) decide cuándo ocurre la lectura y hasta dónde llega, porque la distancia crece una unidad por cada carácter problemático. Si la base de datos la gestiona otra parte (un servicio alojado o multiinquilino), la memoria cruza esa frontera de confianza. Además, los bytes filtrados acaban en el log general del servidor y en sus mensajes de error, que las aplicaciones a menudo guardan o enseñan al usuario.

Límites del hallazgo, según el propio reporte: solo afecta a la versión POSIX, la memoria llega al servidor y no directamente al atacante, y al ser una librería cliente no hay ninguna instancia de MariaDB en la que confirmarlo en producción.

Remediación

El reporte figura como resuelto, pero no detalla el parche aplicado. El investigador propuso esto:

  • En SqlwcsOctetLen, comprobar directamente si la unidad de código es un surrogate alto (0xD800–0xDBFF) en vez de delegar en el mb_charlen pensado para bytes.
  • Dar al bucle un puntero end y salir al rebasarlo, como ya hace SqlwcsCharLen (driver/ma_string.cpp:571) en el mismo driver.

Qué aprender de este caso

  • Si una función recibe una unidad de 16 o 32 bits y la pasa a un helper escrito para recorrer char*, revisa los casts a uchar o char dentro de sus macros: truncan en silencio y hacen que la comprobación mire otro dato. Para encontrar casos así, busca llamadas a mb_charlen, mbcharlen o similares que reciban un wchar_t o SQLWCHAR.
  • En los bucles que avanzan un número variable de unidades por iteración (UTF-16, UTF-8), comprueba que el final se controle con un puntero end y no solo con un contador o con *str != 0. Un salto de dos unidades puede pasar por encima del NUL o del final del búfer.
  • Cuando el tamaño de salida y el de entrada de una conversión (iconv, por ejemplo) se calculan por caminos distintos, compáralos. Aquí el de salida era correcto y el de entrada no, lo que ocultaba una lectura sin límite detrás de una fuga pequeña y repetida.
  • Para probar drivers y librerías de texto, usa caracteres reales como U+8FD9 o U+00D8 además de ASCII y emojis. Este fallo no aparecía con ASCII y, además, la misma función tampoco reconocía bien los surrogates de verdad.