DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-72898: Metabase Password Reset SQL Injection Deep Dive

Item Detail
CVE ID CVE-2026-72898
Affected Product Metabase (open-source / enterprise BI tool)
Vulnerability Type SQL Injection (CWE-89)
Auth Required No (pre-auth)
Attack Vector POST /api/session/reset_password
Severity Critical (CVSS ~9.x)
Impact Admin account takeover, full application DB exposure

Metabase is a BI platform that provides dashboards and a query builder. It's written in Clojure and assembles its SQL internally through a query-builder library called HoneySQL. This vulnerability comes from the password reset API taking values outside the fields the developers expected and letting them flow, unvalidated, all the way into the query builder.

Here's the one-line version:

If you slip one extra, undeclared field into the request body, that value gets used verbatim as part of the raw SQL string. Since the endpoint requires no authentication, a single request is enough for an attacker to reset the password of any user — including an admin — to whatever they choose.


Where the Bug Lives

The reset_password endpoint is designed to take an emailed token and a new password, then update that user's password. The intended flow looks like this:

  1. User clicks "Forgot password"
  2. Server generates a temporary token and emails it
  3. User calls reset_password with the token + new password
  4. Server looks up the user by the token, and updates only that user's password

The problem is step 4. Instead of anchoring the update to "the user looked up via the token," there was a code path where the server trusted whatever value showed up in the request body to decide which record to update. And because that value passed through HoneySQL's query builder unvalidated, it hit a spot where it was interpreted not as a bind parameter, but as a raw SQL expression.


Understanding It at the Source Level

Metabase's backend is Clojure, and request bodies are typically pulled apart with destructuring, something like:

(defn reset-password
  [{{:keys [token password]} :body}]
  ;; intended: only pull token and password out of the body
  ...)
Enter fullscreen mode Exit fullscreen mode

This reads as "only take token and password from the request body." But in the vulnerable version, there was a path that also bound the entire body via :as body, and downstream logic mixed in code that read extra fields straight out of that body map:

(defn reset-password
  [{{:keys [token password] :as body} :body}]
  ;; some downstream helper reaches back into `body`
  ;; for a key that was never declared in :keys
  ...)
Enter fullscreen mode Exit fullscreen mode

(Note: the snippets above are reconstructed to illustrate the mechanism based on public technical analysis — they are not a line-for-line copy of Metabase's actual source.)

Two things stand out here:

First, "only declared fields are read" wasn't actually enforced. Clojure's map destructuring is convenient, but binding the whole original map alongside it via :as body does nothing to stop some other piece of code from reaching into body for a key that was never declared. So even if the API spec only documents token and password, if the actual code references another key on body, that value survives and gets passed along.

Second, when that value landed in the :where clause of a HoneySQL query map, it passed through a spot where it was treated as a raw SQL fragment instead of a bind parameter. HoneySQL is safe by default — ordinary values get auto-bound to ? placeholders. But somewhere in the codebase, this value either got wrapped in something like [:raw ...] (or an equivalent helper that treats a string as literal SQL), or passed through a dynamic condition-building utility that carried the incorrect assumption that "this value is already safe SQL." The result: an attacker-supplied string gets executed as part of the actual SQL syntax.

To summarize the attack conditions:

  • Request body validation worked by pulling out whatever key happened to be present, rather than enforcing a whitelist of allowed keys.
  • The value pulled out was treated as code, not data, at the point the query string was assembled.

Put those two together on an endpoint with no authentication at all, and you get SQL injection.


The Attack, From the Attacker's Side

No special prerequisites needed — just network access to a Metabase instance.

  1. Confirm the normal reset_password request shape (token, password)
  2. Add an extra, undeclared key (e.g., one that functions as a user identifier)
  3. The server accepts this extra key without validation and hands it to the query builder
  4. The value gets interpreted as a raw SQL fragment, tampering with the query that decides whose password gets updated
  5. The password of whatever user the attacker specified — including an admin account — gets changed to a value the attacker controls
  6. The attacker logs in normally with that account and has full admin access

Once you have admin, you can read credentials for connected data sources, view every piece of data exposed in dashboards, change settings, and create new admin accounts or API keys — effectively full takeover of the instance.


Why This One's Nasty

Typical SQL injection shows up somewhere visible — a login form, a search box, some spot where "user input clearly goes into a query." This case is different:

  • Looking only at the documented parameters (token, password), there's nothing to suggest a vulnerability. The actual bug lived in a field that was never documented — one the developers never explicitly declared as something the endpoint accepts.
  • Even a query builder that's "designed to be safe," like Clojure/HoneySQL, loses that safety the moment a value passes through even one helper function that treats it as raw SQL.
  • Because the endpoint requires no authentication, a successful exploit skips privilege escalation entirely and lands directly on admin access.

The Fix

Two principles were applied together in the patched version:

  1. Enforce the input schema as a whitelist. Anything outside the explicitly allowed keys (token, password) now gets dropped at the destructuring step itself, and the path that let code reach back into the full original map via :as body was removed entirely.
  2. Pin the update target to a server-side lookup, not client input. The user is now looked up by the token first, and only that lookup result's id is used as the update target — removing any way for the client to specify the update target itself.

Detection and Response Notes

  • Check WAF or reverse-proxy logs for /api/session/reset_password requests whose body contains keys beyond token and password.
  • Cross-reference admin password-change history, new account creation, and API key issuance around the relevant time window.
  • Until you can upgrade to the patched version, adding an extra layer of request-body schema validation (rejecting anything outside the allowed keys) at the reverse-proxy level can serve as a stopgap mitigation for the reset_password endpoint.

Top comments (0)