# RCA Report — MDEV-40470-F05-RCE-CHAIN: MariaDB 13.0.1-rc pure-SQL RCE chain

## Summary

A USAGE-only MariaDB account with TCP reachability to port 3306 achieves remote
code execution as `uid=999(mysql)` on the **stock, unmodified** MariaDB
13.0.1-rc Docker image (`mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9`)
using nothing but SQL statements. The chain combines: (F-09) a `GRANT PROXY ...
IDENTIFIED VIA ''` privilege escalation that hijacks the root account with an
empty password (MDEV-40470); a server-side `LOAD DATA INFILE '/proc/self/maps'`
read that discloses the live PIE and libc base addresses (ASLR defeat); a
128 MiB user-variable buffer whose address is discovered by diffing
`/proc/self/maps` from SQL; and (F-05) a `SYS_REFCURSOR` use-after-free in
`sp_cursor_array::get_cursor_by_ref()` whose freed 1792-byte array chunk is
reclaimed by an exact-fit heap spray, redirecting a virtual dispatch into a
two-gadget JOP chain (D2 → D1) that calls libc `system()` with an
attacker-chosen command string.

## Impact

- **Package/component:** `mariadb/server` — server core (`sql/sql_acl.cc`
  GRANT PROXY handling; `sql/sp_cursor.{cc,h}` cursor array).
- **Affected versions:** reproduced on 13.0.1-MariaDB-ubu2604 (pinned image).
  Per the advisory, F-09's fix (commit `dbd60d0ad8d`) exists only on dev
  branches and is absent from every released version 13.0.1 → 10.6.27; F-05 is
  unfixed upstream (no commits to `sql/sp_cursor.{cc,h}` since the 13.0.1 tag).
- **Risk level:** Critical. Any authenticated low-privilege database user gains
  full OS command execution as the `mysql` service account (uid 999), i.e.
  complete database-server compromise, remotely, over the normal SQL protocol.

## Impact Parity

- **Disclosed/claimed maximum impact:** code execution as uid 999 (mysql) from
  a USAGE-only account via pure SQL over TCP/3306.
- **Reproduced impact from this run:** identical — attacker-chosen shell
  commands executed as `uid=999(mysql)` on the stock pinned image in 6/6 fresh
  processes (5 scripted + 1 manual calibration run), each with fresh ASLR.
- **Parity:** `full`.

## Root Cause

1. **F-09 — GRANT PROXY privilege escalation (MDEV-40470).**
   `GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '';` passes an
   *empty* authentication clause. `LEX_USER::has_auth()` returns false, so the
   privilege check in `check_alter_user()` is skipped, while
   `replace_user_table()` still applies the (empty) password — replacing root's
   credentials with an empty password. One statement, any authenticated user.
   Fix: commit `dbd60d0ad8d` (dev branches only; no released version has it).
   Runtime proof: after the statement, login as `root` with `--skip-password`
   succeeds (`CURRENT_USER()=root@%`), and the pre-escalation negative control
   shows `ERROR 1045` on `LOAD DATA INFILE` and `ERROR 1227` on `SET GLOBAL`.

2. **ASLR defeat.** With the FILE privilege obtained in step 1 and
   `secure_file_priv` unset (stock), `LOAD DATA INFILE '/proc/self/maps'`
   loads mariadbd's own memory map into a table; plain SELECTs return the PIE
   base (first `r--p 00000000` mapping of `/usr/sbin/mariadbd`) and the libc
   base. Fresh values every process (observed 0x63abb3250000, 0x57b3cea2c000,
   0x55b611d36000, 0x76955fffe000-area, 0x731cf3fff000-area).

3. **F-05 — SYS_REFCURSOR use-after-free (unfixed 0day).**
   `sp_cursor_array::get_cursor_by_ref()` returns an interior pointer into a
   `Dynamic_array`. When a cursor's `open()` executes SQL that opens more
   cursors (`grow5()` opens 16 cursors, then `OPEN p FOR SELECT spray128()`),
   the array grows, `my_realloc` frees the old 1792-byte storage (16×112 B),
   and the caller's cached pointer dangles. A heap spray of 128 session user
   variables of exactly 1784 bytes (`SET @e3sNNN=@e3pad`, glibc exact-fit for
   the 1792-byte chunk) reclaims the freed storage with attacker bytes,
   placing a controlled pointer `V` at offset 0x20 (the `result` member of
   `sp_cursor`). `Materialized_cursor::open()` then dispatches virtually:
   `mov rax,[result]; call [rax+0x20]`.

4. **JOP to `system()`.** The fake vtable `V` lives in a 128 MiB user-variable
   buffer whose address is learned via the maps diff (glibc gives such large
   allocations a dedicated mmap; the slot is reused across free+realloc, so the
   self-referential pointer baked by SQL stays valid; buffer data begins at
   region+0x30 — verified this run via gdb on the live process). Layout:
   `V+0x20=D2`, `V+0xa0=system`, `V+0xa8=V+0x140` (cmd ptr), `V+0x100=D1`,
   `V+0x140="sh -c '<cmd>'"`. Dispatch: `call [rax+0x20]` → D2
   (`call *0x100(%rax)`, PIE+0x80da77) → D1 (`mov rdi,[rax+0xa8]; call
   [rax+0xa0]`, PIE+0xe3075b) → `system()` (libc+0x5c560). All three gadget
   offsets were re-verified with `objdump` against the binaries extracted from
   the pinned image in this run (`logs/repro/gadget_check.log`).

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (run twice consecutively — both runs
   exited 0).
