//nefariousplan

CVE-2026-3494: The Fix For An Audit Bypass Stopped Logging SET PASSWORD

pattern

cve

proof of concept

CVE-2026-3494 was disclosed by AWS on March 3, 2026, against the server_audit plugin in MariaDB through 11.8.5. The bug: prefix a SQL statement with # or -- and the audit log will not record it when per-category filtering is configured. The fix is MariaDB commit 635559a2 by Sergei Golubchik on December 18, 2025, with the message "audit plugin implements its own (simple) SQL parser; remove the parser, use thd_sql_command(thd)." AWS ported the same commit to its audit-plugin-for-mysql fork three weeks later. The PoC for the CVE is a Docker harness that runs five queries against three MariaDB versions, reads each version's server_audit.log, and reports which queries got recorded. Its own data table shows the vulnerable versions logging every query. The patched version drops two. One of them is SET PASSWORD.

The fix removed the audit plugin's own parser

For years, MariaDB's audit plugin classified queries by walking the raw query string and matching keywords. The 11.8.5 source carries about 250 lines of this: get_next_word, filter_query_type, skip_set_statement, and a stack of keyword tables (dml_keywords, ddl_keywords, dcl_keywords, passwd_keywords, keywords_to_skip). The plugin was its own little SQL tokenizer, running downstream of the server's actual parser. It worked because it was independent of whatever the server did. It also worked badly: the tokenizer's "skip leading whitespace and comments" loop only understood /* */ block comments.

while (*query && (is_space(*query) || *query == '(' || *query == '/'))
{
    /* comment handling */
    if (*query == '/' && query[1] == '*')
    {
      /* ... skip block comment ... */
      continue;
    }
    query++;
}

A query beginning with # or -- made the tokenizer's first character not-a-keyword. get_next_word returned length 0. filter_query_type returned SQLCOM_NOTHING. The DDL/DML/DCL filter check failed, and the query was not logged. That is the bug CVE-2026-3494 names.

Commit 635559a2 removes all of it. Every line of the keyword-table code, every recursion through filter_query_type, the entire skip_set_statement helper. Deleted. In its place is one call:

- log_statement(cn, event, "QUERY");
+ log_statement(cn, event, "QUERY", thd_sql_command(thd));

thd_sql_command(thd) returns the server's lex->sql_command: the SQLCOM_* enum value the actual parser assigned during parsing. The audit plugin no longer has to know about comments. It does not have to parse anything. It receives the classification from upstream and translates it into the audit plugin's per-category bit.

The translation lives in a new function: sql_command_to_cmdtype. It is a switch.

SQLCOM_SET_OPTION has no case

static int sql_command_to_cmdtype(int sql_command)
{
  switch (sql_command)
  {
    case SQLCOM_ALTER_DB:
    case SQLCOM_ALTER_DB_UPGRADE:
    /* ... 26 DDL cases total ... */
    case SQLCOM_TRUNCATE:
      return EVENT_QUERY_DDL;

    case SQLCOM_DO:
    case SQLCOM_CALL:
    /* ... 14 DML_NO_SELECT cases total ... */
    case SQLCOM_REPLACE_SELECT:
      return EVENT_QUERY_DML | EVENT_QUERY_DML_NO_SELECT;

    case SQLCOM_SELECT:
      return EVENT_QUERY_DML;

    case SQLCOM_CREATE_USER:
    case SQLCOM_CREATE_ROLE:
    case SQLCOM_DROP_ROLE:
    case SQLCOM_DROP_USER:
    case SQLCOM_RENAME_USER:
    case SQLCOM_GRANT:
    case SQLCOM_GRANT_ROLE:
    case SQLCOM_REVOKE:
    case SQLCOM_REVOKE_ROLE:
    case SQLCOM_REVOKE_ALL:
      return EVENT_QUERY_DCL;

    default:
      return EVENT_QUERY_ALL;
  };
}

The DCL list is ten cases. SET PASSWORD is not one of them.

SET PASSWORD does not parse to its own SQLCOM_* value. The grammar in sql/sql_yacc.yy reduces every form of SET through one rule, which calls LEX::set_stmt_init():

