Unverified Password Change

Description

Unverified Password Change occurs when an application allows users to change their password without verifying they are the legitimate account owner. This typically means accepting a new password without requiring the current password, relying solely on session authentication. While sessions can be hijacked or left logged in on shared devices, requiring the current password ensures only someone who knows it can make changes.

Risk

Attackers who hijack sessions (XSS, session fixation, network interception) can permanently take over accounts by changing the password. Users on shared computers who don't log out leave accounts vulnerable. CSRF attacks can change passwords without user knowledge. Social engineering attacks are easier without password verification. Account recovery becomes permanent compromise.

Solution

Always require the current password before allowing password changes. Implement rate limiting on password change attempts. Send notifications when passwords are changed. Consider requiring re-authentication for sensitive operations. Implement CSRF protection on password change forms. Log password change events for audit trails.

Common Consequences

ImpactDetails
AuthenticationScope: Account Takeover

Attackers can lock out legitimate users.
AccountabilityScope: Non-repudiation

Account actions may not be attributable.
AvailabilityScope: Account Lockout

Users lose access to their accounts.

Example Code + Solution Code

Vulnerable Code

// VULNERABLE: No current password verification
@PostMapping("/api/change-password")
public ResponseEntity<?> changePassword(
        @RequestBody PasswordChangeRequest request,
        @AuthenticationPrincipal User user) {

    // Only checks if user is logged in (session valid)
    // Doesn't verify they know current password!
    String newPassword = request.getNewPassword();

    // Attacker with stolen session can change password
    userService.updatePassword(user.getId(), newPassword);

    return ResponseEntity.ok().build();
}

// VULNERABLE: CSRF vulnerable password change
@WebServlet("/change-password")
public class VulnerablePasswordServlet extends HttpServlet {

    protected void doPost(HttpServletRequest request,
                          HttpServletResponse response)
            throws ServletException, IOException {

        HttpSession session = request.getSession();
        User user = (User) session.getAttribute("user");

        if (user != null) {
            String newPassword = request.getParameter("newPassword");
            // No CSRF token check
            // No current password check
            userService.updatePassword(user.getId(), newPassword);
        }
    }
}
# VULNERABLE: Flask password change without verification
from flask import Flask, request, session

app = Flask(__name__)

@app.route('/change-password', methods=['POST'])
def change_password_vulnerable():
    if 'user_id' not in session:
        return 'Unauthorized', 401

    # Trusts session alone - no password verification
    new_password = request.form['new_password']

    user = User.query.get(session['user_id'])
    user.set_password(new_password)
    db.session.commit()

    return 'Password changed'

# VULNERABLE: API without password verification
@app.route('/api/user/password', methods=['PUT'])
@jwt_required()
def update_password_vulnerable():
    user_id = get_jwt_identity()
    data = request.json

    # JWT valid but no password verification
    user = User.query.get(user_id)
    user.set_password(data['new_password'])
    db.session.commit()

    return jsonify({'status': 'success'})
// VULNERABLE: Express.js password change
app.post('/change-password', requireAuth, (req, res) => {
    const userId = req.session.userId;
    const { newPassword } = req.body;

    // Only session check, no password verification
    User.findByIdAndUpdate(userId, {
        password: hashPassword(newPassword)
    }).then(() => {
        res.json({ success: true });
    });
});

// VULNERABLE: Client-side password change
async function changePassword(newPassword) {
    // No current password sent
    const response = await fetch('/api/password', {
        method: 'PUT',
        headers: {
            'Authorization': `Bearer ${getToken()}`,
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ newPassword })
    });

    return response.json();
}

Fixed Code

// SAFE: Require current password for change
@PostMapping("/api/change-password")
public ResponseEntity<?> changePassword(
        @RequestBody PasswordChangeRequest request,
        @AuthenticationPrincipal User user) {

    // Verify current password
    if (!passwordEncoder.matches(request.getCurrentPassword(),
                                  user.getPassword())) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body("Current password is incorrect");
    }

    // Validate new password
    ValidationResult validation = passwordValidator.validate(
        request.getNewPassword()
    );
    if (!validation.isValid()) {
        return ResponseEntity.badRequest()
            .body(validation.getErrors());
    }

    // Update password
    userService.updatePassword(user.getId(), request.getNewPassword());

    // Send notification
    notificationService.sendPasswordChangeNotification(user);

    // Invalidate other sessions
    sessionService.invalidateOtherSessions(user.getId());

    return ResponseEntity.ok().build();
}

// SAFE: With CSRF protection
@WebServlet("/change-password")
public class SafePasswordServlet extends HttpServlet {

    @Inject
    private CsrfTokenService csrfService;

