{"id":"CVE-2026-47120","aliases":["GHSA-rxf6-wjh4-jfj6","GO-2026-5645"],"url":"https://o3.security/vulnerability/CVE-2026-47120","summary":"Nezha Monitoring: RoleMember can fire other users' cron tasks via AlertRule.FailTriggerTasks (no ownership check)","details":"## Summary\n\n`createAlertRule` and `createService` (and their `update*` siblings) accept `FailTriggerTasks []uint64` and `RecoverTriggerTasks []uint64` — IDs of cron tasks to fire when the alert/service trips. The validation function only validates the alert's `Rules.Ignore` server map; it never checks that the cron task IDs in `FailTriggerTasks` / `RecoverTriggerTasks` belong to the caller.\n\nWhen the alert fires, `singleton.CronShared.SendTriggerTasks(taskIDs, triggerServer)` (`service/singleton/crontask.go:113-127`) looks up those task IDs in the global cron registry and executes them via `CronTrigger`. For non-`AlertTrigger` cover modes, `CronTrigger` fans the command out to every server in `ServerShared.Range` with no ownership check.\n\nNet effect: a `RoleMember` can attach their alert rule (or service monitor) to **another user's** cron task ID — including admin's crons. When the alert trips, the admin's cron command runs across every server (or every server in its allow/deny list).\n\nThis is the same fanout/auth-bypass class as `NEZHA-002` (cron creation), but reachable by a different code path: even if `/cron` writes are restricted to admin, this `/alert-rule` and `/service` writes are member-reachable and let a member invoke pre-existing admin crons.\n\n## Affected versions\n\nCommit `50dc8e660326b9f22990898142c58b7a5312b42a` and earlier on `master`.\n\n## Reachability chain\n\n1. `POST /api/v1/alert-rule` (or `POST /api/v1/service`) is `commonHandler`-gated — any authenticated user.\n2. `createAlertRule` / `createService` accepts `FailTriggerTasks` and `RecoverTriggerTasks` from the request body without validating ownership.\n3. `validateRule` (`cmd/dashboard/controller/alertrule.go:169-196`) only checks `rule.Ignore` server IDs — not the trigger task IDs.\n4. `validateServers` (`cmd/dashboard/controller/service.go:543-549`) only checks the service's `SkipServers` map — not the trigger task IDs.\n5. When the alert/service trips: `service/singleton/alertsentinel.go:170, 180` and `service/singleton/servicesentinel.go:747, 750` call `CronShared.SendTriggerTasks(...)`.\n6. `SendTriggerTasks` (`service/singleton/crontask.go:113-127`) iterates the requested task IDs against `c.list` and calls `CronTrigger(c, triggerServer)()` for each — no ownership check.\n7. `CronTrigger` then fans the cron's `Command` to every connected agent (per `Cover` rules).\n\n## Code locations\n\n```go\n// cmd/dashboard/controller/alertrule.go:47-77\nfunc createAlertRule(c *gin.Context) (uint64, error) {\n    var arf model.AlertRuleForm\n    var r model.AlertRule\n    if err := c.ShouldBindJSON(&arf); err != nil { return 0, err }\n    uid := getUid(c)\n    r.UserID = uid\n    r.Name = arf.Name\n    r.Rules = arf.Rules\n    r.FailTriggerTasks = arf.FailTriggerTasks       // <-- attacker-controlled task IDs\n    r.RecoverTriggerTasks = arf.RecoverTriggerTasks // <-- ditto\n    r.NotificationGroupID = arf.NotificationGroupID\n    enable := arf.Enable\n    r.TriggerMode = arf.TriggerMode\n    r.Enable = &enable\n\n    if err := validateRule(c, &r); err != nil { return 0, err }   // only checks rule.Ignore servers\n    ...\n}\n```\n\n```go\n// cmd/dashboard/controller/alertrule.go:169-196\nfunc validateRule(c *gin.Context, r *model.AlertRule) error {\n    if len(r.Rules) > 0 {\n        for _, rule := range r.Rules {\n            if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) {\n                return singleton.Localizer.ErrorT(\"permission denied\")\n            }\n            // ... duration/cycle validation only\n        }\n    }\n    // BUG: no check on r.FailTriggerTasks or r.RecoverTriggerTasks ownership.\n    return nil\n}\n```\n\n```go\n// service/singleton/crontask.go:113-127\nfunc (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) {\n    c.listMu.RLock()\n    var cronLists []*model.Cron\n    for _, taskID := range taskIDs {\n        if c, ok := c.list[taskID]; ok {                 // <-- looks up ANY cron in global state\n            cronLists = append(cronLists, c)\n        }\n    }\n    c.listMu.RUnlock()\n    // BUG: no ownership check between alert.UserID and cron.UserID before invoking.\n    for _, c := range cronLists {\n        go CronTrigger(c, triggerServer)()\n    }\n}\n```\n\n```go\n// service/singleton/crontask.go:138-181 — CronTrigger\nreturn func() {\n    if cr.Cover == model.CronCoverAlertTrigger {\n        // alert-only: only sends to triggerServer (the member's server, when alert was triggered by it)\n        if s, ok := ServerShared.Get(triggerServer[0]); ok && s.TaskStream != nil {\n            s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand})\n        }\n        return\n    }\n    // For Cover=CronCoverAll or CronCoverIgnoreAll: fan out to every server.\n    for _, s := range ServerShared.Range {\n        if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] { continue }\n        if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] { continue }\n        if s.TaskStream != nil {\n            s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand})\n        }\n    }\n}\n```\n\n## PoC\n\nPre-conditions: attacker has `RoleMember` credentials. Admin has at least one pre-existing cron with `Cover=CronCoverAll` or `Cover=CronCoverIgnoreAll` (i.e., a \"run on all servers\" maintenance cron — common in monitoring deployments).\n\nStep 1: Enumerate admin cron IDs by ID-guessing. Try IDs 1..N; create AlertRule referencing each, see if the alert handler accepts.\n\nStep 2: Create an alert rule referencing the admin's cron and pointed at an offline-trigger condition on the member's own server.\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\ncurl -sX POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\n    -d '{\"name\":\"trip\",\"rules\":[{\"type\":\"offline\",\"duration\":3,\"min\":1.0,\"cover\":\"member-server-id\"}],\"fail_trigger_tasks\":[1,2,3,4,5],\"recover_trigger_tasks\":[],\"notification_group_id\":0,\"trigger_mode\":0,\"enable\":true}' \\\n    http://nezha.example.com/api/v1/alert-rule\n```\n\nStep 3: Stop the agent on the member's own server (or unplug it). The alert trips after `duration` seconds. `SendTriggerTasks([1,2,3,4,5], member-server-id)` runs.\n\nStep 4: For each cron ID in the list, if that cron exists in the global registry and has `Cover=CronCoverAll/IgnoreAll`, its `Command` runs on every server.\n\nThe same chain works via `POST /api/v1/service` (service-monitor with `fail_trigger_tasks`).\n\n## Composability with NEZHA-002\n\nIf `NEZHA-002` is unfixed, this chain is redundant — the member already has direct cron-create access. With `NEZHA-002` fixed, this still gives the member a means to invoke any **pre-existing** admin cron with the member's chosen trigger condition. The fix surface is also independent (alertrule/service write paths, not /cron writes).\n\n## Suggested fix\n\nIn `validateRule` (and `validateServers`):\n\n```go\nif !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) {\n    return singleton.Localizer.ErrorT(\"permission denied\")\n}\nif !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) {\n    return singleton.Localizer.ErrorT(\"permission denied\")\n}\n```\n\nDefense-in-depth in `SendTriggerTasks`: enforce that `task.UserID == alert.UserID || alertOwnerIsAdmin || taskOwnerIsAdmin`.\n\n## Severity\n\n  - PR:L because RoleMember credentials needed.\n  - AC:H because attacker has to ID-guess admin cron IDs and have an alert-trip vector. (For a deployment where the attacker has visibility into max cron ID via UI hints or the `id`-query echo, AC drops to L.)\n  - S:C because the cron command runs on every connected agent (different trust zone).\n- **Auth:** authenticated `RoleMember`.\n\n## Reproduction environment\n\n- Tested against: `nezhahq/nezha` master @ `50dc8e660326b9f22990898142c58b7a5312b42a`.\n- Code locations:\n  - `cmd/dashboard/controller/alertrule.go:47-77` (createAlertRule), 91-131 (updateAlertRule), 169-196 (validateRule)\n  - `cmd/dashboard/controller/service.go:404-445` (createService), 459-509 (updateService), 543-549 (validateServers)\n  - `service/singleton/crontask.go:113-127` (SendTriggerTasks), 133-181 (CronTrigger)\n  - `service/singleton/alertsentinel.go:170, 180` (alert-fire callsite)\n  - `service/singleton/servicesentinel.go:742-750` (service-fire callsite)\n\n## Reporter\n\nEddie Ran. Filed via reporter API. Companion to NEZHA-001/002 — same auth-bypass class but a different write path.","published":"2026-06-12T21:02:50.499Z","modified":"2026-08-12T03:51:29.585986098Z","cvss":{"score":7.1,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L"},"epss":{"score":0.00261,"percentile":0.18013,"asOf":"2026-08-15"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/nezhahq/nezha","fixedVersion":"1.14.15-0.20260517022419-d7526351cf97"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/47xxx/CVE-2026-47120.json"},{"type":"ADVISORY","url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-rxf6-wjh4-jfj6"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47120"},{"type":"PACKAGE","url":"https://github.com/nezhahq/nezha"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:29.585986098Z"}}