VYPR
Medium severity6.4NVD Advisory· Published Jun 12, 2026

CVE-2026-53521

CVE-2026-53521

Description

Nezha Monitoring before 2.1.0 allows storing future DDNS profile IDs, enabling unauthorized use of another user's DDNS profile when that ID is later created.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Nezha Monitoring before 2.1.0 allows storing future DDNS profile IDs, enabling unauthorized use of another user's DDNS profile when that ID is later created.

Vulnerability

In Nezha Monitoring versions 2.0.14 through 2.0.x before 2.1.0, the PATCH /server/{id} endpoint accepts and persists nonexistent ddns_profiles IDs for a member-owned server. The server update path does not validate that the submitted ID corresponds to an existing DDNS profile owned by the same user or accessible to them. This allows an attacker to store a placeholder ID that later becomes a live cross-user reference when another user creates a DDNS profile with that ID [1].

Exploitation

An attacker must be a normal member who owns a server. They can send a PATCH request to /server/{id} with a nonexistent ddns_profiles ID (e.g., a future integer ID). If another user later creates a DDNS profile with that same ID, the DDNS worker resolves the stored ID and dispatches an update using the victim's DDNS profile configuration in the context of the attacker's server. Direct binding to an existing foreign DDNS profile is correctly denied, but this second-order bypass stores the future ID first [1].

Impact

On successful exploitation, the attacker's server receives DDNS updates that combine the victim's DDNS profile ID, provider type, domains, access ID, access secret, and retry policy with the attacker's server context (server ID, owner, IPv4 address, override DDNS domains). This can result in unauthorized DDNS update attempts using another user's DDNS profile context. The attacker does not need permission to bind the victim profile after it exists. Credentials remain server-side but are used in the worker path; downstream DNS impact depends on the victim's provider configuration [1].

Mitigation

The vulnerability is patched in Nezha Monitoring version 2.1.0. Users should upgrade to 2.1.0 or later. No workaround is documented in the available references [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

1

Patches

1
f18232eafab7

fix(security): reserve dashboard hosts from NAT and re-check DDNS profile ownership at worker time

https://github.com/nezhahq/nezhanaibaMay 31, 2026Fixed in 2.1.0via llm-release-walk
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

"The server update endpoint accepts and persists nonexistent ddns_profiles IDs, and the DDNS worker resolves stored IDs without revalidating that the resolved profile belongs to the server owner."

Attack vector

An authenticated member predicts or pre-binds future DDNS profile IDs by submitting a `PATCH /server/{id}` request with a `ddns_profiles` list containing IDs that do not yet exist. The server persists these unresolved IDs. When another user later creates a DDNS profile whose auto-increment ID matches one of the pre-bound IDs, the DDNS worker resolves the stored ID and dispatches an update using the victim's profile configuration (provider type, access secret, domains) in the context of the attacker's server [CWE-863]. The attacker does not need permission to bind the victim profile after it exists.

Affected code

The vulnerability resides in `PATCH /server/{id}` (`cmd/dashboard/controller/server.go`) and the DDNS worker path (`service/singleton/ddns.go`, `service/singleton/server.go`). The server update endpoint accepts and persists nonexistent `ddns_profiles` IDs for a member-owned server. Later, the DDNS worker (`GetDDNSProvidersFromProfiles`) resolves those stored IDs and dispatches provider updates without revalidating that the resolved profile belongs to the server owner.

What the fix does

The patch adds worker-time ownership revalidation in `GetDDNSProvidersFromProfiles`: it now accepts an `ownerUID` parameter and skips any DDNS profile whose `UserID` does not match either the server owner or a real non-zero admin user. A new helper `profileOwnedByRealAdmin` prevents the `UserID==0` migration artifact from being treated as an admin grant. The caller `UpdateDDNS` passes `server.GetUserID()` so foreign-owned profiles are never dispatched.

Preconditions

  • authAttacker must be an authenticated member who owns at least one server.
  • inputAttacker must submit a PATCH /server/{id} request with a ddns_profiles list containing IDs that do not yet exist in the database.
  • inputAnother user must later create a DDNS profile whose auto-increment primary key ID matches one of the pre-bound IDs.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.