Research/mysql-jdv-definer-sqli
0-dayNo CVEHighPublic

MySQL JSON Duality DEFINER SQLi, NO_BACKSLASH_ESCAPES

JSON Duality DML concatenates JSON strings with backslash escaping, then parse_sql under invoker sql_mode as DEFINER. NO_BACKSLASH_ESCAPES turns a quote into SQL. Labbed 26.7.0. Feature-gated. No CVE yet.

Name
MySQL JSON Duality DEFINER SQLi, NO_BACKSLASH_ESCAPES
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Oracle MySQL
Affected
MySQL Community Server mysqld 26.7.0 (06a5c1c). Authenticated DML on a JSON Duality View. Session sql_mode NO_BACKSLASH_ESCAPES.
Published
07 Oct 2026
Updated
07 Oct 2026
Tags
0-day, mysql, json-duality, cwe-89, sql-injection

The quote was in JSON. The subquery ran as DEFINER.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/mysql-jdv-definer-sqli (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is MySQL Community Server 26.7.0 (06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8) mysqld. No CVE yet. CWE-89. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N). Authenticated. Feature-gated. You need INSERT or UPDATE on a JSON Duality View. You set sql_mode to NO_BACKSLASH_ESCAPES. You UPDATE a string field whose JSON value contains '. The generated statement is parsed as DEFINER. A subquery reads a table the invoker cannot touch. The projected column shows the witness.

Default CREATE VIEW is DEFINER. Session sql_mode needs no extra privilege. X Plugin already splits escaping on that mode. JSON Duality DML does not.

Fifth leftover, still the same pin

I had already published four leftovers from 26.7.0: mysqldump SHOW TABLES overflow, mysqldump --tab path, mysqlbinlog --raw Rotate path, SET ROLE ALL EXCEPT leftover FILE. Three of those are clients. One is ACL. Default empty mysqld unpublished Crit/High stayed empty through thirty-one hunts. JSON Duality is off the default empty server: no JDV on a stock init. An app that ships a duality view is the victim.

July 2026 JSON Duality CPU rows are CVE-2026-60194 and CVE-2026-60195: PR:H 4.9 hang/crash on 9.7.0-9.7.1. DoS. Skip. This leftover is string-concat DML as DEFINER, not those CVEs. I wanted Critical or High. A DEFINER subquery that fills a projected column is High.

Oracle Innovation is calendar versioning. 26.7.0 is the full Community GA from 28 July 2026, lineage from 9.7. GitHub tags mysql-26.7.0 and mysql-cluster-26.7.0 share 06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8. Official image mysql:26.7.0 is that binary. JSON Duality is in this Innovation line. The lab uses that image.

Thirty-one hunts walked ACL, auth plugins, connection handling, X Plugin leftovers of 60315/60316, FILE/LOAD, replication, group replication, clone, stored DEFINER, UDF and components, Community thread pool, keyring, TLS including PQC, JSON/GIS/duality, InnoDB paths, Windows configurator, client tools, Router HTTP, Router MRS, Change Stream Applier, I_S, federated, classic protocol, NDB, telemetry, bootstrap, optimizer privilege skip, event scheduler, binlog encryption, parser, admin port. Duality DML is this one.

JSON_EXTRACT / JSON_TABLE stay in-document. JSON Schema remote $ref is rejected. GIS parsers fail closed. INVOKER skip of view-level INSERT is closed: check_view_privileges still runs. Finding is DML-as-DEFINER beyond the view projection after a granted DML privilege.

Concatenate, swap sctx, parse

append_json_dom in sql/json_duality_view/dml.cc turns a JSON value into SQL text. Numbers are bare. Null is NULL. Strings, dates, objects, arrays:

C
sbuf.append("_utf8mb4 '");
auto cur_size = sbuf.size();
std::size_t bytes_generated = ERROR_INDICATOR;
for (sbuf.resize(cur_size + 1);
     (bytes_generated = escape_string_for_mysql(
          &my_charset_utf8mb4_bin, &sbuf.front() + cur_size,
          sbuf.size() - cur_size, buf.ptr(), buf.length())) ==
     ERROR_INDICATOR;
     sbuf.resize((sbuf.size() * 2) - cur_size)) {
}
sbuf.resize(cur_size + bytes_generated);
sbuf.append(1, '\'');

