HexBrain

MariaDB · Report #3891779

Connector/ODBC injects unquoted column names into positioned DELETE and UPDATE via MADB_DynStrGetWhere

Validstatus
mediumseverity
2upvotes
4 Nov 2026disclosed

How it was found

MADB_DynStrGetWhere() in MariaDB Connector/ODBC appends a column name into the WHERE clause of the DELETE and UPDATE statements it generates for positioned operations, with no quoting of any kind. The column value on the same line is correctly escaped; the column name is not. An application that asks the driver to delete one row through SQLSetPos(SQL_DELETE) can be made to delete every row in the table instead, and more generally to execute SQL chosen by whoever controls the identifier, on the application's connection and with the application's privileges.

Reproduced on the released odbc-mariadb 3.2.6-1.1 package as shipped by Debian and Ubuntu, and on current master HEAD 4e175d9 (3.2.10).

Details

The relevant code is driver/ma_string.cpp:345 in MADB_DynStrGetWhere():

      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("'");

The value goes through mysql_real_escape_string. The name is appended raw. This is the only identifier builder in the driver that applies no quoting at all. The others at least wrap the name in backticks:

  • MADB_DynStrAppendQuoted, driver/ma_string.cpp:105, emits `name`
  • MADB_DynStrUpdateSet, driver/ma_string.cpp:142, emits `name`=?
  • MADB_DaeStmt, driver/ma_helper.cpp:1159, :1167, :1176, emits `catalog`.`table`

None of those double an embedded backtick either, so they are injectable too, but they at least require a backtick in the payload. Line 345 requires nothing.

MADB_StmtSetPos(SQL_DELETE) at driver/ma_statement.cpp:3853 assembles the final statement and sends it:

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

which produces DELETE FROM <table> WHERE 1 AND <org_name>= '<escaped value>' LIMIT 1. Note that TableName, which is MYSQL_FIELD.org_table, is also appended unquoted on the same path.

A column named 1 OR 1=1# therefore yields:

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

The # opens a comment, so both the value comparison and the LIMIT 1 are discarded and the server executes DELETE FROM victim WHERE 1 AND 1 OR 1=1. The application requested a single row.

MariaDB permits any byte except NUL inside a quoted identifier and only forbids a trailing space, so the full 64-character identifier length is available as payload. The table is created with ordinary syntax:

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

org_name and org_table are the underlying column and table names as the server reports them in the result-set metadata, so there are two ways an attacker reaches this. The first is anyone able to create or rename a column in a schema the application later performs a positioned update or delete on. That capability is ordinary in applications with user-defined custom fields, form builders, low-code schema generation, or CSV import that derives column names from headers. The second is a malicious or MITM server, which can put any value in org_name regardless of the real schema; the driver does not require TLS on a default DSN, enabling it only when an SSL option is explicitly set at driver/ma_connection.cpp:778.

PoC

Two tables, identical except for the name of the primary key column, both with four rows, and an application that asks to delete exactly one row from each:

--- 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

The server's own general log shows exactly what the driver sent:

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

SQLSetPos returns SQL_SUCCESS in both cases. The application has no indication that anything other than the requested row was touched, beyond the row count.

repro_f6_identifier_sqli.c in the attachment is a single self-contained file. It creates both tables itself, so one command reproduces the whole thing. No custom driver build is needed; the distribution package reproduces it:

  1. Install the packaged driver: apt install odbc-mariadb unixodbc unixodbc-dev
  2. Build: gcc -O0 -g -o repro_f6 repro_f6_identifier_sqli.c -lodbc
  3. Optionally enable general log: SET GLOBAL general_log_file='/tmp/gen.log'; SET GLOBAL general_log=1;
  4. Run: ./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;"
  5. Control table loses 1 row, victim table loses all 4. Program prints VULNERABLE.

Impact

Arbitrary SQL executed on the application's connection with the application's privileges, chosen by whoever controls a column or table name in a schema the application performs positioned updates or deletes on.

The demonstrated case is destructive: an application that asks to delete one row deletes the entire table, and receives SQL_SUCCESS. The same injection point reaches anything expressible in a WHERE clause, so subqueries and boolean conditions are available for reading data the application never asked for, and the same unquoted-identifier pattern is present on the UPDATE path through MADB_DynStrUpdateSet and on the table name itself.

The privilege boundary this crosses is the interesting part. In a multi-tenant or user-extensible schema, the party who names a column is typically far less privileged than the reporting, ETL or administrative application that later reads and modifies those rows over ODBC. The injected statement runs as the latter.

Suggested fix: wrap the identifier in backticks and double any embedded backtick, the same treatment the value already receives.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original