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, "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.
CVE-2026-32710: MariaDB's "Long Enum Crash" Is Already in mysql.global_priv
pattern
cve
proof of concept
The strncpy is the only branch in this function that does not grow the DYNAMIC_STRING
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:
void json_get_normalized_string(json_engine_t *je, String *res,
int *error, MEM_ROOT *current_mem_root,
json_engine_t *temp_je,
MEM_ROOT_DYNAMIC_ARRAY *stack)
{
char *val_begin= (char*)je->value, *val_end= NULL;
String val("",0,je->s.cs);
DYNAMIC_STRING a_res;
if (init_dynamic_string(&a_res, NULL, 0, 0))
goto error;
// ...
if (je->value_type == JSON_VALUE_NUMBER ||
je->value_type == JSON_VALUE_ARRAY ||
je->value_type == JSON_VALUE_OBJECT)
{
if (json_normalize(&a_res, (const char*)val.ptr(),
val_end-val_begin, je->s.cs, current_mem_root, temp_je, stack))
goto error;
}
else if(je->value_type == JSON_VALUE_STRING)
{
strncpy((char*)a_res.str, val.ptr(), je->value_len);
a_res.length= je->value_len;
}
res->append(a_res.str, a_res.length, je->s.cs);
// ...
}The 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.
Three 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.
The 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.
MDEV-38356 named the symptom. CVE-2026-32710 named the class.
The fix is one line:
- strncpy((char*)a_res.str, val.ptr(), je->value_len);
+ dynstr_set(&a_res, val.c_ptr());
a_res.length= je->value_len;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.
The MDEV title is "JSON_SCHEMA_VALID() crashes with a long enum." The regression test the patch ships looks like this:
select json_schema_valid('{"enum":[0]}', sformat('"{:#>200}"','0'));The 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.
That 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.
SELECT to ALL PRIVILEGES, in one SELECT
The 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:
SELECT
json_schema_valid('{"enum":[0]}',
CONCAT('"', REPEAT(CHAR(0x41), 192), CHAR(<lo>), CHAR(<hi>), '"')),
@ccc...c := CONCAT(<8 bytes: &master_access>, CHAR(9), REPEAT(CHAR(0),7),
CHAR(3), REPEAT(CHAR(0),7), CHAR(3), REPEAT(CHAR(0),7),
REPEAT(CHAR(0),64), CHAR(0xA1), REPEAT(CHAR(0),29)),
@aaa...a := REPEAT(CHAR(0xFF), 8);A 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.
Before the chain, the script issues 100 SET @e<unique_name> = 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.
Inside one SELECT, three things happen in evaluation order:
json_schema_validoverflows the 128-bytea_res.strbuffer by 66 bytes. The validator reaches the enum membership test, callsjson_get_normalized_stringon the 194-byte stringAAAA...A<lo><hi>(192 A's plus two control bytes), andstrncpywrites 194 bytes starting ata_res.str. The last two bytes land on the low 16 bits ofentry_c → value, a partial-pointer overwrite. The high bits of the pointer are unchanged, soentry_c → valuenow points at&entry_a + 32, the offset insideentry_awhere its ownvalueandlengthfields live. Bothentry_c → valueand&entry_a + 32are 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.@ccc...c := <126 bytes>writes through the redirected pointer intoentry_a. The first 8 bytes of the payload are the absolute address ofSecurity_context::master_access(lab-supplied by/proc/1/mem). The next 8 bytes setentry_a → lengthto9. The rest of the payload restores adjacent heap metadata so the in-memory user variable table stays walkable. After this hop,entry_a → valuepoints atmaster_accessandentry_a → lengthis9.@aaa...a := REPEAT(CHAR(0xFF), 8)writes eight bytes of0xFFthroughentry_a → value. TheSTRING_RESULTwriter takes the storedlength(now9), realloc-checks atlength + 1and 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", becomes0xFFFFFFFFFFFFFFFF.
The PoC's per-attempt /proc/1/mem scan supplies <lo>, <hi>, 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.
After 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.
GRANT ALL is the persistence. The session crash is the cleanup. The restart is the campaign's exit.
The script's next two statements are the move from in-memory escalation to on-disk persistence:
GRANT ALL PRIVILEGES ON *.* TO 'lowpriv'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;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.
After 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.
The 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.
The 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.
Stage 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.
What the patch closes, and what it does not
This is a 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.
The 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.
The MDEV ticket title is "JSON_SCHEMA_VALID() crashes with a long enum." The crash is the symptom. The cleanup is the campaign's exit.
The patch closes the OOB. mysql.global_priv keeps what the OOB wrote.