-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The fix removed the audit plugin's own parser\n\nFor 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.\n\n```c\nwhile (*query && (is_space(*query) || *query == '(' || *query == '/'))\n{\n /* comment handling */\n if (*query == '/' && query[1] == '*')\n {\n /* ... skip block comment ... */\n continue;\n }\n query++;\n}\n```\n\nA 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.\n\nCommit `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:\n\n```diff\n- log_statement(cn, event, \"QUERY\");\n+ log_statement(cn, event, \"QUERY\", thd_sql_command(thd));\n```\n\n`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.\n\nThe translation lives in a new function: `sql_command_to_cmdtype`. It is a switch.\n\n## SQLCOM_SET_OPTION has no case\n\n```c\nstatic int sql_command_to_cmdtype(int sql_command)\n{\n switch (sql_command)\n {\n case SQLCOM_ALTER_DB:\n case SQLCOM_ALTER_DB_UPGRADE:\n /* ... 26 DDL cases total ... */\n case SQLCOM_TRUNCATE:\n return EVENT_QUERY_DDL;\n\n case SQLCOM_DO:\n case SQLCOM_CALL:\n /* ... 14 DML_NO_SELECT cases total ... */\n case SQLCOM_REPLACE_SELECT:\n return EVENT_QUERY_DML | EVENT_QUERY_DML_NO_SELECT;\n\n case SQLCOM_SELECT:\n return EVENT_QUERY_DML;\n\n case SQLCOM_CREATE_USER:\n case SQLCOM_CREATE_ROLE:\n case SQLCOM_DROP_ROLE:\n case SQLCOM_DROP_USER:\n case SQLCOM_RENAME_USER:\n case SQLCOM_GRANT:\n case SQLCOM_GRANT_ROLE:\n case SQLCOM_REVOKE:\n case SQLCOM_REVOKE_ROLE:\n case SQLCOM_REVOKE_ALL:\n return EVENT_QUERY_DCL;\n\n default:\n return EVENT_QUERY_ALL;\n };\n}\n```\n\nThe DCL list is ten cases. `SET PASSWORD` is not one of them.\n\n`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()`:\n\n```c\nvoid LEX::set_stmt_init()\n{\n sql_command= SQLCOM_SET_OPTION;\n init_select();\n option_type= OPT_SESSION;\n /* ... */\n}\n```\n\n`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.\n\n`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`.\n\n## EVENT_QUERY_ALL is a bit, not a bag\n\nThe plugin's event-category constants are adjacent bits:\n\n```c\n#define EVENT_CONNECT 1\n#define EVENT_QUERY_ALL 2\n#define EVENT_TABLE 4\n#define EVENT_QUERY_DDL 8\n#define EVENT_QUERY_DML 16\n#define EVENT_QUERY_DCL 32\n#define EVENT_QUERY_DML_NO_SELECT 64\n```\n\n`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.\n\nThe filter check in `log_statement_ex`:\n\n```c\nif (!(events & cmdtype &\n (EVENT_QUERY_DDL | EVENT_QUERY_DML | EVENT_QUERY_DML_NO_SELECT |\n EVENT_QUERY_DCL)))\n return 0;\n```\n\n`events` is the operator's `server_audit_events` setting. The PoC's `server_audit.cnf` configures the typical security-conscious value:\n\n```ini\nserver_audit_events=QUERY_DCL,QUERY_DDL,QUERY_DML\n```\n\n`events` = `8 | 16 | 32` = `56`. The mask is `120`. For `SET PASSWORD`:\n\n```\nthd_sql_command(thd) = SQLCOM_SET_OPTION\nsql_command_to_cmdtype(SQLCOM_SET_OPTION) = EVENT_QUERY_ALL = 2\nevents & cmdtype & 120 = 56 & 2 & 120 = 0\nreturn 0 /* not logged */\n```\n\nThe 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.\n\nIn MariaDB 11.8.5, the same query landed in `dcl_keywords`:\n\n```c\nstruct sa_keyword dcl_keywords[]=\n{\n {6, \"CREATE\", user_word, SQLCOM_DCL},\n {6, \"CREATE\", or_replace_user_word, SQLCOM_DCL},\n {4, \"DROP\", user_word, SQLCOM_DCL},\n {6, \"RENAME\", user_word, SQLCOM_DCL},\n {5, \"GRANT\", 0, SQLCOM_DCL},\n {6, \"REVOKE\", 0, SQLCOM_DCL},\n {3, \"SET\", password_word, SQLCOM_DCL},\n {0, NULL, 0, SQLCOM_NOTHING}\n};\n```\n\n`filter_query_type` matched `SET` and recursed on the next keyword `PASSWORD`, returned `SQLCOM_DCL`, the filter passed, the line got written:\n\n```\n20260504 04:45:07,...,QUERY,mysql,'SET PASSWORD\n# CVE-2026-3494 Test\nFOR \\'u01\\'@\\'localhost\\' = PASSWORD(*****)',1133\n```\n\nThe vulnerable version logged `SET PASSWORD`. The patched version does not.\n\n## The unenumerated half of the SQLCOM space\n\n`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`.\n\nStatements an auditor specifically cares about that are not in the switch:\n\n- `SQLCOM_SET_OPTION`: every form of SET, including `SET PASSWORD`, `SET ROLE`, `SET TRANSACTION`.\n- `SQLCOM_ALTER_USER`: modify user (account lock, password expire, resource limits).\n- `SQLCOM_INSTALL_PLUGIN`, `SQLCOM_UNINSTALL_PLUGIN`: install or remove server-level plugins, including the audit plugin itself.\n- `SQLCOM_KILL`: terminate sessions.\n- `SQLCOM_FLUSH`, `SQLCOM_RESET`: flush privileges, reset slave state, purge logs.\n- `SQLCOM_BEGIN`, `SQLCOM_COMMIT`, `SQLCOM_ROLLBACK`: transaction control.\n- `SQLCOM_SLAVE_START`, `SQLCOM_SLAVE_STOP`, `SQLCOM_CHANGE_MASTER`: replication control.\n- `SQLCOM_SHOW_GRANTS`: enumerate another principal's privileges.\n- `SQLCOM_BACKUP`, `SQLCOM_BACKUP_LOCK`: backup operations.\n\nNone 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.\n\n## What the PoC actually documents\n\nThe 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.\n\nThe 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.\n\n## Idle indistinguishable from broken\n\nA `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:\n\n- queries that ran and were configured to be logged: one line each.\n- queries that ran and were configured NOT to be logged: no line.\n- queries that ran and that the classifier produced an unmapped value for: no line.\n\nCases 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.\"\n\nThis is the [idle-indistinguishable-from-broken](/patterns/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.\n\nAWS 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.\n\nPoC: [KKongTen/CVE-2026-3494](https://github.com/KKongTen/CVE-2026-3494)","closing_line":"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.","hook_md":"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`.","post_id":326,"slug":"mariadb-cve-2026-3494-fix-stopped-logging-set-password","title":"CVE-2026-3494: The Fix For An Audit Bypass Stopped Logging SET PASSWORD","type":"initial","unreadable_sentence":"The vulnerable version logged SET PASSWORD. The patched version does not."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaiG9NwAKCRDeZjl4jgkQ JnC+AP9qHDVdw48WFcZDZx9MyIt51yckom1/P89z8UwIG0rrFgEAu7XpB5qRHpOs +nq2PElvaI+l7jf73UxkWq68+7zDWA0= =qxml -----END PGP SIGNATURE-----