GHSA-99gv-2m7h-3hh9 Nezha Monitoring: RoleMember can run shell on every server (cross-tenant RCE) via POST /api/v1/cron
Summary`nezha`'s dashboard supports two user roles: `RoleAdmin` (Role==0) and `RoleMember` (Role==1). The cron routes `POST /api/v1/cron` and `PATCH /api/v1/cron/:id` are wired through `commonHandler` (any authenticated user) rather than `adminHandler`, and the per-server permission check on cron creation has a vacuous-true bypass.A `RoleMember` user can create a scheduled cron task with `Cover=CronCoverAll, Servers=[]` and an arbitrary `Command`. At every tick of the scheduler, the dashboard pushes that command to **every server in the global `ServerShared` map** — including servers that belong to other tenants (admin's servers, other members' servers). Each agent runs the command and returns the output, which is then sent to the attacker's own NotificationGroup → attacker-controlled webhook.Net effect: any `RoleMember` (including a self-bound OAuth2 user, if the dashboard has OAuth2 configured) gets pre-validated cross-tenant RCE on every nezha-monitored host in the deployment.Affected versionsCommit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`.The auth gateCompare with `/user` (adminHandler-gated). `commonHandler` (controller.go:214-218) only requires JWT auth — any role passes.The vacuous-true permission bypass`ServerShared.CheckPermission` (singleton.go:249-261) iterates `idList`; with `cf.Servers == []`, the for-range runs zero times and returns `true`. So a member can submit a cron with `Servers=[]` and skip the permission check entirely.The cross-tenant fanout sinkCompare with the **service**-task path, which DOES gate per-server (`canSendTaskToServer` at `cmd/dashboard/rpc/rpc.go:179-190` enforces `task.UserID == server.UserID || taskOwnerIsAdmin`). The cron path skips that check entirely.The output-exfil channel`result.GetData()` is the agent's stdout/stderr. With `cr.PushSuccessful = true` set by the attacker, the command output is exfil'd to whatever NotificationGroup the attacker chose. Members can create their own Notifications (Webhook-type via `POST /api/v1/notification`) and Groups (`POST /api/v1/notification-group`), and these are owned by the member — `NotificationShared.CheckPermission` passes. So the attacker creates a member-owned webhook pointing at `https://attacker.example.com/exfil`, then references it in the cron.End-to-end PoCPre-conditions: attacker has `RoleMember` credentials. Either admin gave them an account, or the dashboard has OAuth2 self-bind enabled.Step 0: Get JWT (standard login).Step 1: Create a webhook notification + group owned by the member, pointing at attacker server.Step 2: Create the cross-tenant cron.Step 3: Within ~1 second, every monitored agent in the deployment runs the command and pushes output to the attacker's webhook with the per-server hostname. From `c1c1cd1.../webhook.site/<attacker>`:(Output is shown for each of the N agents in the deployment, one webhook fire per agent.)Reachability — additional notesDefault deployment: there is no requirement that an admin even creates a member account explicitly — the dashboard may have OAuth2 self-registration via `singleton.Conf.Oauth2[provider]`. If admin enables OAuth2 auto-bind, any GitHub user can become a member; combined with this bug, that's near-pre-auth RCE.The nezha agent typically runs as **root** (it monitors disk/CPU/processes that require root on Linux); see https://nezha.wiki for the standard install script that uses `sudo systemctl`.The attack works whether `Cover=CronCoverAll` (deny-list, empty) or `Cover=CronCoverIgnoreAll` (allow-list — but you'd need server IDs you don't own, which requires a separate enumeration step). `Cover=CronCoverAll, Servers=[]` is the simplest payload.Suggested fix**Switch `/cron` writes to `adminHandler`.** Same fix as the `/user` and `/setting` routes already use.**Per-server permission gate in `CronTrigger`.** Defense-in-depth: even an admin should not push a cron task to a server they don't own. Add the equivalent of `canSendTaskToServer(task, server)` (already used in `service/rpc/rpc.go:179-190` for service tasks) before each `s.TaskStream.Send()`:**Reject empty `Servers` for `Cover=CronCoverAll`.** A deny-list with zero entries blasting an unrestricted command at every host is dangerous regardless of role:Optional: forbid `cf.PushSuccessful=true` for non-admin to slow down the output-exfil step.Severity**CVSS 3.1:** Critical — `AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` ≈ 9.0.PR:L because attacker needs `RoleMember` (admin-issued, or OAuth2 auto-bind).S:C because compromise of the dashboard yields RCE on every connected agent host (a separate trust zone).C/I/A:H because RCE-as-root is the primary impact.**Auth:** authenticated `RoleMember` (Role == 1).**CWE:** CWE-862 (Missing Authorization), CWE-78 (OS Command Injection), CWE-269 (Improper Privilege Management).Reproduction environmentTested against: `nezhahq/nezha` master @ `50dc8e660326b9f22990898142c58b7a5312b42a`.Code locations:Auth gate: `cmd/dashboard/controller/controller.go:131-135` (commonHandler), 214-236 (handler defs)Bypass: `cmd/dashboard/controller/cron.go:53-55` (vacuous-true `CheckPermission` on empty `cf.Servers`)Sink: `service/singleton/crontask.go:133-181` (`CronTrigger` iterates all servers)Output exfil: `service/rpc/nezha.go:56-76`Comparison (correct gating): `cmd/dashboard/rpc/rpc.go:179-190` (`canSendTaskToServer` for service tasks)ReporterEddie Ran. Filed via the GitHub Security Advisory reporter API. nezha's `SECURITY.md` mentions email `hi@nai.ba`; happy to follow up there if the maintainer prefers email coordination.This is a follow-up to the same auth-bypass class as `GHSA-w4g9-mxgg-j532` (NEZHA-001 — `/notification` SSRF, also commonHandler-gated). The cron path is materially worse because it produces RCE rather than SSRF.Companion finding: nezhahq/agent plaintext gRPC channel (NEZHA-AGENT-001)Filing channel issue: `nezhahq/agent` has private vulnerability reporting disabled (verified via `GET /repos/nezhahq/agent/private-vulnerability-reporting`), so I cannot file the companion finding via the GHSA reporter API. Adding it here so it lands in the same maintainer triage thread.**Summary.** The dashboard→agent control channel uses plaintext gRPC by default. `agentConfig.TLS` zero-value is `false`; the install script's `[y/N]` prompt defaults to `false`. `AuthHandler.RequireTransportSecurity()` returns `false`. An on-path attacker on the dashboard↔agent network path captures `client_secret`+`client_uuid`, terminates the agent's TCP connection, and injects a `CommandTask` over plaintext gRPC. The agent runs the task via `sh -c <attacker-string>` as the systemd-installed UID (typically root).**Adjacent-network attack vector** (corp LAN, datacenter VLAN, cloud VPC peer, hostile WiFi for self-hosters).**Why filable.** This *completes the threat model* for the dashboard-side findings (NEZHA-001 / -002 / -003) — those findings all implicitly assume a trusted dashboard→agent channel. NEZHA-AGENT-001 disproves that assumption: a co-resident network attacker (no auth required) gets root on every agent host, with no dashboard compromise needed.**Severity:** High (CVSS ~7.5, AV:A/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H). Adjacent-network reach + RCE-as-root, post-pwn fanout to every monitored host.**Suggested fix.**Make TLS the install-script default (`[Y/n]`) instead of `[y/N]`.Even if operator opts out of CA-issued TLS, generate a self-signed cert pinned to the dashboard's published key on first connect; refuse plaintext.Add `AuthHandler.RequireTransportSecurity()` returning `true` unconditionally.Document this as a **must-enable** in the agent install README.Disclosure draft is on file in the moneyhunter campaign workspace under `findings/NEZHA-AGENT-001-DISCLOSURE.md` and `findings/NEZHA-AGENT-001.yaml` — happy to share by whatever channel the maintainer prefers (these are deliverable as a single coordinated email or as a fork-PR-with-private-collaboration if PVR gets enabled on `nezhahq/agent`).— Eddie Ran
CVSS v3.1 score: 9.9