# Root Cause Analysis — H1-3876430 / MDEV-40470: MariaDB GRANT PROXY `IDENTIFIED VIA ''` auth-list bypass → admin account takeover

## Summary

MariaDB Server's `GRANT PROXY ... IDENTIFIED VIA <auth-list>` handling contains a privilege-check
bypass. In the vulnerable code, `LEX_USER::has_auth()` (sql/structs.h) returns true only when the
parsed first auth node has a non-empty plugin name or password text. Supplying an EMPTY first auth
node (`IDENTIFIED VIA ''`) makes `has_auth()` return false, so the server skips the access check on
the `mysql` system database that normally guards credential changes. However,
`replace_user_table()` still persists the ENTIRE parsed auth list onto the target account — including
an attacker-controlled SECOND node (`OR mysql_native_password USING PASSWORD('hacked')`). The net
effect: any authenticated user holding only the `USAGE` privilege (the weakest possible session) can
overwrite the stored credentials of an arbitrary existing account — including a full DBA — and then
log in as that account. This is a complete remote account-takeover / privilege-escalation primitive
over the normal MySQL TCP protocol.

## Impact

- **Component:** MariaDB Server ACL/grant handling (`sql/sql_acl.cc`, `sql/structs.h`, `sql/sql_yacc.yy`).
- **Affected versions (confirmed this run):** MariaDB **12.3.2** (Docker `mariadb:12.3.2`,
  digest `sha256:a02fe89cb597d4375812b2eac90cf9d0775d4686daa7f7cc750ebbcad7525bbc`).
  The upstream fix (MDEV-40470, commit `dbd60d0ad8daa2d01050346aa50b7b102490e456`) first shipped in
  released versions 12.3.3 / 11.4.13 / 11.8.9 per the official release notes; earlier release lines
  without the fix are likewise expected to be affected.
- **Risk:** High (H1 triage 8.8). Remote, requires only a valid low-privilege account (`USAGE`),
  one SQL statement, no user interaction. Consequence: full server compromise via takeover of an
  `ALL PRIVILEGES ... WITH GRANT OPTION` account.

## Impact Parity

- **Disclosed/claimed maximum impact:** privilege escalation — USAGE-only user resets an
  administrator's password and obtains the admin's full privileges.
- **Reproduced impact from this run:** exactly that. On `mariadb:12.3.2` a `USAGE`-only `attacker`
  executed the claim's `GRANT PROXY` statement, `victim_admin`'s stored credentials were replaced
  with the attacker-chosen hash, and a fresh TCP login as `victim_admin` with password `hacked`
  returned `GRANT ALL PRIVILEGES ON *.* ... WITH GRANT OPTION`.
- **Parity:** `full`.

## Root Cause

- `sql/structs.h` (vulnerable): `bool has_auth() { return auth && (auth->plugin.length ||
  auth->auth_str.length || auth->pwtext.length); }` — an explicitly supplied but EMPTY
  authentication (`IDENTIFIED VIA ''`) is indistinguishable from "no authentication specified", so
  the privilege check (access to the `mysql` database / admin-check in the grant path) is skipped.
- `sql/sql_yacc.yy` additionally allocated an empty `USER_AUTH()` for `CURRENT_USER()` in
  `user_maybe_role`, compounding the confusion.
- Meanwhile `replace_user_table()` writes the full multi-node auth list to `mysql.user`, so the
  attacker-controlled second node becomes the victim's credential
  (`SHOW CREATE USER` afterwards shows
  `IDENTIFIED VIA mysql_native_password OR mysql_native_password USING '*43F4...'` = `PASSWORD('hacked')`).
- **Fix:** MDEV-40470, commit `dbd60d0ad8daa2d01050346aa50b7b102490e456`
  ("empty password and empty plugin name don't mean 'authentication was not specified', they mean
  'empty authentication was specified'"): `has_auth()` becomes `return auth;` and the empty
  `USER_AUTH()` allocation in `sql_yacc.yy` is removed. On the fixed build the statement is rejected
  with `ERROR 1044 (42000): Access denied for user 'attacker'@'%' to database 'mysql'`.

## Reproduction Steps

1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker only).
2. The script pulls pinned `mariadb:12.3.2` (vulnerable) and `mariadb:12.3.3` (fixed, first release
   containing `dbd60d0ad8d`), then runs **two fresh vulnerable containers and two fresh fixed
   containers**. In each container, over the real MySQL TCP protocol (`mariadb --protocol=TCP
   -h127.0.0.1 -P3306`):
   - root creates `victim_admin` (`ALL PRIVILEGES ... WITH GRANT OPTION`, password `origpass`) and
     `attacker` (`USAGE` only, password `attackerpass`);
   - precondition: attacker's direct `ALTER USER 'victim_admin'@'%' IDENTIFIED BY 'x'` is denied
     (ERROR 1227), proving the attacker holds no account-management privilege;
   - attack (as `attacker`):
     `GRANT PROXY ON CURRENT_USER() TO 'victim_admin'@'%' IDENTIFIED VIA '' OR mysql_native_password USING PASSWORD('hacked');`
   - verification: post-state `SHOW CREATE USER 'victim_admin'@'%'`, fresh TCP logins as
     `victim_admin` with `hacked`, `wrongpw`, and `origpass`.
