DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

MariaDB 13.0.1-rc RCE: Priv-Esc + Heap UAF + JOP Chain to system()

On the unmodified, stock MariaDB 13.0.1-rc Docker image, a chain of five steps gets an attacker from a low-privilege SQL account all the way to command execution as uid 999 (mysql). No host access, no docker commands, no direct /proc/<pid>/mem writes — every step is a plain SQL statement. Found with RAPTOR and raptor-loop-hunt (an autonomous offensive research framework and its iterative hunt plugin).

The chain:

  1. GRANT PROXY privilege escalation (F-09) — any account → full DBA
  2. ASLR defeat via /proc/self/maps leak
  3. SYS_REFCURSOR use-after-free (F-05, unfixed upstream 0day)
  4. Heap spray + JOP (Jump-Oriented Programming) chain
  5. A "pure SQL" trick for resolving a self-referential address, using glibc's mmap slot reuse

1. F-09 — Privilege Escalation: Hijacking root via GRANT PROXY

GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '' ;
Enter fullscreen mode Exit fullscreen mode

One statement, and any authenticated user becomes a full DBA. The root cause sits in the authentication-clause validation path.

  • When the authentication clause is empty, as in IDENTIFIED VIA '', LEX_USER::has_auth() returns false.
  • A false from has_auth() skips the privilege-check routine check_alter_user().
  • But downstream, replace_user_table() still applies that empty auth clause — overwriting root's credentials with an empty password.

The gap is between "no auth info supplied → nothing to validate" and "no auth info supplied → apply it anyway, even if empty." No special privileges are required, and this reproduces on every released MariaDB version. The fix (dbd60d0ad8d, MDEV-40470) exists on dev branches but has not been backported to any release — verified from 13.0.1 through 10.6.27.

2. ASLR Defeat: Reading /proc/self/maps Through SQL

LOAD DATA INFILE '/proc/self/maps' INTO TABLE ...
Enter fullscreen mode Exit fullscreen mode

On the stock image, secure_file_priv is unset (NULL). Combined with FILE privilege, LOAD DATA INFILE can read arbitrary files as the server process — including /proc/self/maps, which hands back the mariadbd process's own memory layout as SQL output. This directly leaks the PIE base and libc base. No separate info-leak bug is required; it's purely the combination of an unset config default and a built-in SQL file-read primitive. The addresses change on every run, but since they're read live from the running process each time, that's a non-issue for the exploit.

3. F-05 — SYS_REFCURSOR Use-After-Free (Unfixed 0day)

The vulnerable function is sp_cursor_array::get_cursor_by_ref().

