-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The strncpy is the only branch in this function that does not grow the DYNAMIC_STRING\n\n`json_get_normalized_string` in `sql/json_schema_helper.cc` is the canonicalizer the schema validator calls when it has to compare two JSON values for equality. The function allocates a `DYNAMIC_STRING`, normalizes the value into it, and hands the result to the caller. The buggy version, present through MariaDB 11.4.9, looks like this:\n\n```cpp\nvoid json_get_normalized_string(json_engine_t *je, String *res,\n int *error, MEM_ROOT *current_mem_root,\n json_engine_t *temp_je,\n MEM_ROOT_DYNAMIC_ARRAY *stack)\n{\n char *val_begin= (char*)je->value, *val_end= NULL;\n String val(\"\",0,je->s.cs);\n DYNAMIC_STRING a_res;\n\n if (init_dynamic_string(&a_res, NULL, 0, 0))\n goto error;\n // ...\n if (je->value_type == JSON_VALUE_NUMBER ||\n je->value_type == JSON_VALUE_ARRAY ||\n je->value_type == JSON_VALUE_OBJECT)\n {\n if (json_normalize(&a_res, (const char*)val.ptr(),\n val_end-val_begin, je->s.cs, current_mem_root, temp_je, stack))\n goto error;\n }\n else if(je->value_type == JSON_VALUE_STRING)\n {\n strncpy((char*)a_res.str, val.ptr(), je->value_len);\n a_res.length= je->value_len;\n }\n\n res->append(a_res.str, a_res.length, je->s.cs);\n // ...\n}\n```\n\nThe call to `init_dynamic_string(&a_res, NULL, 0, 0)` allocates a 128-byte initial buffer. That number comes from the `mysys/string.c` defaults: when `alloc_increment` is zero it becomes 128, and when both `init_str` and `init_alloc` are NULL/0 the allocator hands back a buffer the size of one increment. The container's whole point is that it grows. `mysys/string.c` exports `dynstr_set`, `dynstr_append`, `dynstr_append_mem`, `dynstr_realloc`, all of which check `str->max_length` and `my_realloc` before writing.\n\nThree branches in this function use that growth. The `NUMBER`, `ARRAY`, and `OBJECT` cases each call `json_normalize`, which internally uses `dynstr_append_mem` and grows on every append. The fourth branch, `JSON_VALUE_STRING`, takes the value the caller provided, hands its length to `strncpy`, and writes that many bytes into the 128-byte initial buffer with no `dynstr_realloc` call in front of it.\n\nThe string branch is the only branch that handles a JSON value type whose length is unbounded by the schema. Numbers have a bounded textual form. Arrays and objects go through a recursive normalizer that controls its own writes. Strings are whatever the caller sends. That is the branch that bypassed the growth API.\n\n## MDEV-38356 named the symptom. CVE-2026-32710 named the class.\n\nThe fix is one line:\n\n```diff\n- strncpy((char*)a_res.str, val.ptr(), je->value_len);\n+ dynstr_set(&a_res, val.c_ptr());\n a_res.length= je->value_len;\n```\n\n`dynstr_set` is in the same `mysys/string.c` that allocated the buffer. Its first action on a `DYNAMIC_STRING` is to check `length > str->max_length` and `my_realloc` if so. The patch's diff stat is two lines added, two lines removed, both inside the same `else if` block. The author who wrote `strncpy` had `dynstr_set` available in the same file. The author who wrote the patch is the same project. The patch keeps the `a_res.length= je->value_len;` line that follows; the only thing wrong was the copy.\n\nThe MDEV title is \"JSON_SCHEMA_VALID() crashes with a long enum.\" The regression test the patch ships looks like this:\n\n```sql\nselect json_schema_valid('{\"enum\":[0]}', sformat('\"{:#>200}\"','0'));\n```\n\nThe schema declares the only allowed enum value is the integer `0`. The validated input is a 200-character string of `#` characters, formatted as `\"##########...#\"`. The expected result is `0`, meaning \"the string does not match the integer-0 enum, this is a valid no-match.\" Before the patch, the validator reached the membership comparison, called `json_get_normalized_string` on the 200-byte input, ran `strncpy` past the 128-byte buffer, and crashed. The patch makes the comparison return `0` cleanly.\n\nThat test captures the failure mode the maintainer was looking at. It does not capture the failure mode the heap was in when the crash happened. A 72-byte controlled overflow with attacker-supplied content is not a crash bug. It is a heap write primitive. The CVE-2026-32710 record is the assignment that names it.\n\n## SELECT to ALL PRIVILEGES, in one SELECT\n\nThe PoC is a single Python script that speaks the MariaDB wire protocol directly and sends the chain as a single `SELECT` statement. The interesting part is what the SQL looks like once the heap layout is known:\n\n```sql\nSELECT\n json_schema_valid('{\"enum\":[0]}',\n CONCAT('\"', REPEAT(CHAR(0x41), 192), CHAR(), CHAR(), '\"')),\n @ccc...c := CONCAT(<8 bytes: &master_access>, CHAR(9), REPEAT(CHAR(0),7),\n CHAR(3), REPEAT(CHAR(0),7), CHAR(3), REPEAT(CHAR(0),7),\n REPEAT(CHAR(0),64), CHAR(0xA1), REPEAT(CHAR(0),29)),\n @aaa...a := REPEAT(CHAR(0xFF), 8);\n```\n\nA user variable in MariaDB is a `user_var_entry` struct: a name pointer, a value pointer, a length, a type. Setting a variable copies the assigned bytes through the value pointer, into a heap allocation sized by the variable's stored `length`. That is the primitive the chain weaponizes.\n\nBefore the chain, the script issues 100 `SET @e = REPEAT('X', 127)` statements to drain the relevant tcache bin, then places three sentinel variables (`@aaa...a`, `@bbb...b`, `@ccc...c`, each name 40 bytes) so that their `Entry → Value` allocations land adjacent on the heap. The names matter only as a stable signature the scanner can recognize. The script then sets `@aaa...a` to a 500-byte string so its `Value_a` allocation is large enough to land in the same 64 KB page as the `a_res.str` buffer that `json_get_normalized_string` will allocate inside the same connection.\n\nInside one `SELECT`, three things happen in evaluation order:\n\n1. **`json_schema_valid` overflows the 128-byte `a_res.str` buffer by 66 bytes.** The validator reaches the enum membership test, calls `json_get_normalized_string` on the 194-byte string `AAAA...A` (192 A's plus two control bytes), and `strncpy` writes 194 bytes starting at `a_res.str`. The last two bytes land on the low 16 bits of `entry_c → value`, a partial-pointer overwrite. The high bits of the pointer are unchanged, so `entry_c → value` now points at `&entry_a + 32`, the offset inside `entry_a` where its own `value` and `length` fields live. Both `entry_c → value` and `&entry_a + 32` are in the same 64 KB page, so a 2-byte overwrite is enough. ASLR does not matter; the same-page constraint is the only constraint.\n\n2. **`@ccc...c := <126 bytes>` writes through the redirected pointer into `entry_a`.** The first 8 bytes of the payload are the absolute address of `Security_context::master_access` (lab-supplied by `/proc/1/mem`). The next 8 bytes set `entry_a → length` to `9`. The rest of the payload restores adjacent heap metadata so the in-memory user variable table stays walkable. After this hop, `entry_a → value` points at `master_access` and `entry_a → length` is `9`.\n\n3. **`@aaa...a := REPEAT(CHAR(0xFF), 8)` writes eight bytes of `0xFF` through `entry_a → value`.** The `STRING_RESULT` writer takes the stored `length` (now `9`), realloc-checks at `length + 1` and matches, and writes the 8 bytes plus a NUL through the redirected pointer. `Security_context::master_access`, the eight-byte bitfield the privilege check reads as the source of truth for \"what is this session allowed to do\", becomes `0xFFFFFFFFFFFFFFFF`.\n\nThe PoC's per-attempt `/proc/1/mem` scan supplies ``, ``, and the 8 address bytes for `&master_access`. The single `SELECT` does the rest with no further introspection. A weaponized variant needs to replace the scan with an info-leak primitive that exposes one user-variable address and one `Security_context` address from the same connection. That is the only piece of the chain that is currently lab-assisted, and the PoC says so on the first page of its README.\n\nAfter the chain, the session's in-memory `master_access` is all ones. `SHOW GRANTS FOR CURRENT_USER()` is still cached from connect time, but `SELECT * FROM mysql.global_priv LIMIT 1` succeeds.\n\n## GRANT ALL is the persistence. The session crash is the cleanup. The restart is the campaign's exit.\n\nThe script's next two statements are the move from in-memory escalation to on-disk persistence:\n\n```sql\nGRANT ALL PRIVILEGES ON *.* TO 'lowpriv'@'%' WITH GRANT OPTION;\nFLUSH PRIVILEGES;\n```\n\n`GRANT` consults `Security_context::master_access` to decide whether the caller is allowed to issue it. The bitfield is all ones; the check passes. `GRANT` writes the new privilege bitmap to `mysql.global_priv`, which is an Aria-backed table on disk. The script then issues `SELECT SLEEP(10)` to give Aria time to flush its log and checkpoint the row before the connection collapses.\n\nAfter the sleep, the session does crash. Residual heap corruption from the OOB takes the connection down during cleanup. The crash is not silent: `mariadbd` writes a stack trace to the error log. To an operator reading the log, this is the symptom MDEV-38356 documented. The fix to the function the crash points at is to update past 11.4.9.\n\nThe crash does not undo the GRANT. The Aria checkpoint completed before the session died. `mysql.global_priv` contains a row that says `lowpriv@%` has the privilege bitmap `0xFFFFFFFFFFFF` (the on-disk encoding of ALL PRIVILEGES), inserted at a wall-clock timestamp during the attack window, with `Priv` JSON containing a non-default `access` value. The next time `mariadbd` starts, the privilege loader reads the table at startup and seats `lowpriv@%` as a fully privileged account. The in-memory escalation was a path; the GRANT is the destination.\n\nThe patch closes the path. It does not consult `mysql.global_priv`. It does not enumerate accounts with elevated privileges whose grant timestamp falls inside the vulnerable window. It does not flag accounts whose original `init.sql` granted only `SELECT` and whose current `mysql.global_priv` row says `ALL PRIVILEGES WITH GRANT OPTION`. That work belongs to the operator, and the bug report did not mention it because the bug report was filed as a stability ticket.\n\nStage 2 of the PoC, the UDF that turns the new grants into `system()`, is interesting but is not the bug. `LOAD_FILE` plus `DUMPFILE` plus `CREATE FUNCTION ... SONAME` has been the standard MariaDB-administrator-to-shell-execution chain for over a decade. It requires `FILE` and a `plugin_dir` the `mysql` process can write into; the lab cheats on the second with `chmod 777` in the Dockerfile and is honest about it. In a hardened deployment the UDF step does not work as written, but the escalation that precedes it does. An account with `ALL PRIVILEGES WITH GRANT OPTION` against MariaDB has many paths to whatever the operator hoped MariaDB was protecting; UDF is one and it is not the most subtle.\n\n## What the patch closes, and what it does not\n\nThis is a [`design-debt-driver`](/patterns/design-debt-driver) instance. `JSON_SCHEMA_VALID` has produced multiple class-2025/2026 stability fixes in succession, MDEV-37640 (crash in `Item_func_json*::val_str`), MDEV-38033 (wrong-result on array-of-objects), MDEV-38356 (this OOB filed as a crash), and the substrate underneath them is the same: SELECT-callable validators that operate on raw inputs in C with hand-managed buffers, in a function family that grew up alongside but separately from the MariaDB JSON engine that powers the surrounding query path. Each instance gets a patch. The component keeps producing them, because the design assumes the user-controlled input that reaches it has been bounded somewhere upstream, and there is no such bounding.\n\nThe CVE record will frame this as a memory-corruption bug in MariaDB 11.4.x with a CVSS that prices in the difficulty of the heap-layout discovery the lab uses `/proc/1/mem` for. That framing is accurate about the path. It is not accurate about what the path produces. The path produces a row in a system catalog table that the database treats as authoritative on the next start. Patching the path does not delete the row. Restarting the server does not delete the row. Auditing `mysql.global_priv` for grants whose timestamp falls inside the vulnerable window, and whose holder is an account that originally received only the privilege its `CREATE USER` statement granted, is the only remediation that does.\n\nThe MDEV ticket title is \"JSON_SCHEMA_VALID() crashes with a long enum.\" The crash is the symptom. The cleanup is the campaign's exit.\n\nPoC: [dinosn/CVE-2026-32710](https://github.com/dinosn/CVE-2026-32710)","closing_line":"The patch closes the OOB. mysql.global_priv keeps what the OOB wrote.","hook_md":"A user with `SELECT` on one test table can ask MariaDB 11.4.9 to validate a 200-byte string against the JSON schema `{\"enum\":[0]}`. MariaDB normalizes the string into a 128-byte buffer with `strncpy` and writes 66 bytes past the end. The crash that follows was filed against the 11.4 branch on December 16, 2025 as [MDEV-38356](https://jira.mariadb.org/browse/MDEV-38356), \"JSON_SCHEMA_VALID() crashes with a long enum.\" The patch is one line. CVE-2026-32710 is what was under the crash. A successful run writes ALL PRIVILEGES WITH GRANT OPTION into `mysql.global_priv` before the session dies, and the GRANT survives the restart that mariadbd uses to recover from the cleanup.","post_id":280,"slug":"mariadb-json-schema-valid-crash-is-already-in-global-priv","title":"CVE-2026-32710: MariaDB's \"Long Enum Crash\" Is Already in mysql.global_priv","type":"initial","unreadable_sentence":"MDEV-38356 closes the crash. CVE-2026-32710 is what the crash was the cleanup of."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCatEIrQAKCRDeZjl4jgkQ JtRXAP9EvjHmKR9DysiPXndEW1Ha4VQ6lwzTYb9CRokFuV7C9QEAqKNSjAjyELZq B4LZEeKRdVmvSLfBbQVuCDRB+TGuwgs= =2WCq -----END PGP SIGNATURE-----