Hacktron

AI is starting to find more bugs than anyone can triage, so the useful question is which of them let an attacker compromise the entire infrastructure.

Previously we wrote SupaPwn about how we got access to Supabase tenants. This time it is Metabase, where we got code execution in our tenant’s pod and read the environment variables of 24 other tenant pods on its node.

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 has 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))

Once H2 runs our SQL, we use arbitrary file read and 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 this 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 let our pod read the secrets of every other tenant pod on the node.

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
Credential Initial assumption and verified result
PostgreSQL connection strings Initially assumed to grant full read/write to each tenant’s database; not tested
MB_ENCRYPTION_SECRET_KEY Initially assumed to decrypt all stored Metabase secrets per tenant; not tested
MB_API_KEY Initially assumed to grant full admin API access to each instance; did not grant access
Site URLs Identify each customer

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

Namespace Site URL API key (truncated)
hosting-94950cc15a15c03e https://*****-*****.metabaseapp.com mb_api_key_ec191ad1…
hosting-d89f6af7e1ef27fc https://data.******.com mb_api_key_9481e01e…
hosting-68e56f67966baef2 https://**************.metabaseapp.com mb_api_key_96277d1b…

Our recon on a Friday night triggered logging alerts at Metabase, and someone from their team messaged us:

hi. we just saw some interesting logs. Do yall have a writeup or information that you can share?

With roughly 24 tenants per node and many nodes in the prod-metabase-1 cluster, the same node-credential exposure could reveal environment variables for other tenants on a compromised node. We did not test whether those credentials gave further access.

Conclusion

A sanitizer that checked only the URL field gave us code execution in our tenant’s pod. From there, the node’s IAM role and Kubernetes identity let us read the environment variables of every tenant pod on that node.

Metabase patched the H2 connection handling and fixed the cloud configuration.