VYPR
High severityNVD Advisory· Published Feb 26, 2026· Updated Feb 26, 2026

WireGuard Portal Vulnerable to Privilege Escalation to Admin via User Self-Update

CVE-2026-27899

Description

WireGuard Portal (or wg-portal) is a web-based configuration portal for WireGuard server management. Prior to version 2.1.3, any authenticated non-admin user can become a full administrator by sending a single PUT request to their own user profile endpoint with "IsAdmin": true in the JSON body. After logging out and back in, the session picks up admin privileges from the database. When a user updates their own profile, the server parses the full JSON body into the user model, including the IsAdmin boolean field. A function responsible for preserving calculated or protected attributes pins certain fields to their database values (such as base model data, linked peer count, and authentication data), but it does not do this for IsAdmin. As a result, whatever value the client sends for IsAdmin is written directly to the database. After the exploit, the attacker has full admin access to the WireGuard VPN management portal. The problem was fixed in v2.1.3. The docker images for the tag 'latest' built from the master branch also include the fix.

AI Insight

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

Authenticated non-admin users can escalate to full admin by sending a PUT request with `IsAdmin: true` to their own profile endpoint, granting full control of the WireGuard VPN management portal.

Vulnerability

Overview

CVE-2026-27899 is a privilege escalation vulnerability in WireGuard Portal (wg-portal), a web-based configuration portal for WireGuard server management. Prior to version 2.1.3, any authenticated non-admin user can become a full administrator by sending a single PUT request to their own user profile endpoint with "IsAdmin": true in the JSON body [1]. After logging out and back in, the session picks up admin privileges from the database [1].

Root

Cause and Exploitation

