Overview
Metabase Cloud: The winner takes it all

Metabase Cloud: The winner takes it all

June 25, 2026
5 min read

With how many bugs AI is starting to find, the hard part is no longer finding them. It is knowing which ones actually matter:

  • Which are interesting?
  • Which are devastating?
  • Which ones let an attacker compromise the entire infra?

At Hacktron be it research or an AI pentest our goal is to find the bugs that can completely compromise companies, and helping secure them before attackers do.

Previously we wrote SupaPwn showing how we got Supabase tenants access. This time it is Metabase, where we could have pwned every cloud tenant.

H2 INIT-imidates again

H2 is an embedded Java database that Metabase ships by default, and it is not a plain data store. Its SQL can read and write files on the host and call Java methods inside the Metabase process, so making H2 run our SQL is effectively an RCE primitive on the server.

This is a known attack, and hence Metabase have preventions for this, one strips INIT= out of the connection string, and one rejects any H2 connection that carries an INIT.

However, both checks can be bypassed! They only look for INIT inside the URL. So instead of putting it in the URL, we put it in its own separate field next to the URL.

The reason it survives is that only the URL ever gets cleaned. The H2 driver runs the sanitizer on the db URL and nothing else:

(defmethod sql-jdbc.conn/connection-details->spec :h2
[_ details]
(driver-api/spec :h2 (cond-> details
(string? (:db details)) (update :db connection-string-set-safe-options))))

Every other field is passed on untouched to the connection builder, which copies it into the connection as-is, including INIT:

(defmethod spec :h2
[_ {:keys [db]
:or {db "h2.db"}
:as opts}]
(merge {:classname "org.h2.Driver"
:subprotocol "h2"
:subname db}
(dissoc opts :db)))

That last line, dissoc opts :db, takes everything except the URL and forwards it as-is, so our INIT field flows along.

But we still need to get our malicious H2 database saved, and H2 connections are turned off by default. The normal POST /api/database path runs can-connect? :h2, which throws “H2 is not supported as a data warehouse” before our INIT is ever reached, so nothing gets stored. The way in is the EE serialization import endpoint: it builds Database records straight from the uploaded YAML and never calls can-connect?, so our H2 record (with its INIT field) gets persisted unchecked. Once it exists, we ask Metabase to sync the database. Sync flips that switch on by itself before opening the connection:

;; src/metabase/sync/task/sync_databases.clj
(binding [driver.settings/*allow-testing-h2-connections* true]
(sync-and-analyze/sync-database! database))

With being able to run H2, we use an arb file read/write to get RCE. We use two INIT statements.

The first INIT abuses H2’s CSVWRITE as a plain file-write, dropping our Clojure code at /tmp/evil.clj.

details:
db: dummy
subname: mem:evildb
INIT: |-
CALL CSVWRITE('/tmp/evil.clj', 'SELECT ''<clojure payload>''', 'writeColumnHeader=false fieldDelimiter=')

The second INIT points a SQL alias at a Java method already loaded in the Metabase process, clojure.lang.Compiler.loadFile, then calls it on the file we just wrote:

details:
db: dummy
subname: mem:h2rcestep2
INIT: |-
CREATE ALIAS IF NOT EXISTS LOADCLJ FOR "clojure.lang.Compiler.loadFile"; CALL LOADCLJ('/tmp/evil.clj')

Running against our Metabase Cloud tenant, gives us code execution inside our pod in Metabase Cloud infra.

Pwning other tenants

The cloud environment had a chain of misconfigurations that turned our pod into disclosing secrets of every other tenant’s (pods).

The EC2 Instance Metadata Service was accessible from inside the pod, which gives us temporary AWS credentials for the node’s IAM role, eks-cluster-prod-metabase-1-nodes.

With those credentials, EC2, ECR, S3, and EKS describe calls all succeeded, exposing the full production instance inventory and container registry.

We used the obtained node credentials to mint an EKS bearer token and authenticate to the cluster API as - system:node:ip-10-241-27-69.ec2.internal

Kubernetes’ node authorizer intentionally lets a node read the specs and secrets of pods scheduled on it. The security model assumes that compromising a node already means compromising its pods. Hence the node identity we just got allows us to read all other pods (tenants) and their secrets..

GET /api/v1/pods?fieldSelector=spec.nodeName=ip-10-241-27-69.ec2.internal

The node was running 24 tenant Metabase pods, each in its own namespace. Every pod spec carries its environment variables, and the node authorizer let us read all of them.

These were the tenant pods we got access to:

hosting-042af00fa4333505 hosting-40c786e3ba5960c3 hosting-f254ea061316c421
hosting-2c0f1fb4a5615bec hosting-34e9c39bff085481 hosting-384569e8d44d560e
hosting-a8a6323f02333560 hosting-2a390b146ac5444d hosting-624a2aaef4dc78f1
hosting-37197ca8c39b5b6b hosting-1067d93d0193b25a hosting-e7c68b69049bdc8e
hosting-1f94d52069dbcd85 hosting-94950cc15a15c03e hosting-7786997a5fb7345b
hosting-ac6e233bd72a6746 hosting-7d41dad06f9baf63 hosting-d89f6af7e1ef27fc
hosting-b074e81be8a783c1 hosting-576d0a8b06462953 hosting-68e56f67966baef2
hosting-ec145e2b3509292c hosting-dbd3baaeba81114b (+ system pods)
hosting-0ffd837a059772ae hosting-f6bebd9abed446bb
CredentialWhat it grants
PostgreSQL connection stringsFull read/write to each tenant’s database
MB_ENCRYPTION_SECRET_KEYDecrypt all stored Metabase secrets per tenant
MB_API_KEYFull admin API access to each instance
Site URLsIdentify each customer

A small sample of the confirmed tenant instances (site URLs and API keys redacted):

NamespaceSite URLAPI Key (truncated)
hosting-94950cc15a15c03ehttps://-.metabaseapp.commb_api_key_ec191ad1…
hosting-d89f6af7e1ef27fchttps://data.******.commb_api_key_9481e01e…
hosting-68e56f67966baef2https://**************.metabaseapp.commb_api_key_96277d1b…

Fun fact: While doing this recon on a friday night, this triggered some logging alerts for Metabase team xD

Screenshot 2026-06-24 at 12-55-22.png

With the site URLs + MB_API_KEY in hand we can access the Metabase cloud instance as admin user.

This can be scaled further: with roughly 24 tenants per node and many nodes in the prod-metabase-1 cluster, each node compromise yields all of its tenants.

Conclusion

This chained exploitation highlights that a single sanitization oversight can cascade into a total multi-tenant cluster compromise. Security for such systems cannot rely on single-point defenses, especially when complex third-party drivers or legacy protocols are involved.

The Metabase team was able to patch the underlying connection logic and harden their cloud infrastructure boundaries before malicious actors could exploit the blast radius.

As AI-driven testing continues to uncover hidden entry points, building resilient, defense-in-depth architectures is the only way to ensure that a compromise at the application layer doesn’t equal a compromise of the entire cloud.