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.
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:
- 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.
- 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. - 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).
- 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.
- Pick a target account, e.g.
jim@juice-sh.op. - Research / guess the answer to their security question (for Jim: "Samuel", as encoded in
verifySecurityAnswerChallenges). - Send:
POST /rest/user/reset-password HTTP/1.1
Content-Type: application/json
{"email":"jim@juice-sh.op","answer":"Samuel","new":"pwned123","repeat":"pwned123"}
- 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.
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.
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.
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.
- CWE-640: Weak Password Recovery Mechanism for Forgotten Password
- CWE-287: Improper Authentication
- OWASP ASVS V2.5 Credential Recovery
- OWASP Top 10 A07:2021 Identification and Authentication Failures