2. The script: installs `mariadb-client`/`binutils` if missing; pulls the
   pinned image; verifies the D2/D1/system gadget offsets in the extracted
   binaries; then performs **two** independent attempts, each against a
   freshly created container (fresh datadir, root password `labpass`, USAGE +
   `appdb.*`-only `lowpriv` account, fresh ASLR). Each attempt runs
   `bundle/repro/exploit_mdev40470.py`, a pure-SQL driver (mariadb client over
   TCP/3306) that executes the whole chain and finally reads the marker file
   `id > /tmp/mdev40470_pwned_attemptN` the payload wrote inside the
   container.
3. Expected evidence: per attempt — `F-09 done - root login with EMPTY
   password`, leaked PIE/libc bases, stable 128 MiB slot, session death on
   `CALL uaf5()`, and marker content `uid=999(mysql) gid=999(mysql)
   groups=999(mysql)`; script exits 0 only when both attempts confirm.

   Note on the exploit driver: it is the published PoC
   (github.com/dinosn/mariadb-13-rce-lab @ 6ac868e1) with *one* robustness fix
   in the `/proc/self/maps` region discovery — on this kernel the fresh
   128 MiB mmap is sometimes VMA-merged with an adjacent pre-existing
   anonymous region (observed merged sizes 0x8022000 and 0x10002000), which
   the stock exact-size filter missed. The patched logic tracks all anonymous
   rw-p regions and handles both new-region and grew-region cases. Gadget
   offsets, sizes, DATA_OFF (0x30) and the whole chain are unchanged.

## Evidence

- `bundle/logs/reproduction_steps.log` — full scripted run (exit 0, 2/2).
- `bundle/logs/repro/version_check.log` — `13.0.1-MariaDB-ubu2604`.
- `bundle/logs/repro/gadget_check.log` — objdump verification of D2/D1/system.
- `bundle/logs/repro/vuln_attempt_1.log`, `vuln_attempt_2.log` — per-attempt
  chain transcripts (distinct ASLR bases).
- `bundle/logs/repro/marker_attempt_1.log`, `marker_attempt_2.log` —
  `uid=999(mysql) gid=999(mysql) groups=999(mysql)`.
- `bundle/logs/repro/vuln_attempt_token.log` + `marker_attempt_token.log` —
  additional run writing the unique token `MDEV40470_CHAIN_EXEC_1785762660`.
- `bundle/logs/repro/negative_control_lowpriv.log` + `bundle/repro/negative_control_1.json`
  — identical procedure without F-09: `ERROR 1045` (FILE), `ERROR 1227`
  (SUPER), no marker, no crash; the escalation is the linchpin.
- `bundle/repro/runtime_manifest.json` — tcp_peer entrypoint, service/health/
  target-path flags, pinned-image target identity.
- Environment: Docker 27.5.1 daemon, host kernel 6.8, container glibc 2.43;
  ASLR enabled (`/proc/sys/kernel/randomize_va_space` default); PIE binary.

## Recommendations / Next Steps

- **F-09 (MDEV-40470):** backport commit `dbd60d0ad8d` to all maintained
  release branches; reject empty `IDENTIFIED VIA ''` authentication clauses in
  GRANT PROXY / treat them as authenticated changes requiring
  `check_alter_user()` privileges.
- **F-05:** fix `sp_cursor_array::get_cursor_by_ref()` callers to re-validate
  the cursor reference after `open()` (or pin the array storage / use
  index-based lookup) so growth during a nested open cannot dangle the cached
  pointer.
- **Defense in depth:** set `secure_file_priv` to a dedicated directory in the
  stock image; consider `local_infile=0` and restricting FILE privilege.
- Until patched releases exist, any authenticated account must be treated as
  equivalent to full OS code execution as the mysql user.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` was run twice consecutively; both
  runs passed (4/4 scripted attempts + 1 manual + 1 token run = 6/6 total).
  Each attempt recreates the container from the pinned image, so no state
  carries over.
- **No fixed negative control exists:** the F-09 fix is not in any released
  version and F-05 is unfixed upstream, so no released image can serve as a
  fixed build. The in-band negative control (same procedure without the F-09
  escalation) demonstrates the privilege gate; the gadget-offset verification
  binds the result to the exact pinned binaries.
- **Kernel-dependent VMA merging** was the only environmental deviation from
  the published PoC (its exact-size maps filter). The patched discovery was
  verified against gdb ground truth (marker bytes `ABCD` found at
  region+0x30, confirming `DATA_OFF=0x30`; chunk header `0x8000ff2`
  IS_MMAPPED at region+8).
- The server process crashes after `system()` returns (mariadbd is PID 1, so
  the container exits); marker files persist in the container layer and are
  read after `docker start`. This post-exploit housekeeping is the only
  non-SQL step and is not part of the exploitation.