    protected void doPost(HttpServletRequest request,
                          HttpServletResponse response)
            throws ServletException, IOException {

        // Verify CSRF token
        if (!csrfService.validateToken(request)) {
            response.sendError(403, "Invalid CSRF token");
            return;
        }

        HttpSession session = request.getSession();
        User user = (User) session.getAttribute("user");

        if (user == null) {
            response.sendError(401);
            return;
        }

        String currentPassword = request.getParameter("currentPassword");
        String newPassword = request.getParameter("newPassword");

        // Verify current password
        if (!authService.verifyPassword(user, currentPassword)) {
            response.sendError(403, "Current password incorrect");
            return;
        }

        // Change password
        userService.updatePassword(user.getId(), newPassword);

        // Audit log
        auditService.log("PASSWORD_CHANGE", user.getId());
    }
}
# SAFE: Flask with password verification
from flask import Flask, request, session, jsonify
from flask_wtf.csrf import CSRFProtect

app = Flask(__name__)
csrf = CSRFProtect(app)

@app.route('/change-password', methods=['POST'])
def change_password_safe():
    if 'user_id' not in session:
        return jsonify({'error': 'Unauthorized'}), 401

    current_password = request.form['current_password']
    new_password = request.form['new_password']

    user = User.query.get(session['user_id'])

    # Verify current password
    if not user.check_password(current_password):
        return jsonify({'error': 'Current password incorrect'}), 403

    # Validate new password
    if not is_strong_password(new_password):
        return jsonify({'error': 'Password too weak'}), 400

    # Update password
    user.set_password(new_password)
    db.session.commit()

    # Send notification email
    send_password_change_notification(user.email)

    # Invalidate other sessions
    invalidate_other_sessions(user.id)

    return jsonify({'status': 'success'})

# SAFE: API with re-authentication
@app.route('/api/user/password', methods=['PUT'])
@jwt_required()
def update_password_safe():
    user_id = get_jwt_identity()
    data = request.json

    if 'current_password' not in data:
        return jsonify({'error': 'Current password required'}), 400

    user = User.query.get(user_id)

    # Verify current password
    if not user.check_password(data['current_password']):
        # Rate limit failed attempts
        record_failed_attempt(user_id)
        return jsonify({'error': 'Invalid password'}), 403

    # Check rate limiting
    if is_rate_limited(user_id):
        return jsonify({'error': 'Too many attempts'}), 429

    # Update password
    user.set_password(data['new_password'])
    db.session.commit()

    # Audit log
    log_security_event('password_changed', user_id)

    return jsonify({'status': 'success'})
// SAFE: Express.js with password verification
const rateLimit = require('express-rate-limit');

const passwordChangeLimit = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5
});

app.post('/change-password',
         requireAuth,
         csrf.protect,
         passwordChangeLimit,
         async (req, res) => {

    const userId = req.session.userId;
    const { currentPassword, newPassword } = req.body;

    // Require current password
    if (!currentPassword) {
        return res.status(400).json({
            error: 'Current password required'
        });
    }

    const user = await User.findById(userId);

    // Verify current password
    const isValid = await bcrypt.compare(currentPassword, user.password);
    if (!isValid) {
        return res.status(403).json({
            error: 'Current password incorrect'
        });
    }

    // Validate new password
    const validation = validatePassword(newPassword);
    if (!validation.valid) {
        return res.status(400).json({
            error: validation.message
        });
    }

    // Update password
    user.password = await bcrypt.hash(newPassword, 10);
    await user.save();

    // Send notification
    await sendEmail(user.email, 'Password Changed',
                   'Your password has been changed.');

    // Log event
    await AuditLog.create({
        userId,
        action: 'password_change',
        ip: req.ip
    });

    res.json({ success: true });
});

// SAFE: Client-side form
<form action="/change-password" method="POST">
    <input type="hidden" name="_csrf" value="{{csrfToken}}">

    <label>Current Password:
        <input type="password" name="currentPassword" required>
    </label>

    <label>New Password:
        <input type="password" name="newPassword" required
               minlength="8" pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}">
    </label>

    <label>Confirm New Password:
        <input type="password" name="confirmPassword" required>
    </label>

    <button type="submit">Change Password</button>
</form>

Exploited in the Wild

Session Hijacking to Account Takeover

XSS attacks stealing sessions led to permanent account compromise via password change.

CSRF Password Changes

Malicious sites changed passwords of logged-in users visiting them.

Shared Computer Exploitation

Accounts on library computers were taken over via password change.


Tools to test/exploit

  • Burp Suite — test password change flows.

  • CSRF testing tools.

  • Session hijacking demonstrations.


CVE Examples

  • CVEs from password change without verification.

  • CSRF attacks on password change endpoints.


References

  1. MITRE. "CWE-620: Unverified Password Change." https://cwe.mitre.org/data/definitions/620.html

  2. OWASP. "Forgot Password Cheat Sheet." https://cheatsheetseries.owasp.org/