escape_string_for_mysql is backslash escaping. ' becomes \'. NO_BACKSLASH_ESCAPES is a session sql_mode bit anyone can set. Under that mode, \' is a backslash, then end of string. The rest of the buffer is SQL.

Json_wrapper::to_string(..., json_quoted=false) emits the raw string. A JSON value "x', name=(SELECT note FROM secret.s LIMIT 1)--" keeps the '. JSON itself uses ". The quote does not have to be JSON-escaped.

Before that statement runs, create_sctx_guard installs the view DEFINER:

C
Security_context *orig_sctx = thd->security_context();
if (dvtr->view_sctx != nullptr) {
  thd->set_security_context(dvtr->view_sctx);
}

jdv_insert / jdv_update / jdv_delete all take the guard, then make_insert / make_update / make_delete, then run_substmt. run_substmt is Statement_runnable::execute_server_code → parse_sql with invoker sql_mode and multi_statements=false. Stacked queries are out. A SET-list assignment or a subquery in the same statement is enough.

Table names are backtick-quoted from the content tree. Values are not parameterized. X Plugin query_string_builder.cc already does escape_sql_quote vs escape_string_for_mysql on this mode. JDV never reads MODE_NO_BACKSLASH_ESCAPES (only MODE_NO_AUTO_VALUE_ON_ZERO nearby).

CREATE VIEW default LEX::create_view_suid is true. Open path loads view_sctx via acl_getroot. Invoker still needs INSERT or UPDATE on the view. check_view_privileges is not the skip. The skip is "the generated SQL can name other objects as DEFINER."

A numeric projected column is the wrong oracle. append_json_dom emits bare integers for J_INT. There is no quote to close. The in-tree duality tests use INT keys a lot (jdv_dml_json_check.test). I used VARCHAR(512) name so the value goes through the _utf8mb4 '...' branch. UPDATE is the SET-list path: name=_utf8mb4 '...' is where name=(SELECT ...) belongs. INSERT is VALUES (...), which still injects, and is a worse demo. Etag is a duality DML checksum of the current document. Missing it is ER_JDV_MISSING_VALUE. Wrong it is ER_JDV_ETAG_MISMATCH. The first SELECT is not optional.

NO_BACKSLASH_ESCAPES is in the session sql_mode names table. No extra privilege. Apps that pin sql_mode at connect still lose if the session can SET SESSION. Apps that never set it are on the negative path: the quote stays data.

The lab

Host is macOS. One official mysql:26.7.0. Compose project mysql-jdv-definer-sqli. Loopback 127.0.0.1:18640. Root password labroot.

Two schemas. DEFINER owns both. Victim can only DML the view.

Plain text
CREATE DATABASE app;
CREATE DATABASE secret;
CREATE TABLE app.t (id INT PRIMARY KEY, name VARCHAR(512) NOT NULL);
CREATE TABLE secret.s (id INT PRIMARY KEY, note VARCHAR(128) NOT NULL);
INSERT INTO secret.s VALUES (1, 'MYSQL-JDV-DEFINER-SQLI-WITNESS');
INSERT INTO app.t VALUES (1, 'ok');
CREATE JSON DUALITY VIEW app.dv AS
SELECT JSON_DUALITY_OBJECT(
  WITH (INSERT, UPDATE, DELETE)
  "_id" : id,
  "name" : name
) FROM app.t;
CREATE USER 'victim'@'%' IDENTIFIED BY 'victimpass';
GRANT SELECT, INSERT, UPDATE ON app.dv TO 'victim'@'%';

SHOW CREATE VIEW is DEFINER=\root`@`%` SQL SECURITY DEFINER JSON RELATIONAL DUALITY VIEW. Default is DEFINER. Putting SQL SECURITY DEFINERbetween the view name andAS` is 1064 on this pin. I omitted it.

Victim SELECT note FROM secret.s is 1142. CURRENT_USER() is victim@%. USER() is victim@192.168.176.1. % matches. Grants are USAGE plus SELECT/INSERT/UPDATE on app.dv.

Default sql_mode is the usual STRICT set, no NO_BACKSLASH_ESCAPES. SELECT the view: {"_id": 1, "name": "ok", "_metadata": {"etag": "cb522cc68248ace32faf022f7cc843e9"}}. UPDATE needs that etag (ER_JDV_MISSING_VALUE / ER_JDV_ETAG_MISMATCH in the in-tree tests).

