agentggagentgg
Back to all findings
CRITICALconfirmedweak-password-resetweak-password-resetaff0ca3942f3

Password reset gated solely on security-question answer

The /resetPassword endpoint allows any anonymous caller to set a new password for any account given only the target email and a matching security answer, with no emailed token, no rate limit, and no proof of email control.

Fileroutes/resetPassword.ts
Lines1652
Confidence
98%
File statusvalidated
Details

The handler takes email, answer, new, and repeat directly from req.body. It looks up the SecurityAnswer record joined to the user identified by the request-supplied email, then compares security.hmac(answer) to the stored data.answer. On match it immediately calls user.update({ password: newPassword }) and returns the updated user — no out-of-band token round-trip is performed, the reset is not gated on any session/authentication, and no token is sent to the user's verified email.

Key defects against the safe pattern:

  1. Knowledge-factor-only reset (criterion 1). A security question (researchable on social media: pet name, eldest sibling, favorite movie, etc.) is the sole reset factor. This is the canonical anti-pattern called out in the brief.
  2. Identity inferred from request body (criterion 2). The target account is selected by body.email; there is no proof that the requester controls that mailbox.
  3. No rate limiting / lockout (criterion 4). Nothing in the handler (or visible middleware) throttles repeated attempts, so an attacker can brute-force the answer to a security question with a small answer space (e.g. city of birth, color, pet name).
  4. No token, so TTL / single-use / constant-time compare are all absent (criterion 3). The only "token-like" check is the HMAC of the supplied answer against the stored HMAC, which is a === string compare on a low-entropy secret.

Relevant lines:

const email = body.email
const answer = body.answer
const newPassword = body.new
...
const data = await SecurityAnswerModel.findOne({
  include: [{ model: UserModel, where: { email } }]
})
if ((data != null) && security.hmac(answer) === data.answer) {
  const user = await UserModel.findByPk(data.UserId)
  if (user) {
    const updatedUser = await user.update({ password: newPassword })

No resetToken table, no expiresAt, no sendMail(user.email, ...) call, no session check — this is exactly the Juice-Shop-style true-positive the brief calls out.

Proof of concept
  1. Pick a target account, e.g. jim@juice-sh.op.
  2. Research / guess the answer to their security question (for Jim: "Samuel", as encoded in verifySecurityAnswerChallenges).
  3. Send:
POST /rest/user/reset-password HTTP/1.1
Content-Type: application/json

{"email":"jim@juice-sh.op","answer":"Samuel","new":"pwned123","repeat":"pwned123"}
  1. Server responds 200 with the updated user; attacker logs in as Jim with pwned123. No email is sent to Jim, no token round-trip occurred, and there is no per-IP/per-email rate limit to prevent dictionary-attacking the answer for accounts whose answers aren't already known.
Impact

Unauthenticated, fully remote account takeover of any user whose security-question answer can be guessed or researched on social media. The attacker needs only the victim's email address (often public) plus a single low-entropy knowledge factor. Because there is no rate limit, the answer can also be brute-forced. Blast radius: every user account in the system, including high-privilege users (admin, etc.) that have a security answer on file.

Validation
confirmed

The resetPassword handler resets any user's password based solely on body.email + body.answer, comparing security.hmac(answer) === data.answer and immediately calling user.update({ password: newPassword }). There is no emailed token, no session check, no rate limiting, and no proof-of-control of the email. The hard-coded answers in verifySecurityAnswerChallenges (e.g. Jim → 'Samuel') confirm the PoC is directly exploitable, and scope rules require treating this as a production bug.

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Base score: 9.8 · CRITICAL

The /resetPassword handler reads email, answer, and new directly from req.body with no session check, no emailed token, and no middleware gate — so the endpoint is reachable remotely over HTTP by an anonymous attacker (AV:N, PR:N, UI:N). Attack Complexity is Low because there are no race conditions or special configuration requirements: the handler has no rate limiting/lockout, so the low-entropy security answer can be researched via OSINT or brute-forced via repeated POSTs against SecurityAnswerModel.findOne + security.hmac(answer) === data.answer. A successful call results in user.update({ password: newPassword }) for any chosen account including admins, yielding full account takeover — total loss of confidentiality, integrity, and availability of the victim's account and the data they can reach (C:H/I:H/A:H). Scope is Unchanged because the takeover stays within the application's own auth authority.

References