GHSA-4pvg-prr3-9cxr Nginx-UI is Vulnerable to Unauthenticated Remote Code Execution via Backup Restore
**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 Summarynginx-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 Analysis2.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 BypassThe 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 BarrierThe `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 BackupThe 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 UnconditionallyWhen `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 CommandAfter 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 ConceptStep 1 — Confirm the installation window is openExpected response confirming vulnerability:Step 2 — Craft the malicious backupThe backup format (derived from `internal/backup/backup.go`) is:**2a.** Generate a random 32-byte AES key and 16-byte IV.**2b.** Create the malicious `app.ini` to place inside `nginx-ui.zip`:**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`:**2g.** Compute the HMAC-SHA256 signature of `manifest.json` using the signing key derived as:**2h.** Assemble the outer ZIP containing `manifest.json`, `manifest.sig`, `nginx-ui.zip`, `nginx.zip`.Step 3 — Upload the malicious backup (no authentication required)Expected response (HTTP 200):nginx-ui calls `risefront.Restart()` 2 seconds later, loading the attacker's `app.ini`.Step 4 — Trigger RCE using the restored node secretAfter the restart (wait ~3 seconds):nginx-ui executes: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 VersionsAll 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: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)**File:** api/backup/router.go (L9-16)**File:** api/backup/router.go (L18-25)**File:** api/system/install.go (L27-34)**File:** internal/middleware/encrypted_params.go (L69-75)**File:** api/backup/restore.go (L35-70)**File:** api/backup/restore.go (L126-132)**File:** internal/backup/manifest.go (L156-163)**File:** internal/backup/restore.go (L458-484)**File:** internal/nginx/nginx.go (L25-36)**File:** internal/nginx/exec.go (L12-28)
CVSS v4.0 score: 9
CVSS v3.1 score: 9.8