§ AU

metabase_database.details, the Column Storing Snowflake Credentials in Cleartext

Metabase shipped emergency releases on August 6 for a flaw that let anyone on the internet turn an unauthenticated password-reset request into full administrator access on your BI server. Upgrading closes the door. It does not evict whoever walked through it first, and the confirmed activity predates the patch, so “we’re on the fixed build” is an answer to a different question than “were we hit.” If your instance was reachable from the internet at any point in the affected version range, or reachable from an internal network an attacker had already gotten into, because the bug does not care where the request comes from, then the work in front of you is a retro-hunt. That retro-hunt has a deadline set by your log retention rather than by your change window. Framework, Tally, n8n, and Kilo Code have all confirmed that an unauthorized party accessed customer data through their Metabase instances. Note the verb: those disclosures establish access and querying rather than a quantified exfiltration. n8n dates the unauthorized activity to 3 August 2026 and counts 136 records; Framework’s affected instance was a Cloud one, which is worth holding on to for the next paragraph. Metabase Cloud tenants were upgraded by Metabase and the endpoints used in the attack were blocked, so Cloud-only shops have no patching action to take. That is not the same as having nothing to do, and it is worth being precise about the split. Metabase Cloud is what got attacked in the first place. The tenant-side work is still yours: validate unexpected activity in your own instance, rotate the credentials for every connected database, and hunt your warehouse logs. The application-database forensics further down are aimed at self-hosted operators, because they are the ones who actually hold that database.

The mechanism, precisely. CVE-2026-72898 carries a CVSS 10.0 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) and lives in POST /api/session/reset_password. The endpoint officially takes token and password, but the handler merges the raw request body with the authentication result using Clojure’s merge, and when the token check fails, attacker-supplied keys survive the merge intact. On its own that is only key injection; what makes it exploitable is where those keys land. Wiz’s teardown walks the chain: the JSON body is keywordized into a Clojure map, an extra user-id key comes through as a nested object, and because Metabase builds this lookup with HoneySQL, a {:raw "..."} value compiles to a literal SQL string instead of a bound parameter. That reaches a select-one on the user table, and you have blind injection against the Metabase application database: H2, Postgres, MySQL, or MariaDB, all four. The minimum safe releases are v58.24, v59.21, v60.17, v61.11, v62.9, and v63.5, and Metabase’s own guidance is that any lower point release in those trains is still vulnerable: “if you are running 0.58.6, you should upgrade to 0.58.24 release or later.” Anything below version 58 is unaffected. Mind the prefix while you are checking yours, because Metabase’s own post switches between the two: it describes the flaw as hitting “versions 1.58 and above,” which is the Enterprise numbering, while every download it lists is open-source 0.x. Read that as 0.58 or 1.58 depending on which you run, and it is also why the advisory writes its ranges as x.58.0. Work from that framing rather than from the GitHub advisory’s machine-readable ranges, because the two disagree. The advisory lists affected as >= x.58.0, < x.58.23 while naming x.58.24 as patched, which leaves x.58.23 in neither set, and the same one-version hole repeats in every branch, widening to two at the top (< x.63.3 against a patched x.63.5). Somebody’s dependency tooling will read those endpoints literally and report a build clean that the vendor considers vulnerable.

TL;DR

  • CVE-2026-72898 (CVSS 10.0) turns an unauthenticated POST to /api/session/reset_password into blind SQL injection against the Metabase app DB and full admin. Clojure’s merge lets attacker keys survive a failed token check, and a {:raw "..."} value compiles to literal SQL in HoneySQL. Minimum safe releases are v58.24, v59.21, v60.17, v61.11, v62.9, v63.5; treat any lower point release in those trains as vulnerable, because the GitHub advisory’s affected ranges stop one version short of each patched build.
  • Confirmed activity runs back to August 3 (n8n dates the unauthorized access in its own environment to 3 August 2026), before the August 6 patch, so upgrading answers “are we vulnerable” and not “were we hit.” Metabase Cloud tenants were upgraded by Metabase and have no patching action, but Cloud is what was attacked, so tenant-side validation and credential rotation still apply. The app-DB forensics below are self-hosted work.
  • The circulated fingerprint (a 400 on /api/session/reset_password followed by a 200 on /api/user/current from the same source) is worth running but noisy: the 120-second maxspan is an arbitrary pick rather than a vendor-specified bound, expired reset tokens generate the same pair, and body-content hunting is rarely available in practice, because stock Nginx doesn’t log $request_body, ALB and CloudFront don’t log bodies at all, and only a WAF configured to capture them lets you search for the anomalous user-id key.
  • The application database is the stronger witness and survives the upgrade: check core_user for superusers created or modified since August 3, enumerate the api_key table (keys don’t expire and truncating core_session doesn’t touch them), and sort query_execution by result_rows to surface unusually large executions. A clean gap in audit_log, or an already-empty core_session you didn’t truncate yourself, is itself a finding.
  • metabase_database.details stores connection credentials for every attached database, warehouses and ordinary application databases alike, in cleartext by default; encryption applies only when MB_ENCRYPTION_SECRET_KEY is set. Check rather than assume, including where it was set late: Metabase encrypts only databases added after the key exists, and pre-existing connections stay cleartext until each one is re-saved. Rotate Snowflake, Redshift, and BigQuery creds, then hunt Snowflake QUERY_HISTORY/LOGIN_HISTORY (lagging up to 45 min and 2 hours) and BigQuery data-access logs, which route to the _Default log bucket where the standard retention period is 30 days.

