CVE-2026-53520
Description
In Nezha Monitoring <2.1.0, any authenticated user can claim the dashboard's host via NAT, preempting legitimate routing and enabling traffic interception.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
In Nezha Monitoring <2.1.0, any authenticated user can claim the dashboard's host via NAT, preempting legitimate routing and enabling traffic interception.
Vulnerability
In Nezha Monitoring versions 2.0.14 through 2.1.0 (excluding 2.1.0), the NAT management API (createNAT, updateNAT) is accessible to any authenticated user, including non-administrators [1]. The Domain field in NAT profiles is not validated against reserved hosts, allowing a user to set the domain to the dashboard's own HTTP Host (e.g., dashboard.example:8008). The NAT cache is indexed by domain and is checked before dispatching requests to the dashboard API, frontend, or gRPC handler; thus, a user-created NAT profile for the dashboard Host takes precedence [1].
Exploitation
An attacker must have a valid user account and own at least one server in the dashboard. Using the NAT API, the attacker creates or updates a NAT profile with the Domain set to the dashboard's HTTP Host. No special privileges beyond authentication and server ownership are required [1]. The dashboard's multiplexer then matches incoming requests to the attacker's NAT profile, routing them to ServeNAT, which sends a NAT task to the attacker's chosen agent and wraps the original HTTP request into the NAT IO stream [1].
Impact
A successful exploit allows the attacker to take over routing for the dashboard's global host name. An enabled claimed NAT profile redirects dashboard-bound requests to the attacker's agent, enabling traffic interception, potential data exposure, or denial of service by blocking legitimate requests. A disabled claimed NAT profile simply blocks matching dashboard requests, causing a denial of service [1]. The attacker gains the ability to manipulate or disrupt core dashboard functionality.
Mitigation
The vulnerability is patched in Nezha Monitoring version 2.1.0, released according to the advisory [1]. Users should upgrade to 2.1.0 or later. No workaround is mentioned in the available reference; as a general measure, restricting API access to administrators only could mitigate the issue, but the recommended action is to apply the patch [1].
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
1f18232eafab7fix(security): reserve dashboard hosts from NAT and re-check DDNS profile ownership at worker time
10 files changed · +390 −2
cmd/dashboard/controller/nat.go+8 −0 modified@@ -64,6 +64,10 @@ func createNAT(c *gin.Context) (uint64, error) { return 0, singleton.Localizer.ErrorT("permission denied") } + if singleton.IsReservedDashboardHost(nf.Domain) { + return 0, singleton.Localizer.ErrorT("permission denied") + } + uid := getUid(c) n.UserID = uid @@ -117,6 +121,10 @@ func updateNAT(c *gin.Context) (any, error) { return nil, singleton.Localizer.ErrorT("permission denied") } + if singleton.IsReservedDashboardHost(nf.Domain) { + return nil, singleton.Localizer.ErrorT("permission denied") + } + var n model.NAT if err = singleton.DB.First(&n, id).Error; err != nil { return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id)
cmd/dashboard/controller/setting.go+1 −0 modified@@ -99,6 +99,7 @@ func updateConfig(c *gin.Context) (any, error) { singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification singleton.Conf.Cover = sf.Cover singleton.Conf.InstallHost = sf.InstallHost + singleton.Conf.ReservedHosts = sf.ReservedHosts singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID singleton.Conf.SiteName = sf.SiteName
cmd/dashboard/controller/setting_reserved_hosts_test.go+24 −0 added@@ -0,0 +1,24 @@ +package controller + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/nezhahq/nezha/model" +) + +// GHSA-x6fg-52vr-hj4w: the admin settings endpoint must accept reserved_hosts +// so a reverse-proxy operator can declare the public dashboard hostnames the +// process itself never sees. Without binding here, the only way to set the +// field would be hand-editing the YAML, defeating the in-product guard. +func TestSettingForm_BindsReservedHosts(t *testing.T) { + body := []byte(`{"site_name":"X","reserved_hosts":"panel.example.com, admin.example.com"}`) + var sf model.SettingForm + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&sf); err != nil { + t.Fatal(err) + } + if sf.ReservedHosts != "panel.example.com, admin.example.com" { + t.Fatalf("reserved_hosts must decode into SettingForm, got %q", sf.ReservedHosts) + } +}
model/config.go+5 −0 modified@@ -54,6 +54,11 @@ type ConfigDashboard struct { EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist) + // GHSA-x6fg-52vr-hj4w:反代部署下 dashboard 的对外域名进程自身看不到, + // InstallHost/ListenHost 无法覆盖。运维在此用逗号分隔声明这些对外 host, + // 成员便无法注册与之冲突的 NAT 域名抢占路由。 + ReservedHosts string `koanf:"reserved_hosts" json:"reserved_hosts,omitempty"` + // IP变更提醒 EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"` IPChangeNotificationGroupID uint64 `koanf:"ip_change_notification_group_id" json:"ip_change_notification_group_id"`
model/setting_api.go+1 −0 modified@@ -8,6 +8,7 @@ type SettingForm struct { SiteName string `json:"site_name,omitempty" minLength:"1"` Language string `json:"language,omitempty" minLength:"2"` InstallHost string `json:"install_host,omitempty" validate:"optional"` + ReservedHosts string `json:"reserved_hosts,omitempty" validate:"optional"` CustomCode string `json:"custom_code,omitempty" validate:"optional"` CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"` WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP
service/singleton/ddns.go+20 −1 modified@@ -3,6 +3,7 @@ package singleton import ( "cmp" "fmt" + "log" "slices" "github.com/libdns/cloudflare" @@ -56,12 +57,30 @@ func (c *DDNSClass) Delete(idList []uint64) { c.sortList() } -func (c *DDNSClass) GetDDNSProvidersFromProfiles(profileId []uint64, ip *model.IP) ([]*ddns2.Provider, error) { +// profileOwnedByRealAdmin reports whether uid is a genuine admin user that +// may share its DDNS profiles globally. userIsAdmin(0) returns true as a +// "system resource" shortcut, but a profile with UserID==0 is a migration / +// default-value artifact, not an admin grant — sharing it with foreign server +// owners reopens GHSA-39g2-8x68-pmx8. A real admin always has a non-zero ID. +func profileOwnedByRealAdmin(uid uint64) bool { + return uid != 0 && userIsAdmin(uid) +} + +// GHSA-39g2-8x68-pmx8: bind-time CheckPermission 对「不存在的 profile ID」放行, +// 攻击者可预绑定将来才会被受害者创建的自增 ID。worker 解析时必须按 ownerUID +// 重新校验归属,跳过非 server owner(且非管理员)所有的 profile。 +func (c *DDNSClass) GetDDNSProvidersFromProfiles(profileId []uint64, ip *model.IP, ownerUID uint64) ([]*ddns2.Provider, error) { profiles := make([]*model.DDNSProfile, 0, len(profileId)) c.listMu.RLock() for _, id := range profileId { if profile, ok := c.list[id]; ok { + if profile.UserID != ownerUID && !profileOwnedByRealAdmin(profile.UserID) { + // Fail-closed skip: an admin may bind a member-owned profile, + // but worker-time only runs same-owner or real-admin profiles. + log.Printf("NEZHA>> Skipping DDNS profile %d (owner %d) for server owner %d: not owned by server owner or a real admin", profile.ID, profile.UserID, ownerUID) + continue + } profiles = append(profiles, profile) } else { c.listMu.RUnlock()
service/singleton/ddns_worker_authz_test.go+121 −0 added@@ -0,0 +1,121 @@ +package singleton + +import ( + "testing" + + "github.com/nezhahq/nezha/model" +) + +// newDDNSClassForTest builds a DDNSClass backed by an in-memory profile map, +// mirroring the production cache layout without touching the database. +func newDDNSClassForTest(profiles ...*model.DDNSProfile) *DDNSClass { + list := make(map[uint64]*model.DDNSProfile, len(profiles)) + for _, p := range profiles { + list[p.ID] = p + } + return &DDNSClass{ + class: class[uint64, *model.DDNSProfile]{ + list: list, + sortedList: profiles, + }, + } +} + +// GHSA-39g2-8x68-pmx8: a server owned by the attacker must not be able to +// drive a DDNS update through a DDNS profile owned by another (victim) user. +// The worker-time resolution must skip foreign-owned profiles. +func TestGetDDNSProvidersSkipsForeignOwnedProfile(t *testing.T) { + replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{ + 100: {Role: model.RoleMember}, // attacker / server owner + 200: {Role: model.RoleMember}, // victim / profile owner + }) + + victimProfile := &model.DDNSProfile{ + Common: model.Common{ID: 1, UserID: 200}, + Provider: model.ProviderDummy, + Name: "victim-profile", + AccessSecret: "victim-secret", + } + dc := newDDNSClassForTest(victimProfile) + + providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{1}, &model.IP{}, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(providers) != 0 { + t.Fatalf("expected foreign-owned profile to be skipped, got %d provider(s)", len(providers)) + } +} + +// A server owner using their own DDNS profile must still resolve normally. +func TestGetDDNSProvidersAllowsOwnedProfile(t *testing.T) { + replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{ + 100: {Role: model.RoleMember}, + }) + + ownProfile := &model.DDNSProfile{ + Common: model.Common{ID: 5, UserID: 100}, + Provider: model.ProviderDummy, + Name: "own-profile", + } + dc := newDDNSClassForTest(ownProfile) + + providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{5}, &model.IP{}, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(providers) != 1 { + t.Fatalf("expected owned profile to resolve, got %d provider(s)", len(providers)) + } +} + +// An admin-owned profile may be shared across servers (admin resources are +// global), so an admin profile resolves regardless of the server owner. +func TestGetDDNSProvidersAllowsAdminOwnedProfile(t *testing.T) { + replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{ + 1: {Role: model.RoleAdmin}, // admin / profile owner + 100: {Role: model.RoleMember}, + }) + + adminProfile := &model.DDNSProfile{ + Common: model.Common{ID: 9, UserID: 1}, + Provider: model.ProviderDummy, + Name: "admin-profile", + } + dc := newDDNSClassForTest(adminProfile) + + providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{9}, &model.IP{}, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(providers) != 1 { + t.Fatalf("expected admin-owned profile to resolve, got %d provider(s)", len(providers)) + } +} + +// GHSA-39g2-8x68-pmx8 (UserID==0 variant): userIsAdmin(0) returns true as a +// "system resource" shortcut, but a DDNS profile with UserID==0 is not a real +// admin grant — it is a migration/default-value artifact. A foreign server +// owner must NOT be able to drive an update through such a profile, so the +// worker must skip a UserID==0 profile that the caller does not own. +func TestGetDDNSProvidersSkipsUnownedZeroUserProfile(t *testing.T) { + replaceUserInfoMapForSecurityTest(t, map[uint64]model.UserInfo{ + 100: {Role: model.RoleMember}, // attacker / server owner + }) + + orphanProfile := &model.DDNSProfile{ + Common: model.Common{ID: 3, UserID: 0}, + Provider: model.ProviderDummy, + Name: "orphan-profile", + AccessSecret: "orphan-secret", + } + dc := newDDNSClassForTest(orphanProfile) + + providers, err := dc.GetDDNSProvidersFromProfiles([]uint64{3}, &model.IP{}, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(providers) != 0 { + t.Fatalf("expected UserID==0 foreign profile to be skipped, got %d provider(s)", len(providers)) + } +}
service/singleton/nat.go+70 −0 modified@@ -2,12 +2,81 @@ package singleton import ( "cmp" + "net" + "net/netip" "slices" + "strings" "github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/pkg/utils" ) +// GHSA-x6fg-52vr-hj4w: NAT 是 commonHandler,任意认证成员可创建。newHTTPandGRPCMux +// 在分发 dashboard/gRPC 之前先按 r.Host 命中 NAT,故成员若把 Domain 设成 dashboard +// 自身 host 即可抢占全局路由(disabled 触发 DoS,enabled 把请求隧道到攻击者 agent)。 +// 把 dashboard 的 InstallHost、ListenHost 以及运维声明的 ReservedHosts 列为 +// 保留 host:create/update 时拒绝,启动建表时丢弃,确保补丁前已植入的恶意记录 +// 在升级后不再生效。每个 host 拆成 hostname 后比较(忽略端口与大小写),反代/ +// 默认端口下的端口变体也拦得住。反代部署时进程看不到对外域名,运维把它配进 +// Conf.ReservedHosts(逗号分隔)即可让此处覆盖到公网入口。 +func IsReservedDashboardHost(domain string) bool { + if Conf == nil { + return false + } + + target := splitDashboardHostname(domain) + if target == "" { + return false + } + + hosts := []string{Conf.InstallHost, Conf.ListenHost} + hosts = append(hosts, strings.Split(Conf.ReservedHosts, ",")...) + for _, host := range hosts { + if reserved := splitDashboardHostname(host); reserved != "" && reserved == target { + return true + } + } + return false +} + +// splitDashboardHostname 归一化为小写 hostname,对 bracketed IPv6([::1]、 +// [::1]:8008)与裸 host:port 一视同仁,避免 candidate 与 reserved 解析形态不 +// 一致导致漏拦。两类等价形态也必须收敛,否则 guard 放行而 r.Host 精确命中 +// 仍能劫持路由: +// - DNS absolute name 的尾点(panel.example.com. 与 panel.example.com 指向同 +// 一主机),去掉单个尾点; +// - IP literal 的压缩/展开写法(::1 与 0:0:0:0:0:0:0:1),用 netip 归一到 +// 规范文本。 +func splitDashboardHostname(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return "" + } + if h, _, err := net.SplitHostPort(host); err == nil && h != "" { + host = h + } else { + host = strings.Trim(host, "[]") + } + host = strings.TrimSuffix(host, ".") + if addr, err := netip.ParseAddr(host); err == nil { + return addr.String() + } + return host +} + +// filterReservedNATProfiles 丢弃 Domain 命中 dashboard 保留 host 的 NAT 记录, +// 让 NewNATClass 启动建表时不把补丁前植入的劫持记录加载进路由表。 +func filterReservedNATProfiles(in []*model.NAT) []*model.NAT { + out := in[:0] + for _, profile := range in { + if profile == nil || IsReservedDashboardHost(profile.Domain) { + continue + } + out = append(out, profile) + } + return out +} + type NATClass struct { class[string, *model.NAT] @@ -18,6 +87,7 @@ func NewNATClass() *NATClass { var sortedList []*model.NAT DB.Find(&sortedList) + sortedList = filterReservedNATProfiles(sortedList) list := make(map[string]*model.NAT, len(sortedList)) idToDomain := make(map[uint64]string, len(sortedList)) for _, profile := range sortedList {
service/singleton/nat_reserved_host_test.go+139 −0 added@@ -0,0 +1,139 @@ +package singleton + +import ( + "testing" + + "github.com/nezhahq/nezha/model" +) + +func withReservedHostConf(t *testing.T, c *model.Config) { + t.Helper() + original := Conf + Conf = &ConfigClass{Config: c} + t.Cleanup(func() { Conf = original }) +} + +// GHSA-x6fg-52vr-hj4w: the reserved-host check is the single source of truth +// for both the create/update guard and the startup cache filter. It must +// reject any NAT domain whose hostname collides with the dashboard's own +// InstallHost / ListenHost, regardless of port or case. +func TestIsReservedDashboardHost(t *testing.T) { + withReservedHostConf(t, &model.Config{ + ConfigDashboard: model.ConfigDashboard{InstallHost: "dashboard.example:8008"}, + ListenHost: "10.0.0.5", + ListenPort: 8008, + }) + + cases := []struct { + name string + domain string + want bool + }{ + {"exact install host", "dashboard.example:8008", true}, + {"install host case-insensitive", "Dashboard.Example:8008", true}, + {"install host without port", "dashboard.example", true}, + {"install host arbitrary port", "dashboard.example:8443", true}, + {"listen host and port", "10.0.0.5:8008", true}, + {"listen host bare", "10.0.0.5", true}, + {"unrelated domain", "tunnel.member.example", false}, + {"empty domain", "", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsReservedDashboardHost(tc.domain); got != tc.want { + t.Fatalf("IsReservedDashboardHost(%q) = %v, want %v", tc.domain, got, tc.want) + } + }) + } +} + +// GHSA-x6fg-52vr-hj4w (reverse-proxy coverage): InstallHost/ListenHost alone +// cannot cover a dashboard reached through a reverse proxy on a public domain +// that the dashboard process never sees. ReservedHosts lets the operator +// declare those extra hostnames (comma-separated) so members still cannot +// register a NAT domain that collides with the public entry point. +func TestIsReservedDashboardHostHonoursReservedHostsList(t *testing.T) { + withReservedHostConf(t, &model.Config{ + ConfigDashboard: model.ConfigDashboard{ + InstallHost: "internal.example:8008", + ReservedHosts: "panel.example.com, Admin.Example.COM:443 , ", + }, + }) + + reserved := []string{ + "panel.example.com", + "panel.example.com:8443", + "admin.example.com", + "ADMIN.EXAMPLE.COM:443", + "internal.example", + } + for _, d := range reserved { + if !IsReservedDashboardHost(d) { + t.Errorf("IsReservedDashboardHost(%q) = false, want true (declared reserved host)", d) + } + } + + if IsReservedDashboardHost("tunnel.member.example") { + t.Error("unrelated member domain must not be reserved") + } + if IsReservedDashboardHost("") { + t.Error("empty domain must not be reserved") + } +} + +// The startup cache must not load a NAT record whose domain is reserved, so a +// malicious record planted before the patch cannot keep hijacking dashboard +// routing after upgrade. filterReservedNATProfiles is the gate NewNATClass +// runs over the DB result set. +func TestFilterReservedNATProfilesDropsReserved(t *testing.T) { + withReservedHostConf(t, &model.Config{ + ConfigDashboard: model.ConfigDashboard{InstallHost: "dashboard.example:8008"}, + }) + + in := []*model.NAT{ + {Common: model.Common{ID: 1}, Domain: "dashboard.example", Enabled: true}, + {Common: model.Common{ID: 2}, Domain: "tunnel.member.example", Enabled: true}, + {Common: model.Common{ID: 3}, Domain: "Dashboard.Example:9999", Enabled: false}, + } + out := filterReservedNATProfiles(in) + + if len(out) != 1 { + t.Fatalf("expected only the non-reserved profile to survive, got %d", len(out)) + } + if out[0].Domain != "tunnel.member.example" { + t.Fatalf("surviving profile must be the member tunnel, got %q", out[0].Domain) + } +} + +// GHSA-x6fg-52vr-hj4w (canonical-host coverage): the routing match is an exact +// lookup on r.Host, so a member who registers a NAT Domain that is a DNS/IP +// *equivalent* of the dashboard host — but a different literal string — still +// hijacks the matching r.Host. The guard must collapse the trailing DNS dot and +// the IPv6 compressed/expanded forms, or these variants slip past create/update. +func TestIsReservedDashboardHostCollapsesEquivalentForms(t *testing.T) { + withReservedHostConf(t, &model.Config{ + ConfigDashboard: model.ConfigDashboard{ + InstallHost: "panel.example.com", + ReservedHosts: "[::1]:8008", + }, + }) + + reserved := []string{ + "panel.example.com.", // trailing dot, no port + "panel.example.com.:8008", // trailing dot with port + "PANEL.EXAMPLE.COM.", // trailing dot, mixed case + "[0:0:0:0:0:0:0:1]:8008", // IPv6 expanded form of ::1 + "::1", // IPv6 compressed, bare + "[::1]", // IPv6 compressed, bracketed + } + for _, d := range reserved { + if !IsReservedDashboardHost(d) { + t.Errorf("IsReservedDashboardHost(%q) = false, want true (equivalent of reserved host)", d) + } + } + + if IsReservedDashboardHost("tunnel.member.example.") { + t.Error("unrelated member domain with trailing dot must not be reserved") + } +}
service/singleton/server.go+1 −1 modified@@ -126,7 +126,7 @@ func (c *ServerClass) UpdateDDNS(server *model.Server, ip *model.IP) error { confServers := strings.Split(Conf.DNSServers, ",") ctx := context.WithValue(context.Background(), ddns.DNSServerKey{}, utils.IfOr(confServers[0] != "", confServers, utils.DNSServers)) - providers, err := DDNSShared.GetDDNSProvidersFromProfiles(server.DDNSProfiles, utils.IfOr(ip != nil, ip, &server.GeoIP.IP)) + providers, err := DDNSShared.GetDDNSProvidersFromProfiles(server.DDNSProfiles, utils.IfOr(ip != nil, ip, &server.GeoIP.IP), server.GetUserID()) if err != nil { return err }
Vulnerability mechanics
Root cause
"Missing reserved-host validation in NAT create/update allows any authenticated member to register the dashboard's own Host as a NAT domain, hijacking global routing."
Attack vector
An authenticated non-admin user who controls a server creates or updates a NAT profile (via `POST /nat` or `PATCH /nat/:id`) setting `Domain` equal to the dashboard's own HTTP Host (e.g., `dashboard.example:8008`). The top-level multiplexer matches incoming requests against NAT domains before dispatching to dashboard/gRPC handlers, so a disabled NAT profile blocks all requests to the dashboard Host (DoS), while an enabled profile tunnels the original HTTP request to the attacker's agent, potentially disclosing headers and request data [ref_id=1] [CWE-862]. No host-ownership or reserved-host validation existed at the NAT create/update endpoints [patch_id=5752442].
Affected code
The NAT management API (`cmd/dashboard/controller/nat.go`) allows any authenticated user to create or update a NAT profile with an arbitrary `Domain` value. The top-level HTTP/gRPC multiplexer (`cmd/dashboard/main.go`) checks `NATShared.GetNATConfigByDomain(r.Host)` before dispatching to the dashboard handlers, so a NAT domain matching the dashboard's own host takes precedence [ref_id=1]. The patch adds an `IsReservedDashboardHost` guard in `service/singleton/nat.go` and rejects reserved domains at create/update time, while also filtering them from the startup cache [patch_id=5752442]. For the DDNS issue, `GetDDNSProvidersFromProfiles` in `service/singleton/ddns.go` lacked owner-UID re-validation; the patch adds a check that skips profiles not owned by the server owner or a real admin [patch_id=5752442].
What the fix does
The patch introduces `IsReservedDashboardHost` in `service/singleton/nat.go`, which compares a given domain against the dashboard's `InstallHost`, `ListenHost`, and a new operator-declared `ReservedHosts` list (for reverse-proxy deployments) after canonicalizing hostnames (lowercasing, stripping trailing dots, normalizing IPv6) [patch_id=5752442]. Both `createNAT` and `updateNAT` in `cmd/dashboard/controller/nat.go` reject requests whose `Domain` is reserved, and `filterReservedNATProfiles` in `NewNATClass` drops pre-planted malicious records from the startup cache so the fix also removes routes hijacked before the upgrade [patch_id=5752442]. For the DDNS ownership bypass (`GHSA-39g2-8x68-pmx8`), `GetDDNSProvidersFromProfiles` now accepts an `ownerUID` parameter and skips profiles whose `UserID` does not match the server owner and who are not a real admin (non-zero ID with admin role), preventing a member from driving updates through another user's DDNS profile or a `UserID==0` migration artifact [patch_id=5752442].
Preconditions
- authAttacker must be an authenticated user (any role, no admin required)
- configAttacker must own or control a server registered in Nezha to associate with the NAT profile
- inputAttacker sends a crafted POST/PATCH request to the NAT management API with Domain set to the dashboard host
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.