Summary
Nezha Monitoring: RoleMember can fire other users' cron tasks via AlertRule.FailTriggerTasks (no ownership check)
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.
When 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.
Net 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).
This 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.
Affected versions
Commit 50dc8e660326b9f22990898142c58b7a5312b42a and earlier on master.
Reachability chain
POST /api/v1/alert-rule(orPOST /api/v1/service) iscommonHandler-gated, any authenticated user.createAlertRule/createServiceacceptsFailTriggerTasksandRecoverTriggerTasksfrom the request body without validating ownership.validateRule(cmd/dashboard/controller/alertrule.go:169-196) only checksrule.Ignoreserver IDs, not the trigger task IDs.validateServers(cmd/dashboard/controller/service.go:543-549) only checks the service'sSkipServersmap, not the trigger task IDs.- When the alert/service trips:
service/singleton/alertsentinel.go:170, 180andservice/singleton/servicesentinel.go:747, 750callCronShared.SendTriggerTasks(...). SendTriggerTasks(service/singleton/crontask.go:113-127) iterates the requested task IDs againstc.listand callsCronTrigger(c, triggerServer)()for each, no ownership check.CronTriggerthen fans the cron'sCommandto every connected agent (perCoverrules).
Code locations
// cmd/dashboard/controller/alertrule.go:47-77
func createAlertRule(c *gin.Context) (uint64, error) {
var arf model.AlertRuleForm
var r model.AlertRule
if err := c.ShouldBindJSON(&arf); err != nil { return 0, err }
uid := getUid(c)
r.UserID = uid
r.Name = arf.Name
r.Rules = arf.Rules
r.FailTriggerTasks = arf.FailTriggerTasks // <-- attacker-controlled task IDs
r.RecoverTriggerTasks = arf.RecoverTriggerTasks // <-- ditto
r.NotificationGroupID = arf.NotificationGroupID
enable := arf.Enable
r.TriggerMode = arf.TriggerMode
r.Enable = &enable
if err := validateRule(c, &r); err != nil { return 0, err } // only checks rule.Ignore servers
...
}
// cmd/dashboard/controller/alertrule.go:169-196
func validateRule(c *gin.Context, r *model.AlertRule) error {
if len(r.Rules) > 0 {
for _, rule := range r.Rules {
if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) {
return singleton.Localizer.ErrorT("permission denied")
}
// ... duration/cycle validation only
}
}
// BUG: no check on r.FailTriggerTasks or r.RecoverTriggerTasks ownership.
return nil
}
// service/singleton/crontask.go:113-127
func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) {
c.listMu.RLock()
var cronLists []*model.Cron
for _, taskID := range taskIDs {
if c, ok := c.list[taskID]; ok { // <-- looks up ANY cron in global state
cronLists = append(cronLists, c)
}
}
c.listMu.RUnlock()
// BUG: no ownership check between alert.UserID and cron.UserID before invoking.
for _, c := range cronLists {
go CronTrigger(c, triggerServer)()
}
}
// service/singleton/crontask.go:138-181, CronTrigger
return func() {
if cr.Cover == model.CronCoverAlertTrigger {
// alert-only: only sends to triggerServer (the member's server, when alert was triggered by it)
if s, ok := ServerShared.Get(triggerServer[0]); ok && s.TaskStream != nil {
s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand})
}
return
}
// For Cover=CronCoverAll or CronCoverIgnoreAll: fan out to every server.
for _, s := range ServerShared.Range {
if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] { continue }
if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] { continue }
if s.TaskStream != nil {
s.TaskStream.Send(&pb.Task{Id: cr.ID, Data: cr.Command, Type: model.TaskTypeCommand})
}
}
}
PoC
Pre-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).
Step 1: Enumerate admin cron IDs by ID-guessing. Try IDs 1..N; create AlertRule referencing each, see if the alert handler accepts.
Step 2: Create an alert rule referencing the admin's cron and pointed at an offline-trigger condition on the member's own server.
TOKEN=$(curl -sX POST -H 'Content-Type: application/json' \
-d '{"username":"member","password":"hunter2"}' \
http://nezha.example.com/api/v1/login | jq -r .token)
curl -sX POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-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}' \
http://nezha.example.com/api/v1/alert-rule
Step 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.
Step 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.
The same chain works via POST /api/v1/service (service-monitor with fail_trigger_tasks).
Composability with NEZHA-002
If 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).
Severity
- PR:L because RoleMember credentials needed.
- 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.) - S:C because the cron command runs on every connected agent (different trust zone).
- 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
- Auth: authenticated
RoleMember.
Reproduction environment
- Tested against:
nezhahq/nezhamaster @50dc8e660326b9f22990898142c58b7a5312b42a. - Code locations:
cmd/dashboard/controller/alertrule.go:47-77(createAlertRule), 91-131 (updateAlertRule), 169-196 (validateRule)cmd/dashboard/controller/service.go:404-445(createService), 459-509 (updateService), 543-549 (validateServers)service/singleton/crontask.go:113-127(SendTriggerTasks), 133-181 (CronTrigger)service/singleton/alertsentinel.go:170, 180(alert-fire callsite)service/singleton/servicesentinel.go:742-750(service-fire callsite)
Reporter
Eddie Ran. Filed via reporter API. Companion to NEZHA-001/002, same auth-bypass class but a different write path.
Impact
The application does not perform an authorization check before performing a sensitive operation. Typical impact: unauthorized access to restricted functionality or data.
CVE-2026-47120 has a CVSS score of 5.4 (Medium). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (1.14.15-0.20260517022419-d7526351cf97); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
In validateRule (and validateServers):
if !singleton.CronShared.CheckPermission(c, slices.Values(r.FailTriggerTasks)) {
return singleton.Localizer.ErrorT("permission denied")
}
if !singleton.CronShared.CheckPermission(c, slices.Values(r.RecoverTriggerTasks)) {
return singleton.Localizer.ErrorT("permission denied")
}
Defense-in-depth in SendTriggerTasks: enforce that task.UserID == alert.UserID || alertOwnerIsAdmin || taskOwnerIsAdmin.
Frequently Asked Questions
- What is CVE-2026-47120? CVE-2026-47120 is a medium-severity missing authorization vulnerability in github.com/nezhahq/nezha (go), affecting versions >= 1.4.0, < 1.14.15-0.20260517022419-d7526351cf97. It is fixed in 1.14.15-0.20260517022419-d7526351cf97. The application does not perform an authorization check before performing a sensitive operation.
- How severe is CVE-2026-47120? CVE-2026-47120 has a CVSS score of 5.4 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of github.com/nezhahq/nezha are affected by CVE-2026-47120? github.com/nezhahq/nezha (go) versions >= 1.4.0, < 1.14.15-0.20260517022419-d7526351cf97 is affected.
- Is there a fix for CVE-2026-47120? Yes. CVE-2026-47120 is fixed in 1.14.15-0.20260517022419-d7526351cf97. Upgrade to this version or later.
- Is CVE-2026-47120 exploitable, and should I be worried? Whether CVE-2026-47120 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-47120 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-47120? Upgrade
github.com/nezhahq/nezhato 1.14.15-0.20260517022419-d7526351cf97 or later.