Reports
MEDIA[SQL INJECTION]#3891779

Inyección SQL en MariaDB Connector/ODBC: nombres de columna sin comillas en DELETE y UPDATE posicionados

El driver ODBC de MariaDB pegaba el nombre de columna sin entrecomillar en el WHERE de los DELETE y UPDATE posicionados. Una columna llamada `1 OR 1=1#` convertía el borrado de una fila en el borrado de toda la tabla.

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

MariaDB Connector/ODBC genera por su cuenta sentencias DELETE y UPDATE cuando una aplicación modifica la fila sobre la que está el cursor (operaciones "posicionadas", como SQLSetPos(SQL_DELETE)). Para identificar esa fila, construye una cláusula WHERE con el nombre de cada columna y su valor.

El fallo estaba en MADB_DynStrGetWhere() (driver/ma_string.cpp:345): el valor se escapaba correctamente con mysql_real_escape_string, pero el nombre de la columna se concatenaba tal cual, sin comillas de ningún tipo:

      DynString.append(" AND ").append(field->org_name);

      if (ParameterMarkers)
      {
        DynString.append("=?");
      }
      else
      {
        ...
          Escaped= static_cast<char*>(MADB_CALLOC(2 * StrLength + 1));
          EscapedLength= mysql_real_escape_string(Stmt->Connection->mariadb, Escaped, Column, (unsigned long)StrLength);

          DynString.append("= '").append(Escaped).append("'");

Según el investigador, es el único constructor de identificadores del driver que no aplica ningún entrecomillado. Los demás (MADB_DynStrAppendQuoted en driver/ma_string.cpp:105, MADB_DynStrUpdateSet en driver/ma_string.cpp:142 y MADB_DaeStmt en driver/ma_helper.cpp:1159, :1167 y :1176) envuelven el nombre en acentos graves, aunque tampoco duplican un acento grave incrustado, así que también serían inyectables si el payload incluye uno.

La sentencia final la monta MADB_StmtSetPos(SQL_DELETE) en driver/ma_statement.cpp:3853, que además añade el nombre de la tabla (org_table) igualmente sin comillas:

      SQLString     DynamicStmt("DELETE FROM ");
      ...
      DynamicStmt.append(TableName);
      ...
        if (MADB_DynStrGetWhere(Stmt, DynamicStmt, TableName, false))
        ...
          Stmt->Connection->guard->safeRealQuery(DynamicStmt);

El resultado tiene la forma DELETE FROM <tabla> WHERE 1 AND <org_name>= '<valor escapado>' LIMIT 1. Si la columna se llama 1 OR 1=1#, lo que llega al servidor es:

DELETE FROM victim WHERE 1 AND 1 OR 1=1#= '3' LIMIT 1

El # abre un comentario que se come la comparación con el valor y el LIMIT 1, de modo que se ejecuta DELETE FROM victim WHERE 1 AND 1 OR 1=1: se borra la tabla entera, cuando la aplicación había pedido una sola fila.

MariaDB admite cualquier byte salvo NUL dentro de un identificador entre acentos graves (solo prohíbe el espacio final), así que el atacante dispone de los 64 caracteres que puede medir un nombre de columna. Crear la tabla trampa no requiere nada especial:

CREATE TABLE victim (`1 OR 1=1#` INT PRIMARY KEY, data VARCHAR(64)) ENGINE=InnoDB;

Se reprodujo con el paquete odbc-mariadb 3.2.6-1.1 de Debian y Ubuntu y con la rama master en 4e175d9 (3.2.10).

Quién puede llegar a explotarlo

org_name y org_table son los nombres reales que el servidor devuelve en los metadatos del resultado. Hay dos caminos:

  1. Quien pueda crear o renombrar una columna en un esquema sobre el que la aplicación hace después borrados o actualizaciones posicionadas: campos personalizados definidos por el usuario, constructores de formularios, generación de esquemas low-code o importaciones CSV que sacan los nombres de columna de la cabecera.
  2. Un servidor malicioso o un atacante en medio de la conexión, que puede poner cualquier valor en org_name sin importar el esquema real. El driver no exige TLS con un DSN por defecto: solo lo activa si se indica explícitamente una opción SSL (driver/ma_connection.cpp:778).

Pasos de reproducción

El PoC del investigador es un único fichero C (repro_f6_identifier_sqli.c, adjunto al reporte) que crea él mismo dos tablas casi idénticas, cada una con cuatro filas; solo cambia el nombre de la clave primaria (id en la de control, 1 OR 1=1# en la víctima). En ambas coloca el cursor en la fila con clave 3 y pide borrar solo esa fila. No hace falta compilar el driver: basta el paquete de la distribución.

  1. Instalar el driver empaquetado:

``bash apt install odbc-mariadb unixodbc unixodbc-dev ``

  1. Compilar el PoC:

``bash gcc -O0 -g -o repro_f6 repro_f6_identifier_sqli.c -lodbc ``

  1. (Opcional) Activar el log general del servidor para ver las sentencias:

``sql SET GLOBAL general_log_file='/tmp/gen.log'; SET GLOBAL general_log=1; ``

  1. Ejecutarlo con la cadena de conexión:

``bash ./repro_f6 "DRIVER=/usr/lib/x86_64-linux-gnu/odbc/libmaodbc.so;SERVER=127.0.0.1;PORT=3306;UID=user;PWD=pass;DATABASE=test;SSLVERIFY=0;" ``

  1. La tabla de control pierde una fila y la víctima las cuatro; el programa imprime VULNERABLE.

Salida obtenida:

--- table 'odbc_f6_control' ---     (column named  id)
    rows before : 4
    cursor is on row key=3 (row-three)
    calling SQLSetPos(1, SQL_DELETE) - delete THIS ONE ROW
    SQLSetPos returned 0, SQLRowCount reports 1 row(s) affected
    rows after  : 3

--- table 'odbc_f6_victim' ---      (column named  1 OR 1=1#)
    rows before : 4
    cursor is on row key=3 (row-three)
    calling SQLSetPos(1, SQL_DELETE) - delete THIS ONE ROW
    SQLSetPos returned 0, SQLRowCount reports 4 row(s) affected
    rows after  : 0

Y lo que registró el log general del servidor:

DELETE FROM control WHERE 1 AND id= '3' LIMIT 1
DELETE FROM victim WHERE 1 AND 1 OR 1=1#= '3' LIMIT 1

En los dos casos SQLSetPos devuelve SQL_SUCCESS: la aplicación no tiene más pista de lo ocurrido que el contador de filas afectadas.

Impacto

Quien controle el nombre de una columna o tabla puede ejecutar SQL arbitrario en la conexión de la aplicación y con sus privilegios. El caso demostrado es destructivo: una petición para borrar una fila vacía la tabla entera sin que la aplicación reciba un error. Como el punto de inyección es una cláusula WHERE, también caben subconsultas y condiciones booleanas para leer datos que la aplicación nunca pidió. El mismo patrón de identificador sin comillas aparece en el camino de UPDATE (MADB_DynStrUpdateSet) y en el nombre de la tabla.

Lo relevante es el salto de privilegios: en esquemas multiinquilino o extensibles por el usuario, quien pone nombre a una columna suele tener muchos menos permisos que la aplicación de informes, ETL o administración que luego lee y modifica esas filas por ODBC, y la sentencia inyectada se ejecuta con los permisos de esta última.

El programa clasificó el reporte con severidad media y lo marcó como resuelto.

Remediación

El investigador propuso envolver el identificador en acentos graves y duplicar cualquier acento grave incrustado, es decir, darle al nombre el mismo tratamiento de escape que ya recibía el valor. El reporte figura como resuelto, pero no detalla el parche aplicado ni la versión que lo incluye.

Qué aprender de este caso

  • Cuando un driver u ORM genera SQL por su cuenta (borrados posicionados, actualizaciones por cursor, "guardar fila"), revisa cómo concatena los nombres de tabla y columna, no solo los valores: aquí el valor iba escapado y el nombre a pelo en la misma línea.
  • Los metadatos que devuelve el servidor (org_name, org_table) no son de confianza: los puede elegir cualquiera con permiso para crear columnas, o un servidor manipulado si la conexión va sin TLS.
  • Entrecomillar con acentos graves no basta si no se duplica el acento grave interno; una única función de escape de identificadores, usada en todos los sitios, evita que una ruta se quede sin ella.
  • Si tu aplicación deja a los usuarios definir campos o importar CSV con columnas a partir de la cabecera, restringe esos nombres a un juego de caracteres seguro: un # o un espacio en un nombre de columna rara vez tiene un uso legítimo.