Get in Touch

Tell us a bit about yourself — someone from our team will reach out shortly.

Max file size 10MB.
Uploading...
fileuploaded.jpg
Upload failed. Max size for files is 10 MB.
Send Request
Send Request
Request sent!
Thanks for reaching out - we've got your details. Someone from the Emphere team will reach out shortly.
Typical response:
within one business day
Done
Done
Oops! Something went wrong while submitting the form.

Emphere Engineering

Assurance

CVE-2026-63087: unauthenticated admin takeover via Grafana OnCall's v2 install endpoint

Grafana OnCall's v2 install endpoint ships with auth off, minting a plugin token to any anonymous caller; a second flaw grants Admin. We verified the fix.

Table of content
1.
Lorem ipsum dolor
  • July 17, 2026
  • 3 min read

CVE-2026-63087 is an unauthenticated compromise of self-hosted Grafana OnCall. The install endpoint InstallV2View, mounted at POST /api/internal/v1/plugin/v2/install/, sets both authentication_classes and permission_classes to empty. Its own parent view enforces plugin authentication. The install subclass removes it.

Emphere Evidence

[
  ["affected", "Grafana OnCall through 1.16.11, repo archived to grafana-cold-storage/oncall"],
  ["endpoint", "InstallV2View, POST /api/internal/v1/plugin/v2/install/"],
  ["authentication", "authentication_classes = () and permission_classes = (); the parent SyncV2View requires BasePluginAuthentication"],
  ["precondition", "none; the attacker posts the public default STACK_ID 5 and ORG_ID 100"],
  ["reproduced by Assurance", "unauthenticated POST returned 200 and wrote a real Organization and token to the database, on the running OnCall engine under DRF 3.15.2 and Django 4.2.27"]
]

The endpoint turns off its own parent's auth

SyncV2View is authenticated. InstallV2View extends it and overrides the auth away to nothing.

`class InstallV2View(SyncV2View):`
`    authentication_classes = ()`
`    permission_classes = ()`

`    def post(self, request: Request) -> Response:`
`        ...`
`        organization = self.do_sync(request)`
`        organization.revoke_plugin()`
`        provisioned_data = organization.provision_plugin()`
`        return Response(data=provisioned_data, status=status.HTTP_200_OK)`

The `do_sync` it inherits does not re-establish any identity. On the open-source branch it reads the expected `stack_id` and `org_id` from server settings and only checks that the request body matches them. Both are known values: `org_id` is hardcoded to 100, and `stack_id` defaults to 5, so an attacker simply supplies them. The serializer requires a `grafana_token` string in the body, but nothing ever verifies it.

`def do_sync(self, request: Request) -> Organization:`
`    serializer = SyncDataSerializer(data=data)`
`    if not serializer.is_valid():`
`        raise SyncException(serializer.errors)`
`    sync_data = serializer.save()`

`    if settings.LICENSE == settings.OPEN_SOURCE_LICENSE_NAME:`
`        stack_id = settings.SELF_HOSTED_SETTINGS["STACK_ID"]   # 5`
`        org_id   = settings.SELF_HOSTED_SETTINGS["ORG_ID"]     # 100`
`    ...`
`    if sync_data.settings.org_id != org_id or sync_data.settings.stack_id != stack_id:`
`        raise SyncException(INVALID_SELF_HOSTED_ID)`
`    organization = get_or_create_organization(...)`
`    apply_sync_data(organization, sync_data)`
`    return organization`

So a match against two known constants is the only gate, and provision_plugin() then returns a fresh plaintext token to a caller who proved nothing.

The second flaw: a header that assigns your own role

The minted token authenticates through PluginAuthentication. When it resolves a user that does not exist yet, it falls back to a request header and trusts the role written there.

except User.DoesNotExist:
    user_data = dict(json.loads(request.headers.get("X-Oncall-User-Context")))
    ...
    user_sync_data = SyncUser(
        id=user_data["id"],
        ...
        role=user_data["role"],   # attacker-supplied, trusted verbatim
        ...
    )
    return get_or_create_user(organization, user_sync_data)

get_or_create_user maps that string to a database role with no check. Supply "role": "Admin" and you are Admin.

The exploit

StepWhat happens1Anonymous POST /api/internal/v1/plugin/v2/install/ with a body carrying the public defaults stack_id: 5, org_id: 100.2InstallV2View has no authentication_classes, so the request is never authenticated.3do_sync confirms only that the body matches the two default IDs. The supplied grafana_token is never verified.4provision_plugin() mints a plaintext onCallToken and returns it.5The caller presents that token plus X-Oncall-User-Context: {"role":"Admin", ...} to any PluginAuthentication endpoint.6On User.DoesNotExist, the header's role is trusted and an Admin user is created.7The caller is Admin: create integrations, revoke the legitimate plugin token, repoint OnCall's Grafana calls at an attacker-controlled host.