The 400-then-200 fingerprint, and what it misses

Every writeup is circulating the same detection, because it’s the one Metabase handed out: a POST /api/session/reset_password returning 400, followed shortly by a GET /api/user/current returning 200. The 400 is the injection succeeding while the reset itself fails; the 200 is the attacker confirming they now hold a superuser session.

It’s a real signal and you should absolutely run it. In Splunk, against whatever sourcetype your ingress tier writes:

index=proxy (uri_path="/api/session/reset_password" OR uri_path="/api/user/current")
| transaction src_ip maxspan=120s
| search uri_path="/api/session/reset_password" status=400
   AND uri_path="/api/user/current" status=200

transaction is a pig at scale and you’ll want streamstats instead once you know the pattern exists, but for a bounded retro-hunt over a two-week window it’s fine and it’s faster to write. Field names vary: Nginx ingress via the Elastic pipeline gives you url.path and http.response.status_code; ALB access logs bury the method and path inside the request field as one space-delimited string, so you’re extracting before you can filter, which is annoying enough that people skip it.

Two things about that fingerprint bother me.

The maxspan is doing more work than it looks like. A hundred and twenty seconds is my arbitrary pick rather than a vendor-specified bound: Metabase publishes the request pattern and says nothing at all about how far apart the two calls should sit. An operator scripting this closes the loop in under a second; one working interactively through Burp might take four minutes to get back to /api/user/current, and a slow blind-injection extraction stretches the whole sequence out considerably. Widen the span and your false-positive rate climbs, because a legitimate user with an expired reset token generates a genuine 400 and then, on a lot of deployments, lands on a page that calls /api/user/current. In an environment with real self-service password reset traffic, legitimate failures produce this shape too, so treat a correlated pair as a high-value lead rather than as proof by itself. Metabase’s own wording is that finding the pattern makes compromise likely, which is about the right strength of claim. Baseline your corporate egress, your uptime checker, your normal reset volume, user agents, and the authentication activity either side of the pair before escalating any individual match.

The other problem is worse. The angle everyone reaches for, grepping the proxy logs for SQL metacharacters in the request body, mostly does not work, because you do not have request bodies. Nginx does not log $request_body unless somebody explicitly added it to log_format, and almost nobody did, because it’s a compliance grenade for an endpoint that carries plaintext passwords. ALB and CloudFront don’t log bodies at all. If you have Cloudflare WAF with a managed ruleset, or ModSecurity with SecRequestBodyAccess On and enough of the CRS enabled to log the matched data, then yes, go hunt for the user-id key with a nested object value, which is a far more specific string than any generic metacharacter regex. Everyone else is working from method, path, and status code, and should stop pretending otherwise.

Retention is the other constraint nobody says out loud. Confirmed activity runs back to August 3, which is the date n8n puts on the unauthorized access in its own environment. A lot of shops keep proxy logs hot for seven days and roll to frozen S3 after that. If you’re reading this in the back half of August, the August 3 data is in a bucket, not in your index, and somebody has to thaw it. Start that restore before you start writing searches — it’s the long pole.

The application database is the better witness

Web logs tell you a request pattern. The Metabase application database tells you what the attacker did, and it survives the upgrade untouched. Point a read-only psql session at the app DB (not through Metabase’s own UI; if the box is compromised you don’t want your investigative queries showing up in the attacker’s query history) and start here:

Superusers. SELECT id, email, is_superuser, is_active, date_joined, updated_at FROM core_user WHERE is_superuser = true; Anything with a date_joined on or after August 3 is a high-priority investigative hit rather than a finding on its own: establish who created it and why before you call it evidence of compromise. Anything with an old date_joined and an updated_at inside the incident window deserves investigation, because it tells you an existing user row changed during the period you care about. It does not tell you which column changed. updated_at advances on any row modification, so a legitimate edit to some unrelated attribute moves it too. Settle that against audit history, your identity provider, or a known-good backup before anybody writes “promoted to superuser” in a ticket. Cross-reference permissions_group_membership against the Administrators group — the two can disagree, and the disagreement is interesting.

API keys. The api_key table holds name, key_prefix, creator_id, and created_at. An API key minted during the window is particularly durable persistence, because it is independent of any user session and truncating core_session, the remediation step every advisory lists, does nothing to revoke it. It is not the only thing an administrator could have left behind. SSO configuration, additional accounts, and downstream credentials all deserve the same scrutiny. It is, though, the one the standard remediation step walks straight past. Enumerate every key, establish an owner and a purpose for each, and delete anything unexplained.

