
Every secure coding guide tells you the same thing about SQL injection: use parameterised queries and you're done. Metabase used a query builder. It still shipped a CVSS 10.0 unauthenticated SQL injection, and somebody used it against Metabase Cloud before anyone knew the bug was there. That advice is what I want to argue with. Parameterisation protects you from an attacker-controlled string. It does nothing when the attacker gets to hand you part of the query structure, and CVE-2026-72898 is the cleanest example of that I've seen in a long while.
If you run a self-hosted Metabase somewhere behind a reverse proxy, wired up to the warehouse that holds everything your company actually cares about, this one's for you. That's an extremely common shape and it's the exact shape this bug was built to eat.
What happened
On 3 August 2026 Metabase spotted attacks against Metabase Cloud that used a bug nobody had a name for yet. Three days later, on 6 August, they published the advisory (GHSA-vwf4-m7j8-wcjf) and shipped patched builds across every supported branch. The flaw got the ID CVE-2026-72898 and a CVSS 3.1 base score of 10.0, vector AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H. Ten out of ten is rare, and it's usually a sign that the scoring hit every worst case at once: reachable over the network, easy, no login, no clicking, and it breaks out of the thing it lives in.
The vulnerable endpoint is POST /api/session/reset_password. That endpoint has to be unauthenticated, because the whole point of a password reset is that you cannot log in. It expects two fields: a token and a password. Send it a third field it never documented, shaped the right way, and you can run SQL of your choosing against the Metabase application database. From there you can promote yourself to admin, and admin on a business intelligence tool is admin over every database that tool is plugged into.
| Branch | Affected | Fixed build |
|---|---|---|
| Metabase x.58 | ≥ x.58.0, < x.58.23 | x.58.24 |
| Metabase x.59 | ≥ x.59.0, < x.59.20 | x.59.21 |
| Metabase x.60 | ≥ x.60.0, < x.60.16 | x.60.17 |
| Metabase x.61 | ≥ x.61.0, < x.61.10 | x.61.11 |
| Metabase x.62 | ≥ x.62.0, < x.62.8 | x.62.9 |
| Metabase x.63 | ≥ x.63.0, < x.63.3 | x.63.5 |
x is 0 for open source and 1 for Enterprise, so 0.58.24 and 1.58.24 are the same fix.The week, in order
This one moved fast, and the order matters if you're trying to work out how long your window of exposure was.
Metabase finds attacks against Metabase Cloud using an unknown bug, and starts containing it.
Advisory published, patched releases out across x.58 to x.63, incident disclosed.
Affected companies start publishing their own notices. Wiz observes public exploits by 10 Aug.
CISA adds CVE-2026-72898 to the KEV catalogue.
Wiz Research listed the downstream notices as they landed: Framework and Tally on 7 August, n8n on 8 August, Kilo Code (Anaconda) on 9 August, ChecklyHQ on 10 August. That's a decent reminder that "we use a hosted BI tool" is a supply chain statement, not an IT detail.
The bit that gets me is the four days between the patch dropping and public exploits appearing. Metabase did the right thing, shipped fixes across six branches at once, and self-hosted operators still had a long weekend to lose. Patch windows are getting shorter every year and I don't think most teams have adjusted.
Root cause: four small behaviours that shouldn't have met
Metabase didn't publish the patch diff in the public repository, so the root cause came from reverse engineering. Rami McCarthy and Wiz Research did that work: they pulled the vulnerable and patched JARs (v0.58.22 and v0.58.24), diffed them, decompiled the relevant Clojure class and matched the bytecode back to the open source. Everything in this section is their finding, and I'm just walking through it because it's a genuinely lovely bug.
Metabase's backend is Clojure. Four ordinary, individually reasonable behaviours line up here, and the bug lives in the gaps between them.
The code does (merge request (authenticate ...)). Clojure's merge lets the second map overwrite the first, but it never strips extra keys. Failed auth returns no :user-id, so yours survives.
Metabase keywordises incoming JSON. {"user-id": {"raw": "..."}} parses into the Clojure map {:user-id {:raw "..."}}. Standard behaviour, no bug yet.
HoneySQL, the query builder, treats :raw as "put this literal SQL in, skip parameterisation". It's a deliberate escape hatch for developers.
That value goes to t2/select-one ... :id user-id. Instead of an integer it gets a :raw map, and the query compiles with your SQL welded into it.
Read that chain again and notice something: not one of those four behaviours is a bug. merge is doing exactly what the docs say. Keywordising JSON is normal. HoneySQL's :raw exists because sometimes you genuinely need to write SQL the builder can't express. The lookup by ID is the most boring line of code in the file.
Parameterisation stops a hostile string. It has nothing to say about a hostile data structure, because by the time the builder sees one, the attacker is already writing the query with you.
The patch is small and it tells you exactly where the trust boundary should have been. Wiz reconstructed it as roughly this shape: before resolving a user, check that user-id is a positive integer, and if it isn't, refuse and log it. That's the whole fix. Validate the type, at the edge, before anything downstream gets a chance to be clever.
POST /api/session/reset_password takes exactly two fields: token (the reset token from the email) and password (the new one). Nothing else is part of the API.user-id, whose value is a nested object rather than a number. Nothing rejects it, and the object is what carries the payload.I'll admit where I started out wrong. When I first read "SQL injection in a password reset", I assumed the bug had to be in how the reset token was compared or looked up, because that's where these usually live. I spent a while reading around that idea before the Wiz write-up landed and showed me the token was never the interesting part. The token is a red herring. Auth failing is what makes the attack work, because a failed authentication returns a map with no :user-id in it, which is precisely why the attacker's one survives.
Why the blast radius is worse than "one app got popped"
Metabase is a business intelligence tool. Its job is to hold connection details for your databases and warehouses so that people can point and click at charts instead of writing SQL. That job description is also a very good description of a credential vault that nobody treats like one.
- It's pre-auth. No account, no token, no user interaction. If the endpoint is reachable, the attack is available. That's the whole ballgame.
- Admin is one hop away. Injecting into the application database means altering application records, and Metabase's own advisory says the result is administrator access.
- The connected databases are the real prize. Bishop Fox made this point plainly: a compromised Metabase is a springboard into the systems it queries, because it already holds the credentials and the network path.
- Exposure is common. Wiz reported it sees self-hosted Metabase in roughly 13% of cloud environments, with about a quarter of those fully internet accessible, and around 2,500 instances visible in Shodan.
- The payload varies by backend. Metabase defaults to H2 but recommends PostgreSQL, MySQL or MariaDB for production, and the injection differs per engine. Handy for attackers who bother to fingerprint, awkward for anyone writing a single detection rule.
Detection and hunting
The nice thing about this bug from a defender's point of view is that the attack has a very distinctive shape. Real clients never send that field.
- Hunt the undeclared field. Any request body to
/api/session/reset_passwordcontaining auser-idkey is hostile by definition. It isn't part of the API. If your proxy or WAF logs request bodies, that's a single high-confidence string to grep for. - Hunt the type. Even where
user-idmight appear legitimately elsewhere, auser-idwhose value is an object rather than a number is the tell. Look for"user-id":{rather than"user-id":123. - Volume on a quiet endpoint. Password resets are rare and human-paced. A burst of POSTs to that path from one source, especially with near-identical bodies, is a blind injection being walked one character at a time.
- Application database logs. Blind SQLi against the app database will leave unusual query patterns behind. If you log slow queries or statements on the Metabase application database, that's where the injected SQL shows up in its compiled form.
- Look for the result, not just the attempt. New administrator accounts, unexpected privilege changes, altered email addresses on existing admins, and API keys nobody recognises. Those are the outcomes worth alerting on even if you never see the request.
Version fingerprinting is straightforward too. Wiz pointed at the properties endpoint for working out what you're running:
The one control that would have broken this earliest
Patching is the answer now, but the interesting question for an autopsy is which single control would have stopped the chain furthest to the left. For me it isn't a WAF rule and it isn't network segmentation, useful as both are.
It's rejecting undeclared fields at the API boundary. A strict schema on that endpoint, one that accepts token and password and throws away anything else, kills this before any of the four behaviours downstream even get a turn. The merge can't preserve a key that was never allowed in. HoneySQL never sees a map. The whole chain needs step one to succeed, and step one is just "the API tolerated a field it didn't ask for".
That's a design habit rather than a product you buy, which is probably why it keeps not happening. Most frameworks default to permissive parsing because strict parsing breaks clients, and "be liberal in what you accept" is one of the oldest bits of advice in networking. It's also, on a security boundary, mostly wrong.
/api/session/properties → upgrade to the fixed build for your branch → if it was exposed, revoke sessions and rotate connected database credentials → hunt user-id in reset_password bodies and review admin accounts.Remediation
- Upgrade. Move to x.58.24, x.59.21, x.60.17, x.61.11, x.62.9 or x.63.5, whichever matches your branch. That's the fix.
- If you can't upgrade today, block the endpoint. Metabase's own interim advice is to temporarily block
/api/session/reset_passwordat the proxy. Your users lose self-service password reset until you patch, which is a fair trade for a few hours. - Treat an exposed instance as compromised until proven otherwise. Metabase's post-upgrade guidance is specific: revoke all active sessions, review API keys and delete anything unrecognised, check administrator accounts for unexpected changes, rotate credentials for every connected database, and review both warehouse logs and Metabase's own query history.
- Then ask why it was reachable. A BI tool holding warehouse credentials rarely needs to be on the public internet. If yours is, that decision deserves a second look now that you have a reason to have the conversation.
MITRE ATT&CK mapping
- T1190 · Exploit Public-Facing Application (the unauthenticated reset endpoint).
- T1098 · Account Manipulation (altering application records to gain administrator access).
- T1552 · Unsecured Credentials (stored connection credentials for the databases Metabase queries).
- T1213 · Data from Information Repositories (Metabase is quite literally the repository).
References
- Metabase security advisory GHSA-vwf4-m7j8-wcjf
- Metabase: security update and immediate steps
- Wiz Research (Rami McCarthy): Inside the Metabase SQLi, exploited in the wild
- Bishop Fox: Critical SQL injection in Metabase via password reset
- Horizon3: CVE-2026-72898 affected versions, mitigations and timeline
- CISA: three vulnerabilities added to the KEV catalogue, 11 August 2026
- CVE record: CVE-2026-72898
FAQ
What is CVE-2026-72898?
An unauthenticated SQL injection in Metabase's POST /api/session/reset_password endpoint, tracked as GHSA-vwf4-m7j8-wcjf and scored CVSS 3.1 10.0. An attacker who can reach the endpoint can inject SQL into the Metabase application database and end up with administrator access. Metabase confirmed it was exploited in the wild.
Why didn't parameterised queries stop this SQL injection?
Because the attacker supplied query structure, not a string. Metabase builds queries with HoneySQL, which treats a map shaped like {:raw "..."} as a request to embed literal SQL. Parameterisation protects string values. It cannot protect a builder that was handed a directive instead of data.
Which Metabase versions fix CVE-2026-72898?
Metabase lists x.58.24, x.59.21, x.60.17, x.61.11, x.62.9 and x.63.5 as the patched builds, where x is 0 for the open-source edition and 1 for Enterprise. Anything on the x.58 to x.63 branches below those numbers should be treated as vulnerable and upgraded.
Is Metabase Cloud affected or only self-hosted instances?
The original attack was against Metabase Cloud, which Metabase disclosed on 6 August 2026 and patches itself. The ongoing risk sits with self-hosted deployments, because nobody upgrades those but you. Wiz Research reported that around a quarter of the self-hosted instances it sees are fully internet accessible.
If my Metabase instance was exposed, is patching enough?
No. Metabase's own guidance is to revoke all active sessions, review and delete unrecognised API keys, check administrator accounts for unexpected changes, rotate credentials for every connected database, and review warehouse logs and Metabase query history. Patching stops the next attacker, not the one who already came through.
Related reading
- Injection Attacks Explained: Beyond SQLi (why injection keeps outliving the fixes we invent for it)
- SharePoint's RCE Chain Just Grew a Fourth CVE (another KEV entry where the app server held the keys)
- CVE-2026-8451: NetScaler CitrixBleed Pre-Auth Memory Overread (a different pre-auth bug with the same "no login needed" problem)
- Browse the whole Threats & Exploits track