Materialized_cursor::open() -> result->prepare()
  -> mov rax, [result]       ; rax = attacker's vtable pointer (V)
  -> call [rax + 0x20]       ; calls the D2 gadget (prepare()'s vtable slot)
Enter fullscreen mode Exit fullscreen mode

This function returns an interior pointer into a Dynamic_array backing the cursor array. The array is managed via my_realloc, and that's where the bug lives:

  1. Attacker-controlled SQL running inside a cursor's open() method opens additional cursors.
  2. The cursor array has to grow, my_realloc allocates new storage, and the old backing storage is freed.
  3. The caller's already-cached pointer into the old storage is never updated — it's now dangling.

The freed chunk is exactly 16 cursors x 112 bytes = 1792 bytes. A heap spray of 128 user-variable copies, each sized at an exact-fit 1784 bytes, reclaims that chunk. The spray payload plants an attacker-controlled vtable pointer at offset 0x20 — the location of sp_cursor's result member — and the next virtual dispatch through that member hijacks control flow.

As of 2026-08-03, this bug is still unfixed upstream: zero commits to sql/sp_cursor.{cc,h} between the 13.0.1 tag and HEAD.

4. The JOP Chain: system() Without ROP or a Stack Pivot

Two gadgets from the stock mariadbd binary are enough to reach system().

Gadget Offset Instruction Purpose
D2 PIE+0x80da77 call *0x100(%rax) Stack alignment fix
D1 PIE+0xe3075b mov rdi,[rax+0xa8]; call [rax+0xa0] Load command pointer, call system()

The fake vtable V lives inside the 128 MiB buffer built earlier, laid out as:

V+0x20  = D2          (prepare()'s vtable slot)
V+0xa0  = system()    (libc+0x5c560)
V+0xa8  = V+0x140     (pointer to the command string -> loaded into rdi)
V+0x100 = D1          (JOP dispatcher)
V+0x140 = "sh -c '<cmd>'\0"
Enter fullscreen mode Exit fullscreen mode

This is a classic JOP chain: control flow moves purely through chained virtual-function calls, never touching the stack, so stack canaries and return-address protections are simply irrelevant here.

5. Resolving a Self-Referential Address Using Only SQL

There's a chicken-and-egg problem baked into the layout above: V+0xa8 needs to hold the address of the buffer itself (V+0x140), but that address isn't known until the buffer is allocated. This is solved with a quirk of glibc's mmap behavior.

-- Step 1: allocate a 128 MiB marker buffer
SET @fake = REPEAT(CHAR(0xDE), 134217728);
-- glibc hands out a dedicated mmap region (e.g. 0x8001000, data at +0x30)
-- the address is found by diffing /proc/self/maps before and after

-- Step 2: re-allocate with the full JOP layout, self-reference included
SET @fake = CONCAT(REPEAT(...), UNHEX('<JOP layout>'), REPEAT(...));
-- glibc munmaps the old chunk and reuses the exact same slot,
-- so the address discovered in step 1 stays valid
Enter fullscreen mode Exit fullscreen mode

In order:

  1. Allocate a 128 MiB marker buffer → glibc grants a dedicated mmap region → its address is recovered via a /proc/self/maps diff.
  2. Re-allocate the same variable with the complete layout → glibc reuses the same mmap slot → the self-reference baked in during step 1 remains valid.
  3. Each step re-reads /proc/self/maps to verify; if the address ever moved, the self-reference is re-baked and the write retried (converges in one iteration in practice).

The choice of 128 MiB is deliberate: glibc's dynamic mmap threshold can grow past 4 MiB after large frees, and 128 MiB reliably lands a dedicated mmap region regardless (128 MiB and 256 MiB were both verified). One catch — a buffer larger than max_allowed_packet simply fails, so SET GLOBAL max_allowed_packet has to be raised first, with a fresh connection afterward. The +0x30 data offset within the mmap region is also glibc/image-specific and is exposed as a DATA_OFF constant in the code for that reason.


Full Attack Flow

1. GRANT PROXY ...                     -> root account hijacked (F-09)
2. LOAD DATA INFILE '/proc/self/maps'  -> PIE/libc base recovered (ASLR defeat)
3. SET @fake = REPEAT(...)  128MiB     -> mmap slot secured, address found via diff
4. SET @fake = CONCAT(... UNHEX(...))  -> self-referential JOP layout written
5. CALL uaf5                           -> SYS_REFCURSOR UAF triggered (F-05)
                                        -> heap spray reclaims the freed chunk
                                        -> result->prepare() virtual call
                                        -> D2 -> D1 -> system("sh -c '<cmd>'")
6. mariadbd is PID 1 -> container exits right after system() returns
                                        -> restart, read the marker file as proof
Enter fullscreen mode Exit fullscreen mode

All that's required to run this is a MariaDB account with USAGE-only privileges and TCP reachability to port 3306 — no host shell, no docker commands, no /proc/<pid>/mem access, and no root password. (The repo also keeps a legacy host-assisted PoC, exploit.py, which writes the JOP chain via /proc/<pid>/mem from the Docker host — it's explicitly noted as superseded by the pure-SQL variant.)

What Makes This Chain Interesting

  • F-09 is a pure logic bug in the auth-clause validation path, not a memory-safety issue, yet by itself it's a complete DBA takeover.
  • The ASLR defeat needs no separate info-leak vulnerability — an unset secure_file_priv plus LOAD DATA INFILE is enough to pull /proc/self/maps straight out through SQL.
  • The UAF trigger is a reentrancy pattern that only exists at the SQL layer: SQL executed from inside a cursor callback grows the very cursor array the callback is iterating over.
  • From heap grooming to resolving a self-referential pointer, SQL statements themselves are the exploitation primitive — no /proc/<pid>/mem writes, no host-side tooling, and a correspondingly much smaller detection surface than the traditional approach.

Top comments (0)