{"repro_id":"REPRO-2026-00330","version":7,"title":"MariaDB 13.0.1-rc RCE chain: F-09 GRANT PROXY priv-esc (MDEV-40470) + /proc/self/maps ASLR leak + F-05 SYS_REFCURSOR heap UAF → JOP to system() as uid 999(mysql), pure SQL from a low-priv account","repro_type":"security","status":"published","severity":"critical","description":"Full remote code execution chain against the stock, unmodified MariaDB 13.0.1-rc Docker image (mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9), executing arbitrary commands as uid=999 (mysql) via pure SQL from a USAGE-only low-privilege account over TCP port 3306. No CVE assigned as of 2026-08-03.","root_cause":"# RCA Report — MDEV-40470-F05-RCE-CHAIN: MariaDB 13.0.1-rc pure-SQL RCE chain\n\n## Summary\n\nA USAGE-only MariaDB account with TCP reachability to port 3306 achieves remote\ncode execution as `uid=999(mysql)` on the **stock, unmodified** MariaDB\n13.0.1-rc Docker image (`mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9`)\nusing nothing but SQL statements. The chain combines: (F-09) a `GRANT PROXY ...\nIDENTIFIED VIA ''` privilege escalation that hijacks the root account with an\nempty password (MDEV-40470); a server-side `LOAD DATA INFILE '/proc/self/maps'`\nread that discloses the live PIE and libc base addresses (ASLR defeat); a\n128 MiB user-variable buffer whose address is discovered by diffing\n`/proc/self/maps` from SQL; and (F-05) a `SYS_REFCURSOR` use-after-free in\n`sp_cursor_array::get_cursor_by_ref()` whose freed 1792-byte array chunk is\nreclaimed by an exact-fit heap spray, redirecting a virtual dispatch into a\ntwo-gadget JOP chain (D2 → D1) that calls libc `system()` with an\nattacker-chosen command string.\n\n## Impact\n\n- **Package/component:** `mariadb/server` — server core (`sql/sql_acl.cc`\n  GRANT PROXY handling; `sql/sp_cursor.{cc,h}` cursor array).\n- **Affected versions:** reproduced on 13.0.1-MariaDB-ubu2604 (pinned image).\n  Per the advisory, F-09's fix (commit `dbd60d0ad8d`) exists only on dev\n  branches and is absent from every released version 13.0.1 → 10.6.27; F-05 is\n  unfixed upstream (no commits to `sql/sp_cursor.{cc,h}` since the 13.0.1 tag).\n- **Risk level:** Critical. Any authenticated low-privilege database user gains\n  full OS command execution as the `mysql` service account (uid 999), i.e.\n  complete database-server compromise, remotely, over the normal SQL protocol.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** code execution as uid 999 (mysql) from\n  a USAGE-only account via pure SQL over TCP/3306.\n- **Reproduced impact from this run:** identical — attacker-chosen shell\n  commands executed as `uid=999(mysql)` on the stock pinned image in 6/6 fresh\n  processes (5 scripted + 1 manual calibration run), each with fresh ASLR.\n- **Parity:** `full`.\n\n## Root Cause\n\n1. **F-09 — GRANT PROXY privilege escalation (MDEV-40470).**\n   `GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '';` passes an\n   *empty* authentication clause. `LEX_USER::has_auth()` returns false, so the\n   privilege check in `check_alter_user()` is skipped, while\n   `replace_user_table()` still applies the (empty) password — replacing root's\n   credentials with an empty password. One statement, any authenticated user.\n   Fix: commit `dbd60d0ad8d` (dev branches only; no released version has it).\n   Runtime proof: after the statement, login as `root` with `--skip-password`\n   succeeds (`CURRENT_USER()=root@%`), and the pre-escalation negative control\n   shows `ERROR 1045` on `LOAD DATA INFILE` and `ERROR 1227` on `SET GLOBAL`.\n\n2. **ASLR defeat.** With the FILE privilege obtained in step 1 and\n   `secure_file_priv` unset (stock), `LOAD DATA INFILE '/proc/self/maps'`\n   loads mariadbd's own memory map into a table; plain SELECTs return the PIE\n   base (first `r--p 00000000` mapping of `/usr/sbin/mariadbd`) and the libc\n   base. Fresh values every process (observed 0x63abb3250000, 0x57b3cea2c000,\n   0x55b611d36000, 0x76955fffe000-area, 0x731cf3fff000-area).\n\n3. **F-05 — SYS_REFCURSOR use-after-free (unfixed 0day).**\n   `sp_cursor_array::get_cursor_by_ref()` returns an interior pointer into a\n   `Dynamic_array`. When a cursor's `open()` executes SQL that opens more\n   cursors (`grow5()` opens 16 cursors, then `OPEN p FOR SELECT spray128()`),\n   the array grows, `my_realloc` frees the old 1792-byte storage (16×112 B),\n   and the caller's cached pointer dangles. A heap spray of 128 session user\n   variables of exactly 1784 bytes (`SET @e3sNNN=@e3pad`, glibc exact-fit for\n   the 1792-byte chunk) reclaims the freed storage with attacker bytes,\n   placing a controlled pointer `V` at offset 0x20 (the `result` member of\n   `sp_cursor`). `Materialized_cursor::open()` then dispatches virtually:\n   `mov rax,[result]; call [rax+0x20]`.\n\n4. **JOP to `system()`.** The fake vtable `V` lives in a 128 MiB user-variable\n   buffer whose address is learned via the maps diff (glibc gives such large\n   allocations a dedicated mmap; the slot is reused across free+realloc, so the\n   self-referential pointer baked by SQL stays valid; buffer data begins at\n   region+0x30 — verified this run via gdb on the live process). Layout:\n   `V+0x20=D2`, `V+0xa0=system`, `V+0xa8=V+0x140` (cmd ptr), `V+0x100=D1`,\n   `V+0x140=\"sh -c '<cmd>'\"`. Dispatch: `call [rax+0x20]` → D2\n   (`call *0x100(%rax)`, PIE+0x80da77) → D1 (`mov rdi,[rax+0xa8]; call\n   [rax+0xa0]`, PIE+0xe3075b) → `system()` (libc+0x5c560). All three gadget\n   offsets were re-verified with `objdump` against the binaries extracted from\n   the pinned image in this run (`logs/repro/gadget_check.log`).\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (run twice consecutively — both runs\n   exited 0).\n2. The script: installs `mariadb-client`/`binutils` if missing; pulls the\n   pinned image; verifies the D2/D1/system gadget offsets in the extracted\n   binaries; then performs **two** independent attempts, each against a\n   freshly created container (fresh datadir, root password `labpass`, USAGE +\n   `appdb.*`-only `lowpriv` account, fresh ASLR). Each attempt runs\n   `bundle/repro/exploit_mdev40470.py`, a pure-SQL driver (mariadb client over\n   TCP/3306) that executes the whole chain and finally reads the marker file\n   `id > /tmp/mdev40470_pwned_attemptN` the payload wrote inside the\n   container.\n3. Expected evidence: per attempt — `F-09 done - root login with EMPTY\n   password`, leaked PIE/libc bases, stable 128 MiB slot, session death on\n   `CALL uaf5()`, and marker content `uid=999(mysql) gid=999(mysql)\n   groups=999(mysql)`; script exits 0 only when both attempts confirm.\n\n   Note on the exploit driver: it is the published PoC\n   (github.com/dinosn/mariadb-13-rce-lab @ 6ac868e1) with *one* robustness fix\n   in the `/proc/self/maps` region discovery — on this kernel the fresh\n   128 MiB mmap is sometimes VMA-merged with an adjacent pre-existing\n   anonymous region (observed merged sizes 0x8022000 and 0x10002000), which\n   the stock exact-size filter missed. The patched logic tracks all anonymous\n   rw-p regions and handles both new-region and grew-region cases. Gadget\n   offsets, sizes, DATA_OFF (0x30) and the whole chain are unchanged.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full scripted run (exit 0, 2/2).\n- `bundle/logs/repro/version_check.log` — `13.0.1-MariaDB-ubu2604`.\n- `bundle/logs/repro/gadget_check.log` — objdump verification of D2/D1/system.\n- `bundle/logs/repro/vuln_attempt_1.log`, `vuln_attempt_2.log` — per-attempt\n  chain transcripts (distinct ASLR bases).\n- `bundle/logs/repro/marker_attempt_1.log`, `marker_attempt_2.log` —\n  `uid=999(mysql) gid=999(mysql) groups=999(mysql)`.\n- `bundle/logs/repro/vuln_attempt_token.log` + `marker_attempt_token.log` —\n  additional run writing the unique token `MDEV40470_CHAIN_EXEC_1785762660`.\n- `bundle/logs/repro/negative_control_lowpriv.log` + `bundle/repro/negative_control_1.json`\n  — identical procedure without F-09: `ERROR 1045` (FILE), `ERROR 1227`\n  (SUPER), no marker, no crash; the escalation is the linchpin.\n- `bundle/repro/runtime_manifest.json` — tcp_peer entrypoint, service/health/\n  target-path flags, pinned-image target identity.\n- Environment: Docker 27.5.1 daemon, host kernel 6.8, container glibc 2.43;\n  ASLR enabled (`/proc/sys/kernel/randomize_va_space` default); PIE binary.\n\n## Recommendations / Next Steps\n\n- **F-09 (MDEV-40470):** backport commit `dbd60d0ad8d` to all maintained\n  release branches; reject empty `IDENTIFIED VIA ''` authentication clauses in\n  GRANT PROXY / treat them as authenticated changes requiring\n  `check_alter_user()` privileges.\n- **F-05:** fix `sp_cursor_array::get_cursor_by_ref()` callers to re-validate\n  the cursor reference after `open()` (or pin the array storage / use\n  index-based lookup) so growth during a nested open cannot dangle the cached\n  pointer.\n- **Defense in depth:** set `secure_file_priv` to a dedicated directory in the\n  stock image; consider `local_infile=0` and restricting FILE privilege.\n- Until patched releases exist, any authenticated account must be treated as\n  equivalent to full OS code execution as the mysql user.\n\n## Additional Notes\n\n- **Idempotency:** `reproduction_steps.sh` was run twice consecutively; both\n  runs passed (4/4 scripted attempts + 1 manual + 1 token run = 6/6 total).\n  Each attempt recreates the container from the pinned image, so no state\n  carries over.\n- **No fixed negative control exists:** the F-09 fix is not in any released\n  version and F-05 is unfixed upstream, so no released image can serve as a\n  fixed build. The in-band negative control (same procedure without the F-09\n  escalation) demonstrates the privilege gate; the gadget-offset verification\n  binds the result to the exact pinned binaries.\n- **Kernel-dependent VMA merging** was the only environmental deviation from\n  the published PoC (its exact-size maps filter). The patched discovery was\n  verified against gdb ground truth (marker bytes `ABCD` found at\n  region+0x30, confirming `DATA_OFF=0x30`; chunk header `0x8000ff2`\n  IS_MMAPPED at region+8).\n- The server process crashes after `system()` returns (mariadbd is PID 1, so\n  the container exits); marker files persist in the container layer and are\n  read after `docker start`. This post-exploit housekeeping is the only\n  non-SQL step and is not part of the exploitation.\n","cwe_id":"CWE-266","source_url":"https://github.com/dinosn/mariadb-13-rce-lab","package":{"name":"mariadb/server","ecosystem":"github","affected_versions":"F-09 affects every released MariaDB version (confirmed 13.0.1 through 10.6.27). F-05 unfixed at HEAD as of 2026-08-03."},"reproduced_at":"2026-08-23T15:38:53.860955+00:00","duration_secs":3693.0,"tool_calls":217,"handoffs":2,"total_cost_usd":6.73444,"agent_costs":{"claim_matcher":0.027596,"judge":0.757188,"learning_policy":0.016154,"repro":3.18513,"support":0.080244,"vuln_variant":2.668128},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.027596},"judge":{"gpt-5.6-sol":0.757188},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.016154},"repro":{"accounts/fireworks/models/kimi-k3":3.18513},"support":{"accounts/fireworks/models/kimi-k3":0.080244},"vuln_variant":{"accounts/fireworks/models/kimi-k3":2.668128}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:38:54.453914+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9731,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7765,"category":"reproduction_script"},{"path":"bundle/logs/repro/dockerlogs_attempt_1.log","filename":"dockerlogs_attempt_1.log","size":15355,"category":"log"},{"path":"bundle/logs/repro/dockerlogs_attempt_2.log","filename":"dockerlogs_attempt_2.log","size":15355,"category":"log"},{"path":"bundle/logs/repro/marker_attempt_2.log","filename":"marker_attempt_2.log","size":48,"category":"log"},{"path":"bundle/logs/repro/negative_control_lowpriv.log","filename":"negative_control_lowpriv.log","size":905,"category":"log"},{"path":"bundle/logs/repro/version_check.log","filename":"version_check.log","size":33,"category":"log"},{"path":"bundle/repro/exploit_mdev40470.py","filename":"exploit_mdev40470.py","size":14662,"category":"script"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1280,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1194,"category":"other"}]}