void LEX::set_stmt_init()
{
  sql_command= SQLCOM_SET_OPTION;
  init_select();
  option_type= OPT_SESSION;
  /* ... */
}

SET @x = 1 is SQLCOM_SET_OPTION. SET ROLE admin is SQLCOM_SET_OPTION. SET TRANSACTION ISOLATION LEVEL SERIALIZABLE is SQLCOM_SET_OPTION. SET PASSWORD FOR 'u01'@'localhost' = PASSWORD('Test1234!') is SQLCOM_SET_OPTION. The parser's enum does not distinguish them; the difference is in the lex variables the rule populates downstream.

SQLCOM_SET_OPTION is not in sql_command_to_cmdtype. Not in the DCL list, not in the DDL list, not in either DML list. It falls through to default. The default returns EVENT_QUERY_ALL.

EVENT_QUERY_ALL is a bit, not a bag

The plugin's event-category constants are adjacent bits:

#define EVENT_CONNECT             1
#define EVENT_QUERY_ALL           2
#define EVENT_TABLE               4
#define EVENT_QUERY_DDL           8
#define EVENT_QUERY_DML          16
#define EVENT_QUERY_DCL          32
#define EVENT_QUERY_DML_NO_SELECT 64

EVENT_QUERY_ALL is the value 2. It is a single bit. The name promises a wildcard. The value participates in the mask the same way every other category does. It is, in particular, NOT the union DDL | DML | DCL | DML_NO_SELECT = 8 | 16 | 32 | 64 = 120. It is bit 2, distinct from bits 8, 16, 32, and 64.

The filter check in log_statement_ex:

if (!(events & cmdtype &
      (EVENT_QUERY_DDL | EVENT_QUERY_DML | EVENT_QUERY_DML_NO_SELECT |
       EVENT_QUERY_DCL)))
    return 0;

events is the operator's server_audit_events setting. The PoC's server_audit.cnf configures the typical security-conscious value:

server_audit_events=QUERY_DCL,QUERY_DDL,QUERY_DML

events = 8 | 16 | 32 = 56. The mask is 120. For SET PASSWORD:

thd_sql_command(thd)                       =  SQLCOM_SET_OPTION
sql_command_to_cmdtype(SQLCOM_SET_OPTION)  =  EVENT_QUERY_ALL  =  2
events & cmdtype & 120                     =  56 & 2 & 120     =  0
return 0                                       /* not logged */

The operator asked for DCL. SET PASSWORD is a credential-changing statement, the canonical DCL operation. The classifier puts it in a category named ALL whose numeric value participates in the bitmask like any other category, and the operator's filter does not select it. The audit log file gets no entry.

In MariaDB 11.8.5, the same query landed in dcl_keywords:

struct sa_keyword dcl_keywords[]=
{
  {6, "CREATE", user_word, SQLCOM_DCL},
  {6, "CREATE", or_replace_user_word, SQLCOM_DCL},
  {4, "DROP", user_word, SQLCOM_DCL},
  {6, "RENAME", user_word, SQLCOM_DCL},
  {5, "GRANT", 0, SQLCOM_DCL},
  {6, "REVOKE", 0, SQLCOM_DCL},
  {3, "SET", password_word, SQLCOM_DCL},
  {0, NULL, 0, SQLCOM_NOTHING}
};

filter_query_type matched SET and recursed on the next keyword PASSWORD, returned SQLCOM_DCL, the filter passed, the line got written:

20260504 04:45:07,...,QUERY,mysql,'SET PASSWORD
# CVE-2026-3494 Test
FOR \'u01\'@\'localhost\' = PASSWORD(*****)',1133

The vulnerable version logged SET PASSWORD. The patched version does not.

The unenumerated half of the SQLCOM space

SQLCOM_SET_OPTION is not alone. enum_sql_command in sql/sql_command.h defines roughly 160 values terminated by SQLCOM_END. sql_command_to_cmdtype covers 53 of them. Approximately 110 values fall through to default and return EVENT_QUERY_ALL.