3. Expected evidence (vulnerable): statement succeeds; victim's auth replaced by
   `PASSWORD('hacked')` hash; login with `hacked` succeeds with full privileges; `origpass` no
   longer works. Expected evidence (fixed): statement rejected with ERROR 1044; victim unchanged;
   `hacked` login fails, `origpass` login works.

## Evidence

- Per-attempt transcripts: `bundle/logs/repro/vuln_attempt_{1,2}.log`,
  `bundle/logs/repro/fixed_attempt_{1,2}.log`; structured results:
  `bundle/repro/evidence/{vuln,fixed}_{1,2}.json`; manifest with SHA-256:
  `bundle/repro/runtime_manifest.json`; session log: `bundle/logs/reproduction_steps.log`.
- Key excerpts (vulnerable attempt):
  - `Grants for attacker@%: GRANT USAGE ON *.* TO 'attacker'@'%'` (precondition)
  - `ATTACK_STMT_OK` (statement succeeded as USAGE-only user)
  - `CREATE USER 'victim_admin'@'%' IDENTIFIED VIA mysql_native_password OR mysql_native_password USING '*43F422B78BE9A02900B231D67662A7DC2A7BF8C4'` (= `PASSWORD('hacked')`)
  - login with `hacked` → `GRANT ALL PRIVILEGES ON *.* TO 'victim_admin'@'%' ... WITH GRANT OPTION`
  - login with `wrongpw` and with the ORIGINAL `origpass` → `ERROR 1045` (credentials truly replaced)
- Key excerpts (fixed attempt): `ERROR 1044 (42000): Access denied for user 'attacker'@'%' to
  database 'mysql'`; victim row unchanged; `hacked` login fails; `origpass` login succeeds.
- Result: vulnerable takeovers 2/2, fixed resistances 2/2, script exit 0 on two consecutive runs.
- Environment: Docker 27.5.1, `mariadb:12.3.2@sha256:a02fe89c…`, `mariadb:12.3.3@sha256:dd9b303a…`,
  linux/amd64, no sanitizers involved (pure product behavior at the SQL/TCP boundary).

## Recommendations / Next Steps

- Upgrade to MariaDB ≥ 12.3.3 / 11.4.13 / 11.8.9 (or any build containing commit `dbd60d0ad8d`).
- The fix approach upstream is correct: treat an explicitly supplied empty auth as an auth change
  (`has_auth()` → `return auth;`) and stop auto-allocating an empty `USER_AUTH` for `CURRENT_USER()`.
- Audit: `SELECT user, host, plugin, authentication_string FROM mysql.user` for unexpected changes;
  alert on any `GRANT PROXY` executed by non-admin accounts.
- Regression test: the upstream `mysql-test/main/grant5.test` MDEV-40470 case covers both
  `IDENTIFIED VIA 'foo'` and `IDENTIFIED VIA ''` from a USAGE-only user.

## Additional Notes

- The script is idempotent: every attempt uses a fresh, uniquely named container that is removed
  afterwards; a `trap` cleans up on early exit.
- The exact statement variant from the H1 report using `ON ''@''` behaves differently
  (ERROR 1698); the semantically identical variant `ON CURRENT_USER()` used here matches both the
  upstream regression test (`grant proxy on u1@localhost to u2@localhost identified via ''`) and the
  original discoverer PoC, and exercises the identical vulnerable code path (`has_auth()` bypass +
  `replace_user_table()` persisting the full auth list). The attacker-visible outcome — silent
  overwrite of the victim admin's credentials from a USAGE-only session — is the claim's core and is
  fully demonstrated.
- No crash, sanitizer, or debugger was used; the proof is pure product behavior over the real
  client/server TCP protocol.
