Username Enumeration via Account Lock — PortSwigger Lab
Enumeración de usuarios forzando el bloqueo de cuentas con 5 intentos fallidos: la respuesta de bloqueo solo aparece para cuentas existentes.
Username Enumeration via Account Lock
1. Target Overview & Vulnerability Summary
| Campo | Valor |
|---|---|
| Severidad | Media |
| Endpoint vulnerable | POST /login |
| Root cause | La política de bloqueo de cuenta (account lockout) se dispara solo para usuarios existentes, revelando qué cuentas son válidas mediante la respuesta de bloqueo |
La app bloquea temporalmente una cuenta tras 5 intentos de login fallidos. El mensaje de bloqueo solo se emite para cuentas que existen: un usuario inexistente nunca llega a bloquearse. Esta asimetría convierte la política de bloqueo en un oráculo de enumeración de usuarios.
2. Technical Breakdown & Exploitation Flow
2.1 Forzar el bloqueo por cuenta
Para cada candidato de la wordlist se envían 5 requests de login con contraseña inválida:
POST /login HTTP/2
Content-Type: application/x-www-form-urlencoded
username=<CANDIDATO>&password=invalid
Repetir 5 veces por candidato. Las cuentas existentes alcanzan el umbral de bloqueo y devuelven el mensaje de lockout; las inexistentes responden siempre con el error de credenciales genérico.
2.2 Detección de cuentas válidas
Tras la quinta request, la respuesta distingue ambos casos:
You have made too many incorrect login attempts. Please try again in 1 minute. # Usuario existe
Invalid username or password # Usuario no existe
Los candidatos que devuelven el mensaje de bloqueo son usuarios válidos. Con el usuario confirmado, el siguiente paso es esperar la ventana de desbloqueo (~1 minuto) y ejecutar la fuerza bruta de la contraseña.
3. Root Cause Analysis
El fallo es aplicar la política de bloqueo después de la comprobación de existencia del usuario:
# Implementación vulnerable
user = db.get_user(request.username)
if user is None:
return "Invalid username or password" # ❌ No cuenta como intento
increment_failed_attempts(user) # Solo para usuarios existentes
if failed_attempts(user) >= 5:
lock_account(user) # El bloqueo revela la existencia
La política de account lockout debe contar intentos de forma indistinguible entre cuentas válidas e inválidas; si no, la propia defensa expone el inventario de usuarios (además de permitir un DoS por bloqueo masivo de cuentas reales).
4. Secure Remediation
Contar los intentos por username+IP y devolver la misma respuesta en todos los casos:
# Implementación segura
attempts = get_attempts(request.username, request.ip)
increment_attempts(request.username, request.ip)
if attempts >= 5:
return generic_lockout_message() # Mismo mensaje para usuarios reales y ficticios
user = db.get_user(request.username)
if user is None or not valid_credentials(user, request.password):
return "Invalid username or password"
El mensaje de bloqueo debe ser idéntico para todos los usernames (reales o no) y, cuando aplique desbloqueo temporal, la ventana debe contar también para los inexistentes para no diferenciarlos.