Statements an auditor specifically cares about that are not in the switch:

  • SQLCOM_SET_OPTION: every form of SET, including SET PASSWORD, SET ROLE, SET TRANSACTION.
  • SQLCOM_ALTER_USER: modify user (account lock, password expire, resource limits).
  • SQLCOM_INSTALL_PLUGIN, SQLCOM_UNINSTALL_PLUGIN: install or remove server-level plugins, including the audit plugin itself.
  • SQLCOM_KILL: terminate sessions.
  • SQLCOM_FLUSH, SQLCOM_RESET: flush privileges, reset slave state, purge logs.
  • SQLCOM_BEGIN, SQLCOM_COMMIT, SQLCOM_ROLLBACK: transaction control.
  • SQLCOM_SLAVE_START, SQLCOM_SLAVE_STOP, SQLCOM_CHANGE_MASTER: replication control.
  • SQLCOM_SHOW_GRANTS: enumerate another principal's privileges.
  • SQLCOM_BACKUP, SQLCOM_BACKUP_LOCK: backup operations.

None of these are auditing edge cases. Every compliance regime that mandates database audit logging (PCI-DSS, HIPAA, SOX) names credential changes, privilege grants, replication-topology changes, and administrative state mutations as the events that must be recorded. The new classifier records none of them when the operator filters by category. The operator can recover visibility only by adding QUERY_ALL (bit 2) to server_audit_events. That option exists. It is not named in any of the typical example configurations.

What the PoC actually documents

The PoC's TEST_CASE_3 is SET PASSWORD\n# CVE-2026-3494 Test\nFOR 'u01'@'localhost' = PASSWORD('Test1234!'). The # comment is in the middle of the statement, not the beginning. The original CVE-2026-3494 class (prefixed comments) does not apply. The query was logged in 10.3.39 and 11.8.5, both versions inside the CVE's "vulnerable" range. It is not logged in 11.8.6, the patched version. The PoC author labels the result "Bypass check" and notes the omission as if it confirms the original CVE. It does not. It documents a different bug: the new classifier does not classify SET PASSWORD at all, with or without comments.

The PoC's README says two things on the same page. Section 1 says: "Affected Versions: MariaDB 11.8.5 and below (including 10.x branches)." Section 7 says: "MariaDB 11.8.6 (Target Version): TEST_CASE_3 → NOT LOGGED. Logging omission detected." Section 1 names the version the CVE indicts. Section 7 names the version that drops the queries. The author wrote both and did not notice.

Idle indistinguishable from broken

A server_audit.log file is a sequential CSV-like stream. Each line is one query. When a query is not recorded, the file simply has no entry for it. The operator reading the file at end of day sees three possible cases collapsed into two:

  • queries that ran and were configured to be logged: one line each.
  • queries that ran and were configured NOT to be logged: no line.
  • queries that ran and that the classifier produced an unmapped value for: no line.

Cases two and three produce identical wire format. The operator who configured server_audit_events=QUERY_DCL,QUERY_DDL,QUERY_DML cannot tell from the file whether a missing entry means the query fell outside the configured categories (correct behavior) or whether the new classifier silently dropped it (broken behavior). The audit plugin has no field for "we did not know what this query was."

This is the idle-indistinguishable-from-broken pattern. The wire format compresses "filtered per operator config" and "the classifier returned an unmapped value" into the same observable signal. The operator cannot reason about absences. The compliance auditor cannot reason about absences. The forensics analyst, post-incident, cannot reason about absences. The categories the new classifier does not enumerate are exactly the categories an audit log exists to record.

AWS ported the MariaDB commit on January 8, 2026 (commit 01e25a5c on aws/audit-plugin-for-mysql, author Tony Chen, citing Sergei Golubchik). The CVE record's affected-products list names not just MariaDB but Amazon Aurora MySQL through 3.11.0 and Amazon RDS MySQL/MariaDB across the 5.7, 8.0, 8.4, 10.6, 10.11, 11.4, and 11.8 series. The fix is the same fix everywhere.

PoC: KKongTen/CVE-2026-3494

The patched plugin is what Aurora MySQL and RDS MariaDB ship when the engine is upgraded past the affected range. Operators who configure server_audit_events by category and rely on the audit log for credential-change forensics are running the regression right now.