Negative, default mode, same JSON idea: name becomes the literal x', name=(SELECT note FROM secret.s LIMIT 1)-- . Witness is not in $.name. Backslash escape held. Reset the row to ok.

Then SET SESSION sql_mode='NO_BACKSLASH_ESCAPES'. Same etag. UPDATE with a different prefix (z' instead of x'), because the engine skips an is_equal no-op if the JSON name matches the current row. NBE UPDATE succeeds. SELECT the view: $.name is MYSQL-JDV-DEFINER-SQLI-WITNESS. The subquery ran as root@%. The invoker never read secret.s.

Last run:

Plain text
IOC secret-direct ok=False errno=1142
IOC default-mode view names=["x', name=(SELECT note FROM secret.s LIMIT 1)-- "]
IOC sql_mode nbe='NO_BACKSLASH_ESCAPES'
IOC NBE view names=['MYSQL-JDV-DEFINER-SQLI-WITNESS']
SUCCESS mysql-jdv-definer-sqli victim-secret=denied nbe-inject=yes view-has-witness=yes default-mode-inject=no dump=26.7.0 image=mysql:26.7.0 MYSQL-JDV-DEFINER-SQLI-WITNESS

Replay:

Plain text
git clone https://github.com/abraxas/mysql-jdv-definer-sqli
cd mysql-jdv-definer-sqli/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it. The oracle is a witness string in a projected column. Not a shell.

Wrong turns, in the order I made them

SQL SECURITY DEFINER in the CREATE text. Syntax 1064 next to AS. Default is already DEFINER. SHOW CREATE VIEW proves it after the short form.

Same name as the current row. First NBE payload reused x'... after the negative had already stored that literal, then reset. If you inject a value is_equal to the live JSON, JDV skips the UPDATE. Prefix z' after reset to ok.

Skipping the etag. Tests want _metadata.etag. First SELECT supplies it. INSERT fallback unused.

Calling JSON Duality DoS CVEs this bug. 60194/60195 are PR:H hangs. This is DEFINER SQLi. Different class, same component.

Claiming default-install. No JDV on a stock init. Someone creates the view. The leftover is the DML compiler, not a missing GRANT.

Stacked queries. multi_statements=false. One SET-list / subquery. The lab is a scalar subquery into name.

Using a stub mysqld. G1-G3 needed hostile protocol. G5 is real duality DML. Official mysql:26.7.0 is the oracle.

INVOKER as a workaround in the lab. I wanted DEFINER, which is the default. An INVOKER view would run the injected SQL as victim and 1142 secret.s. That would be FAIL for this leftover. SHOW CREATE VIEW has to say DEFINER.

JSON object injection instead of a string field. Objects and arrays also take the quoted branch. A string column is the smallest SET-list. I stayed there.

Reading $.name from a new connection. sql_mode is session. The leak is in the row. A later SELECT without NBE still shows the witness. The column was written as DEFINER. That is the point.

What an attacker can do

Hold SELECT plus INSERT or UPDATE on a DEFINER JSON Duality View that projects a string (or JSON) column. SET SESSION sql_mode='NO_BACKSLASH_ESCAPES'. SELECT the etag. UPDATE a JSON document whose string value contains ' plus SQL that DEFINER can run. Read the view. The projected field is DEFINER data the invoker was not granted.

Remote authenticated SQL. UI none. PR low: DML on the view, which the app already gave the user. Scope is the DEFINER account. Unauth this is not.

G1-G3 were clients. G4 was leftover FILE after SET ROLE. G5 is DEFINER SQLi on a feature. I published them separately because they disclose separately.

The fix

Escape JDV DML strings with the same NO_BACKSLASH_ESCAPES branch X Plugin already has. Parameterize make_insert / make_update. Do not parse_sql concatenated values under a DEFINER sctx. Until then: do not GRANT DML on a DEFINER duality view to an account you do not trust with the DEFINER's tables, and do not let that account set NO_BACKSLASH_ESCAPES if you do.

I am not going to print a GRANT plus UPDATE you can paste at someone else's API. The missing sql_mode branch is the useful part. If you own the daemon, run the proof of concept against loopback.

References