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

ImpactDetails
AvailabilityScope: User Lockout

Legitimate users denied access.
BusinessScope: Disruption

Critical operations halted.
SecurityScope: 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

  1. MITRE. "CWE-645: Overly Restrictive Account Lockout Mechanism." https://cwe.mitre.org/data/definitions/645.html

  2. OWASP. "Authentication Cheat Sheet."