Query history. query_execution gives you executor_id, started_at, result_rows, and whether the query was native. Sort by result_rows descending across the window and baseline the outliers by executor, hour, database_id, and card_id. Be careful what you conclude from it. A large result_rows surfaces bulk access, and native = true tells you the execution used a native query rather than the query builder. It says nothing about whether that SQL was ad-hoc or came from a saved question; card_id is the field carrying that distinction, and a null one means the execution didn’t run from a saved card. Neither field records an export. A query-builder question can be exported, and a native query can be run and abandoned. Use these to rank what to look at first, then settle the export question against Metabase activity and the warehouse’s own query and access logs. Also diff report_card. A new saved question whose dataset_query is a SELECT * against a table nobody has ever charted is a staging artifact.

audit_log and login_history are both worth pulling, with the caveat that audit_log is thin on OSS and the enterprise Audit UI in front of it is licensed, so you’ll be reading rows directly. And a compromised admin can delete from all of these. An audit trail with a suspiciously clean gap across August 3–6 is itself the finding — as is an empty core_session table you didn’t truncate yourself, which Resecurity flagged as a secondary indicator.

Rotation scope is bigger than the Metabase box

Here is the part that turns a BI incident into a data-platform incident. Metabase stores connection details for every attached database in metabase_database.details. Those details are encrypted at rest only if MB_ENCRYPTION_SECRET_KEY is set. It defaults to null, so go and check yours rather than assuming in either direction. Check it properly, too, because there is a second failure mode that catches the people who did set it. Metabase encrypts connection details for each database added after the key exists; for anything added before, the documented fix is to open each database under Admin and click Save. Until somebody does that, those rows stay in cleartext, and the docs note they “will continue to work normally,” so nothing in the product ever tells you they are sitting there. If your rows were plaintext, an attacker with admin read the Snowflake, Redshift, and BigQuery service credentials straight out of your app DB, and the blast radius is every row those service accounts can see.

So rotate credentials for every database attached to that instance, not just the warehouses. The details column does not discriminate, and a Postgres app database wired up for one dashboard is in there on the same terms as Snowflake. Then go looking on the warehouse side, where the telemetry is genuinely good. Snowflake’s SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY and LOGIN_HISTORY are the right tables; pivot on the Metabase service user and look for sessions originating from a client IP outside your egress ranges. Bear in mind the ACCOUNT_USAGE views lag, and not uniformly. Snowflake documents up to 45 minutes for QUERY_HISTORY and up to two hours for LOGIN_HISTORY, which matters if you’re trying to watch something live rather than reconstruct it. LOGIN_HISTORY retains 365 days, which for once is longer than you need. BigQuery, same idea through Cloud Audit Logs data_access, and here you catch a break: BigQuery is the exception to Google Cloud’s rule that data-access logs are off until somebody enables them. Google’s documentation is explicit that Data Access audit logs “except for BigQuery Data Access audit logs” are disabled by default, so for BigQuery they were already being written. The catch is where they land and for how long. Absent a sink somebody deliberately configured, they route to the _Default log bucket, where the standard retention period is 30 days. That is a retention setting rather than a property of the bucket, so it can be extended, and keeping them longer costs real money at volume. That’s a different failure mode from the one above, and worth separating: nobody had to switch this telemetry on, so your August 3 records exist. Whether you can still read them is a retention question, and for an investigation run today the window is open. It is also a countdown, which is the argument for doing this now rather than in September.

One more timing note. The Cloud incident window Metabase reported is narrow, and it does not bound your self-hosted exposure at all. Wiz’s teardown put public PoC availability at August 10, and roughly 2,500 internet-exposed instances in Shodan at the time. Anything reachable and unpatched after the 10th was in a commodity scanning window, not a targeted one. Different threat model, same queries.

Control mapping

Activity 800-53
Emergency patch of v58–v63 to fixed builds SI-2, SI-2(5), CM-3
Ingress retro-hunt to August 3 AU-6, AU-6(3), AU-11 (retention is the binding constraint)
Superuser and API key enumeration in the app DB AC-2(4), AC-2(7), IA-5
Warehouse credential rotation, MB_ENCRYPTION_SECRET_KEY IA-5(1), SC-28, SC-28(1)
Removing the instance from public reachability SC-7, AC-4
Third-party notification (Metabase Cloud → tenant) IR-6, SR-8

The AU-11 row is the one to raise with whoever owns your retention budget. This incident is a clean, dated argument that seven days of hot proxy logs is not enough to answer “were we breached” for a bug disclosed three days after exploitation started.

Patch, then assume the patch bought you nothing retroactively. The finding you’re looking for is a superuser row or an API key that nobody will claim, and it’s sitting in a Postgres table that upgrading Metabase never touched.

Sources


This post was engineered and validated through a multi-agent AI workflow — drafted, adversarially reviewed by several independent models, checked against primary sources, and given a human review before publishing. See an inaccuracy, or found this useful? Leave a comment below — corrections and feedback are read and shape what comes next.

Leave a comment