Overly Restrictive Account Lockout Mechanism
Description
Overly Restrictive Account Lockout Mechanism occurs when an application's account lockout policy is too aggressive, allowing attackers to deliberately lock out legitimate users. By repeatedly entering incorrect passwords for targeted accounts, attackers can cause denial of service against specific users. This is particularly severe when combined with user enumeration vulnerabilities.
Risk
Attackers can lock out any user account by repeated failed login attempts. Targeted denial of service against administrators or critical users. Combined with user enumeration, enables systematic lockout of all users. Business disruption when key personnel cannot access systems. Support costs increase due to lockout-related tickets. Can be used to cover other attacks by locking out security personnel.
Solution
Implement progressive delays (exponential backoff) instead of hard lockouts. Use CAPTCHA after failed attempts. Lock by IP address instead of or in addition to username. Require secondary verification for unlock rather than time-based unlock. Implement notification for repeated failed attempts. Use multi-factor authentication to reduce password guessing value.
Common Consequences
| Impact | Details |
|---|---|
| Availability | Scope: User Lockout Legitimate users denied access. |
| Business | Scope: Disruption Critical operations halted. |
| Security | Scope: Cover Attack Lockouts mask other malicious activity. |
Example Code + Solution Code
Vulnerable Code
// VULNERABLE: Hard lockout after failed attempts
public class VulnerableLockoutAuth {
private static final int MAX_ATTEMPTS = 3;
private Map<String, Integer> failedAttempts = new ConcurrentHashMap<>();
private Set<String> lockedAccounts = ConcurrentHashMap.newKeySet();
public AuthResult authenticate(String username, String password) {
// Check if locked
if (lockedAccounts.contains(username)) {
return AuthResult.accountLocked();
}
User user = userRepository.findByUsername(username);
if (user == null || !verifyPassword(password, user.getPasswordHash())) {
int attempts = failedAttempts.merge(username, 1, Integer::sum);
if (attempts >= MAX_ATTEMPTS) {
// VULNERABLE: Hard lockout - any attacker can lock any account
lockedAccounts.add(username);
log.info("Account locked: " + username);
}
return AuthResult.invalidCredentials();
}
failedAttempts.remove(username);
return AuthResult.success(user);
}
// Only admin can unlock - creates support burden
public void unlockAccount(String username) {
lockedAccounts.remove(username);
failedAttempts.remove(username);
}
}
// VULNERABLE: Time-based lockout still allows DoS
public class VulnerableTimeLockout {
private static final int LOCKOUT_DURATION_MS = 30 * 60 * 1000; // 30 minutes
private Map<String, Long> lockoutEnd = new ConcurrentHashMap<>();
public AuthResult authenticate(String username, String password) {
Long lockoutEndTime = lockoutEnd.get(username);
if (lockoutEndTime != null && System.currentTimeMillis() < lockoutEndTime) {
// VULNERABLE: Attacker keeps failing to extend lockout
return AuthResult.accountLocked(getRemainingTime(lockoutEndTime));
}
// ... authentication logic
if (failed) {
if (getFailedAttempts(username) >= 3) {
// VULNERABLE: Attacker triggers lockout repeatedly
lockoutEnd.put(username, System.currentTimeMillis() + LOCKOUT_DURATION_MS);
}
}
}
}
# VULNERABLE: Simple lockout mechanism
from datetime import datetime, timedelta
class VulnerableLockout:
def __init__(self):
self.failed_attempts = {}
self.locked_until = {}
self.MAX_ATTEMPTS = 5
self.LOCKOUT_MINUTES = 30
def authenticate(self, username, password):
# Check lockout
if username in self.locked_until:
if datetime.now() < self.locked_until[username]:
return {'error': 'Account locked'}
else:
del self.locked_until[username]
self.failed_attempts[username] = 0
user = get_user(username)
if not user or not verify_password(password, user.password_hash):
# Increment failed attempts
self.failed_attempts[username] = self.failed_attempts.get(username, 0) + 1
if self.failed_attempts[username] >= self.MAX_ATTEMPTS:
# VULNERABLE: Any attacker can lock any account
self.locked_until[username] = datetime.now() + timedelta(minutes=self.LOCKOUT_MINUTES)
return {'error': 'Account locked due to too many attempts'}
return {'error': 'Invalid credentials'}
self.failed_attempts[username] = 0
return {'success': True, 'user': user}
# VULNERABLE: Global lockout without IP tracking
class GlobalVulnerableLockout:
def __init__(self):
self.lockout_count = {}
def check_lockout(self, username):
# Only checks username, not IP
# Attacker from any IP can lock the account
count = self.lockout_count.get(username, 0)
if count >= 3:
return True
return False
def record_failure(self, username):
# VULNERABLE: No IP tracking
self.lockout_count[username] = self.lockout_count.get(username, 0) + 1
// VULNERABLE: Express.js hard lockout
const lockedAccounts = new Map();
const failedAttempts = new Map();
const MAX_ATTEMPTS = 3;
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Check if locked
if (lockedAccounts.has(username)) {
return res.status(403).json({ error: 'Account locked' });
}
const user = await db.findUser(username);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
const attempts = (failedAttempts.get(username) || 0) + 1;
failedAttempts.set(username, attempts);
if (attempts >= MAX_ATTEMPTS) {
// VULNERABLE: Hard lockout
lockedAccounts.set(username, true);
return res.status(403).json({ error: 'Account locked' });
}
return res.status(401).json({ error: 'Invalid credentials' });
}
failedAttempts.delete(username);
// ... login success
});
// VULNERABLE: Permanent lockout until manual reset
app.post('/admin/unlock', requireAdmin, (req, res) => {
const { username } = req.body;
lockedAccounts.delete(username);
failedAttempts.delete(username);
res.json({ success: true });
// Every lockout requires admin intervention!
});
<?php
// VULNERABLE: Simple lockout
class VulnerableLockout {
private $maxAttempts = 3;
private $lockoutDuration = 1800; // 30 minutes
public function authenticate($username, $password) {
// Check if locked
$lockout = $this->getLockout($username);
if ($lockout && time() < $lockout['until']) {
return ['error' => 'Account locked'];
}
$user = $this->getUser($username);
if (!$user || !password_verify($password, $user['password_hash'])) {
$attempts = $this->recordFailure($username);
if ($attempts >= $this->maxAttempts) {
// VULNERABLE: Attacker locks any account
$this->lockAccount($username);
return ['error' => 'Account locked'];
}
return ['error' => 'Invalid credentials'];
}
$this->clearFailures($username);
return ['success' => true];
}
private function lockAccount($username) {
// VULNERABLE: No IP tracking, no self-unlock
$this->db->query(
"INSERT INTO lockouts (username, until) VALUES (?, ?)",
[$username, time() + $this->lockoutDuration]
);
}
}
?>
Fixed Code
// SAFE: Multi-factor lockout with progressive delays
public class SafeLockoutAuth {
private final Cache<String, AttemptRecord> attemptCache;
private final Cache<String, AttemptRecord> ipAttemptCache;
public SafeLockoutAuth() {
// Short-lived cache for attempt tracking
this.attemptCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
this.ipAttemptCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
}
public AuthResult authenticate(String username, String password, String ipAddress) {
AttemptRecord userRecord = attemptCache.getIfPresent(username);
AttemptRecord ipRecord = ipAttemptCache.getIfPresent(ipAddress);
// Check IP-based rate limit first
if (ipRecord != null && ipRecord.isRateLimited()) {
return AuthResult.rateLimited(ipRecord.getRetryAfter());
}
// Progressive delay for user account
if (userRecord != null && userRecord.isDelayed()) {
// Return delay time but don't lock
return AuthResult.pleaseWait(userRecord.getRequiredDelay());
}
User user = userRepository.findByUsername(username);
if (user == null || !verifyPassword(password, user.getPasswordHash())) {
recordFailure(username, ipAddress);
return AuthResult.invalidCredentials();
}
clearFailures(username, ipAddress);
return AuthResult.success(user);
}
private void recordFailure(String username, String ipAddress) {
// Record by username with progressive delay
AttemptRecord userRecord = attemptCache.getIfPresent(username);
if (userRecord == null) {
userRecord = new AttemptRecord();
}
userRecord.increment();
// Exponential backoff: 1s, 2s, 4s, 8s, 16s...
userRecord.setDelay((long) Math.pow(2, Math.min(userRecord.getAttempts() - 1, 6)) * 1000);
attemptCache.put(username, userRecord);
// Record by IP with harder limits
AttemptRecord ipRecord = ipAttemptCache.getIfPresent(ipAddress);
if (ipRecord == null) {
ipRecord = new AttemptRecord();
}
ipRecord.increment();
// IP gets rate limited after many attempts
if (ipRecord.getAttempts() >= 20) {
ipRecord.setRateLimited(true);
ipRecord.setRetryAfter(System.currentTimeMillis() + 300000); // 5 minutes
}
ipAttemptCache.put(ipAddress, ipRecord);
}
}
// SAFE: CAPTCHA-based protection
public class CaptchaProtectedAuth {
public AuthResult authenticate(String username, String password,
String captchaResponse, String ipAddress) {
int attempts = getAttemptCount(username, ipAddress);
// Require CAPTCHA after 3 attempts
if (attempts >= 3) {
if (!verifyCaptcha(captchaResponse)) {
return AuthResult.captchaRequired();
}
}
// Normal auth flow...
}
}
# SAFE: Progressive delay with IP tracking
from datetime import datetime, timedelta
from functools import lru_cache
import redis
class SafeLockout:
def __init__(self, redis_client):
self.redis = redis_client
def get_delay_seconds(self, attempts):
"""Exponential backoff: 1, 2, 4, 8, 16, 32, 60 seconds max"""
if attempts < 1:
return 0
return min(2 ** (attempts - 1), 60)
def authenticate(self, username, password, ip_address):
# Check IP rate limit
ip_key = f"auth:ip:{ip_address}"
ip_attempts = int(self.redis.get(ip_key) or 0)
if ip_attempts >= 50: # High threshold for IP
return {'error': 'Too many requests from this IP', 'retry_after': 300}
# Get user attempts
user_key = f"auth:user:{username}"
user_attempts = int(self.redis.get(user_key) or 0)
# Check if delay required (not lockout!)
delay = self.get_delay_seconds(user_attempts)
last_attempt_key = f"auth:last:{username}"
last_attempt = self.redis.get(last_attempt_key)
if last_attempt:
elapsed = (datetime.now() - datetime.fromisoformat(last_attempt.decode())).total_seconds()
if elapsed < delay:
remaining = delay - elapsed
return {'error': 'Please wait', 'retry_after': remaining}
# Authenticate
user = get_user(username)
if not user or not verify_password(password, user.password_hash):
# Record failure
self.redis.incr(user_key)
self.redis.expire(user_key, 3600) # 1 hour expiry
self.redis.set(last_attempt_key, datetime.now().isoformat(), ex=3600)
self.redis.incr(ip_key)
self.redis.expire(ip_key, 3600)
return {'error': 'Invalid credentials'}
# Success - clear user attempts (not IP)
self.redis.delete(user_key)
self.redis.delete(last_attempt_key)
return {'success': True, 'user': user}
# SAFE: With CAPTCHA support
class CaptchaProtectedAuth:
def __init__(self, redis_client, captcha_service):
self.redis = redis_client
self.captcha_service = captcha_service
def authenticate(self, username, password, ip_address, captcha_token=None):
user_key = f"auth:attempts:{username}"
attempts = int(self.redis.get(user_key) or 0)
# Require CAPTCHA after 3 attempts
if attempts >= 3:
if not captcha_token:
return {'error': 'Captcha required', 'require_captcha': True}
if not self.captcha_service.verify(captcha_token):
return {'error': 'Invalid captcha', 'require_captcha': True}
# Normal authentication...
// SAFE: Express with progressive delays and IP tracking
const rateLimit = require('express-rate-limit');
const redis = require('redis');
const client = redis.createClient();
// IP-based rate limiting
const loginLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 100, // 100 requests per IP per hour
message: { error: 'Too many requests from this IP' }
});
function getDelaySeconds(attempts) {
if (attempts < 1) return 0;
return Math.min(Math.pow(2, attempts - 1), 60);
}
app.post('/login', loginLimiter, async (req, res) => {
const { username, password, captchaToken } = req.body;
const ip = req.ip;
// Get attempt count for user
const attempts = parseInt(await client.get(`auth:${username}`) || 0);
// Check progressive delay
const delay = getDelaySeconds(attempts);
const lastAttempt = await client.get(`auth:last:${username}`);
if (lastAttempt) {
const elapsed = (Date.now() - parseInt(lastAttempt)) / 1000;
if (elapsed < delay) {
return res.status(429).json({
error: 'Please wait',
retryAfter: Math.ceil(delay - elapsed)
});
}
}
// Require CAPTCHA after 3 attempts
if (attempts >= 3) {
if (!captchaToken || !await verifyCaptcha(captchaToken)) {
return res.status(400).json({
error: 'Captcha required',
requireCaptcha: true
});
}
}
// Authenticate
const user = await db.findUser(username);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
// Record failure with TTL
await client.incr(`auth:${username}`);
await client.expire(`auth:${username}`, 3600);
await client.set(`auth:last:${username}`, Date.now(), 'EX', 3600);
const newAttempts = attempts + 1;
return res.status(401).json({
error: 'Invalid credentials',
requireCaptcha: newAttempts >= 3,
retryAfter: getDelaySeconds(newAttempts)
});
}
// Clear on success
await client.del(`auth:${username}`);
await client.del(`auth:last:${username}`);
// ... login success
});
Exploited in the Wild
Account Lockout Attacks
Targeted lockout of admin accounts during attacks.
Mass Lockout DoS
Scripted lockout of entire user bases.
Competitive Attacks
Locking out competitor's business accounts.
Tools to test/exploit
-
Hydra/Medusa for automated login attempts.
-
Custom scripts for targeted lockouts.
-
Load testing tools.
CVE Examples
-
Account lockout DoS vulnerabilities in various applications.
-
Business impact from excessive lockout policies.
References
-
MITRE. "CWE-645: Overly Restrictive Account Lockout Mechanism." https://cwe.mitre.org/data/definitions/645.html
-
OWASP. "Authentication Cheat Sheet."