The vulnerability stems from how the server processes user profile updates. When a user updates their own profile, the server parses the full JSON body into the user model, including the IsAdmin including the boolean field [1]. A function responsible for preserving calculated or protected attributes pins certain fields to their database values (such as base model data, linked peer count, and authentication data), but it does not do this for IsAdmin [1]. As a result, whatever value the client sends for IsAdmin is written directly to the database [1]. The fix, introduced in v2.1.3, adds a validation function that prevents non-admin users from setting IsAdmin` to true [3].

Impact

After successful exploitation, the attacker gains full admin privileges are granted to the attacker, providing full administrative access to the WireGuard VPN management portal [1]. This includes the ability to manage users, peers, and configuration, potentially compromising the entire VPN infrastructure.

Mitigation

The issue is fixed in version 2.1.3 [2]. Docker images tagged 'latest' built from the master branch also include the fix [1]. Users should upgrade to v2.1.3 or later immediately.

AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/h44z/wg-portalGo
< 2.1.32.1.3

Affected products

2

Patches

1
fe4485037a25

Merge commit from fork

https://github.com/h44z/wg-portalh44zFeb 24, 2026via ghsa
3 files changed · +79 2
  • internal/app/api/v0/backend/user_service.go+11 0 modified
    @@ -53,6 +53,17 @@ func (u UserService) GetAllUsers(ctx context.Context) ([]domain.User, error) {
     }
     
     func (u UserService) UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error) {
    +	sessionUser := domain.GetUserInfo(ctx)
    +	currentUser, err := u.users.GetUser(ctx, user.Identifier)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	// if this endpoint is used by non-admins, make sure that the user can only modify a specific subset of attributes
    +	if !sessionUser.IsAdmin {
    +		user.CopyAdminAttributes(currentUser, u.cfg.Advanced.ApiAdminOnly)
    +	}
    +
     	return u.users.UpdateUser(ctx, user)
     }
     
    
  • internal/app/users/user_manager.go+43 2 modified
    @@ -352,8 +352,9 @@ func (m Manager) DeactivateApi(ctx context.Context, id domain.UserIdentifier) (*
     func (m Manager) validateModifications(ctx context.Context, old, new *domain.User) error {
     	currentUser := domain.GetUserInfo(ctx)
     
    -	if currentUser.Id != new.Identifier && !currentUser.IsAdmin {
    -		return fmt.Errorf("insufficient permissions")
    +	adminErrors := m.validateAdminModifications(ctx, old, new)
    +	if adminErrors != nil {
    +		return adminErrors
     	}
     
     	if err := old.EditAllowed(new); err != nil && currentUser.Id != domain.SystemAdminContextUserInfo().Id {
    @@ -387,6 +388,42 @@ func (m Manager) validateModifications(ctx context.Context, old, new *domain.Use
     	return nil
     }
     
    +func (m Manager) validateAdminModifications(ctx context.Context, old, new *domain.User) error {
    +	currentUser := domain.GetUserInfo(ctx)
    +
    +	if currentUser.IsAdmin {
    +		if currentUser.Id == old.Identifier && !new.IsAdmin {
    +			return fmt.Errorf("cannot remove own admin rights: %w", domain.ErrInvalidData)
    +		}
    +
    +		return nil // admins can do (almost) everything
    +	}
    +
    +	// non-admins can only modify very their own profile data
    +
    +	if currentUser.Id != new.Identifier {
    +		return fmt.Errorf("insufficient permissions: %w", domain.ErrInvalidData)
    +	}
    +
    +	if new.IsAdmin {
    +		return fmt.Errorf("cannot grant admin rights: %w", domain.ErrInvalidData)
    +	}
    +
    +	if new.Notes != old.Notes {
    +		return fmt.Errorf("cannot update notes: %w", domain.ErrInvalidData)
    +	}
    +
    +	if old.Locked != new.Locked || old.LockedReason != new.LockedReason {
    +		return fmt.Errorf("cannot change lock state: %w", domain.ErrInvalidData)
    +	}
    +
    +	if old.Disabled != new.Disabled || old.DisabledReason != new.DisabledReason {
    +		return fmt.Errorf("cannot change disabled state: %w", domain.ErrInvalidData)
    +	}
    +
    +	return nil
    +}
    +
     func (m Manager) validateCreation(ctx context.Context, new *domain.User) error {
     	currentUser := domain.GetUserInfo(ctx)
     
    @@ -453,6 +490,10 @@ func (m Manager) validateDeletion(ctx context.Context, del *domain.User) error {
     func (m Manager) validateApiChange(ctx context.Context, user *domain.User) error {
     	currentUser := domain.GetUserInfo(ctx)
     
    +	if !currentUser.IsAdmin && m.cfg.Advanced.ApiAdminOnly {
    +		return fmt.Errorf("insufficient permissions to change API access: %w", domain.ErrNoPermission)
    +	}
    +
     	if currentUser.Id != user.Identifier {
     		return fmt.Errorf("cannot change API access of user: %w", domain.ErrNoPermission)
     	}
    
  • internal/domain/user.go+25 0 modified
    @@ -185,6 +185,31 @@ func (u *User) CopyCalculatedAttributes(src *User) {
     	u.LinkedPeerCount = src.LinkedPeerCount
     }
     
    +// CopyAdminAttributes copies all attributes from the given user except password, passkey and
    +// api-token if apiAdminOnly is false.
    +func (u *User) CopyAdminAttributes(src *User, apiAdminOnly bool) {
    +	u.BaseModel = src.BaseModel
    +	u.Identifier = src.Identifier
    +	u.Email = src.Email
    +	u.Source = src.Source
    +	u.ProviderName = src.ProviderName
    +	u.IsAdmin = src.IsAdmin
    +	u.Firstname = src.Firstname
    +	u.Lastname = src.Lastname
    +	u.Phone = src.Phone
    +	u.Department = src.Department
    +	u.Notes = src.Notes
    +	u.Disabled = src.Disabled
    +	u.DisabledReason = src.DisabledReason
    +	u.Locked = src.Locked
    +	u.LockedReason = src.LockedReason
    +	u.LinkedPeerCount = src.LinkedPeerCount
    +	if apiAdminOnly {
    +		u.ApiToken = src.ApiToken
    +		u.ApiTokenCreated = src.ApiTokenCreated
    +	}
    +}
    +
     // DisplayName returns the display name of the user.
     // The display name is the first and last name, or the email address of the user.
     // If none of these fields are set, the user identifier is returned.
    

Vulnerability mechanics

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

References

6

News mentions

0

No linked articles in our index yet.