No token, no credential, and no prior access is required at any step.

The admin-check fix is a trap

The tempting fix is to require that the caller be a Grafana Org Admin before provisioning. We built that and ran it against the real view. It does not work, and the reason is structural: there is no authenticated principal on this endpoint to check. The only grafana_token in the request is the attacker's own body value, pointing at a grafana_url the attacker also controls. An admin check asks that attacker-controlled Grafana whether the attacker is an admin, and it happily says yes.

Emphere Evidence

[
  ["admin-check fix, applied to InstallV2View", "unauthenticated POST still returned 200 and still minted a token"],
  ["exploit steps blocked", "0 of 2"],
  ["why", "no authenticated principal exists to attach an authority check to; the token and the Grafana it validates against are both attacker-supplied"]
]

An authority check only means something once there is an authenticated identity. Here there is none, so the check is decoration.

The fix that actually closes it

Two changes, both required. Neither alone is enough.

1. Put real authentication on the install endpoint. Not the plugin token this endpoint mints, which would lock out first install, but an unforgeable, operator-provisioned install secret the attacker cannot supply.

Emphere Diff

{
  "file": "engine/apps/grafana_plugin/views/install_v2.py",
  "diff": " class InstallV2View(SyncV2View):\n     authentication_classes = ()\n     permission_classes = ()\n \n     def post(self, request: Request) -> Response:\n+        # Require an unforgeable, operator-provisioned install secret the\n+        # attacker cannot supply. Not the plugin token this endpoint issues.\n+        if request.headers.get(\"X-Self-Hosted-Install-Secret\") != settings.SELF_HOSTED_INSTALL_SECRET:\n+            return Response(status=status.HTTP_401_UNAUTHORIZED)\n         ..."
}

2. Stop trusting the client-supplied role in the user-context bootstrap. Derive the role server-side instead of reading it from the attacker-controlled header.

Emphere Diff

{
  "file": "engine/apps/auth_token/auth.py",
  "diff": "                 user_sync_data = SyncUser(\n                     id=user_data[\"id\"],\n                     ...\n-                    role=user_data[\"role\"],\n+                    role=LegacyAccessControlRole.NONE,  # never trust a client-supplied role\n                     ...\n                 )"
}

Applying this is a contract change. The install endpoint now requires a header no client sends today, so every legitimate install path, the Helm chart, provisioning scripts, and OnCall's own install test, has to be updated to send the install secret in lockstep.

How we verified it

Assurance stood up the real OnCall engine for this: real Django 4.2.27 and DRF 3.15.2, a real database with all migrations applied, and every request driven through the project's own URLconf, middleware, and DRF dispatch. We confirmed each result by inspecting the database directly after every request. Fidelity is full; the only thing not real was the external Grafana the endpoint calls, and the vendor's own client handles an unreachable Grafana exactly as the harness saw it.

Emphere Evidence

[
  ["red, unpatched", "unauthenticated POST wrote a real Organization and PluginAuthToken to the database, 0 rows to 1, and the forged X-Oncall-User-Context role Admin created a real ADMIN user"],
  ["green, patched", "the same attack returned 401 with zero rows written, and the forged Admin role mapped to NONE"],
  ["the admin-check fix, for contrast", "left the exploit open: 200, token minted, 0 of 2 steps blocked"],
  ["legitimate install", "with the correct install secret, a real install still completed end to end: 200, token minted, org provisioned, no lockout"],
  ["app health", "boots clean (manage.py check reported no issues), migrations consistent, patched files import cleanly"],
  ["project test suite", "150 passed, 1 failed; the one failure is the shipped install test omitting the new secret header, confirmed a pure contract change (it passes on unpatched code and passes again once the header is added)"],
  ["fidelity", "full: real Django app, real database, real dispatch, migrations applied"]
]

The fix nobody upstream is going to ship

OnCall is archived at 1.16.11 and moved to cold storage, so no vendor patch is coming. The change above is the one we verified, on the real view under real DRF, actually stops the exploit: the empty-auth endpoint stops minting tokens to anonymous callers, and the trusted-role header stops handing out Admin. It is here for operators holding the line while they migrate off, who need to build from source in the meantime.

An install endpoint that switches off its own parent's authentication is not a smaller version of a secured endpoint. It is an unauthenticated one, and no check you add after the token is minted can put the authentication back.