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
| Impact | Details |
|---|---|
| Authentication | Scope: Account Takeover Attackers can lock out legitimate users. |
| Accountability | Scope: Non-repudiation Account actions may not be attributable. |
| Availability | Scope: 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
-
MITRE. "CWE-620: Unverified Password Change." https://cwe.mitre.org/data/definitions/620.html
-
OWASP. "Forgot Password Cheat Sheet." https://cheatsheetseries.owasp.org/