In this section

TH4.3 Hunting Credential Stuffing

6-8 hours · Module 4
What you already know

The previous section covered hunting aitm session hijacking. This section covers hunting credential stuffing.

The breach credential economy

Credential stuffing is a volume game. The attacker purchases a database of email-password pairs from a previous breach (LinkedIn 2012, Collection #1-5, various corporate breaches available on dark web markets). They feed these pairs into an automated tool that submits them against your organization's Entra ID login endpoint. If any pair matches, because the user reused the password from the breached service: the attacker gains access without triggering MFA bypass techniques, without phishing, without any social engineering.

Anti-Pattern

Hunting hunting credential stuffing without a hypothesis

The hunter opens Advanced Hunting and starts writing queries without a clear hypothesis. They find interesting data but cannot determine whether it represents a threat, a misconfiguration, or normal activity. Every hunt starts with a hypothesis: a specific, testable statement about attacker behavior. Without a hypothesis, you are exploring, not hunting. Exploration has value, but it produces findings you cannot action without additional scoping.

The detection challenge: each account receives only one or two attempts with a specific password. There is no brute force: no 100 failed attempts against one account. The failures are distributed across hundreds of accounts from dozens of IPs (the attacker uses rotating proxies). The individual events look like normal mistyped passwords. The pattern is only visible in aggregate.

Understanding Entra ID error codes

Entra ID returns specific error codes that distinguish credential stuffing from other login failures. The most relevant:

50126. Invalid username or password. The password submitted does not match the account's current password. This is the primary credential stuffing indicator when it occurs across many accounts in a short window. Note: this code also fires for every legitimate mistyped password, so volume and distribution are required to distinguish attack from noise.

50053. Account locked. The account reached the lockout threshold. In a credential stuffing campaign, lockouts indicate the attacker tried multiple passwords for the same account, crossing from stuffing into brute force territory.

50057. Disabled account. The target account is disabled. Credential stuffing tools do not check account status before attempting, they submit every pair in the breach database. Failures against disabled accounts are strong stuffing indicators because legitimate users know their account is disabled and do not try to log in.

50076. MFA required. The password was correct but MFA challenge was triggered. This is a partial success for the attacker: the password matched. If this occurs for many accounts simultaneously, the breach database contains valid passwords for your users. Even though MFA blocked access, the password match confirms the users are reusing breached passwords.

The credential stuffing detection query

// Credential Stuffing Hunt — Distributed breach-credential attempts
let detectionWindow = 24h;
let minAccounts = 10;  // Minimum accounts targeted in the window
let failureCodes = dynamic(["50126", "50057", "50053"]);
SigninLogs
| where TimeGenerated > ago(detectionWindow)
| where ResultType in (failureCodes)
| extend ErrorCode = ResultType
| extend Country = tostring(LocationDetails.countryOrRegion)
| extend City = tostring(LocationDetails.city)
| extend ISP = tostring(
    parse_json(LocationDetails).["networkNames"])
// Group by time window and IP to find campaigns
| summarize
    TargetedAccounts = dcount(UserPrincipalName),
    Accounts = make_set(UserPrincipalName, 50),
    FailureCount = count(),
    ErrorCodes = make_set(ErrorCode),
    Countries = make_set(Country, 10),
    TimeSpan = datetime_diff(
        'minute', max(TimeGenerated), min(TimeGenerated))
    by IPAddress, bin(TimeGenerated, 1h)
| where TargetedAccounts >= minAccounts
// An IP targeting 10+ unique accounts in 1 hour with password
//   failures is highly suspicious
// Legitimate users do not fail against 10 different accounts
//   from the same IP
| extend AttackRate = round(
    todouble(FailureCount) / todouble(TimeSpan + 1), 2)
| sort by TargetedAccounts desc
// INTERPRETATION:
// TargetedAccounts = breadth of the campaign
// AttackRate = speed (failures per minute)
//   High rate + high breadth = automated credential stuffing
//   Low rate + high breadth = slow-and-slow stuffing (harder to detect)
// ErrorCodes containing "50076" = some passwords matched (critical)

Identifying slow-and-slow attacks

Sophisticated attackers throttle their credential stuffing to stay under rate limits and detection thresholds. Instead of 100 attempts per minute from one IP, they submit 1 attempt per minute from 50 different IPs.

// Slow-and-slow credential stuffing — distributed across many IPs
let detectionWindow = 24h;
let failureCodes = dynamic(["50126", "50057"]);
SigninLogs
| where TimeGenerated > ago(detectionWindow)
| where ResultType in (failureCodes)
| summarize
    SourceIPs = dcount(IPAddress),
    FailureCount = count(),
    AttackerIPs = make_set(IPAddress, 100),
    ErrorCodes = make_set(ResultType),
    TimeSpan = datetime_diff(
        'minute', max(TimeGenerated), min(TimeGenerated))
    by UserPrincipalName
| where SourceIPs >= 5 and FailureCount >= 5
// 5+ different IPs all failing for the same user = targeted
//   credential rotation (different IPs trying different passwords)
// OR: a user who rotates VPNs — check against the baseline
| sort by SourceIPs desc
// Cross-reference with the TH4.1 baseline:
// If these IPs are NOT in the user's baseline, the multi-IP
//   failure pattern is an attack, not a user on multiple networks

Detecting successful credential reuse (the 50076 signal)

The most critical finding in a credential stuffing hunt is not the failures, it is the successes. Error code 50076 (MFA required) means the password matched but MFA blocked access. This confirms the user is reusing a breached password.

// Find accounts where the password MATCHED during the campaign
// These users are reusing breached passwords — immediate risk
let stuffingWindow = 24h;
let stuffingIPs = SigninLogs
| where TimeGenerated > ago(stuffingWindow)
| where ResultType == "50126"
| summarize TargetCount = dcount(UserPrincipalName) by IPAddress
| where TargetCount >= 10
| project IPAddress;
// Now find successes or MFA-blocked attempts from those IPs
SigninLogs
| where TimeGenerated > ago(stuffingWindow)
| where IPAddress in (stuffingIPs)
| where ResultType == "50076"  // Password correct, MFA required
    or ResultType == "0"       // Full success (no MFA or MFA passed)
| project TimeGenerated, UserPrincipalName, IPAddress,
    ResultType,
    ResultDescription = case(
        ResultType == "50076", "PASSWORD MATCHED — MFA blocked",
        ResultType == "0", "FULL ACCESS — compromised",
        "Other"),
    AppDisplayName, ConditionalAccessStatus
| sort by ResultType asc  // Full successes first
// ResultType == "0" from a stuffing IP = ACTIVE COMPROMISE
//   Immediate escalation to IR
// ResultType == "50076" = password reuse confirmed
//   Force password reset for these users immediately
//   MFA prevented access this time — it may not next time
//   (AiTM can capture the MFA session)
CREDENTIAL STUFFING — ATTACK PATTERN AND DETECTION SIGNALS BREACH DATABASE user@yourorg.com : P@ssw0rd123 Unique password per user (not spray) ROTATING PROXIES 1 attempt per IP per account Stays under lockout thresholds ENTRA ID LOGIN 50126: wrong password (most) 50076: correct password, MFA blocked HUNT SIGNALS Many accounts, same time window, error 50126 | Single IPs targeting 10+ accounts | 50076 = password match (critical) The stuffing failures are noise. The 50076 events are the signal — they confirm breached password reuse. Force password resets for every 50076 user. They are one AiTM away from full compromise.

Figure TH4.3. Credential stuffing detection. The failures identify the campaign. The 50076 events identify the users at risk.

Run the primary credential stuffing query with a 24-hour window. Do any IPs target 10+ accounts? If yes, examine the error code distribution, are there any 50076 events (password matched)?

If you find 50076 events, these users are reusing breached passwords. Even though MFA blocked access, the password match means the user's credentials are in an active breach database. Remediation: force password reset, verify MFA method is phishing-resistant, and check whether those users were subsequently targeted by AiTM (run TH4.2 for those specific users).

Run the slow-and-slow query. Do any users have 5+ distinct IPs all failing in 24 hours? Cross-reference with the TH4.1 baseline, if these IPs are not in the user's normal set, the pattern is an attack.

Compliance Context

Password complexity policies govern the format of the password, not its uniqueness. A user who creates `C0mplex!Pass2024` for your organization and also uses `C0mplex!Pass2024` on LinkedIn is fully compliant with your policy and fully vulnerable to credential stuffing. Password complexity is irrelevant if the same password is reused across services. The only defenses are: (1) MFA on every account (blocks access even when the password matches), (2) phishing-resistant MFA (blocks AiTM follow-up after the password match is discovered), (3) Entra ID Password Protection with custom banned words (prevents common patterns), and (4) hunting for 50076 events to identify and remediate users who are currently reusing breached passwords.

Extend this hunt

If your organization uses Microsoft Entra Password Protection (which checks passwords against a global banned list and custom banned words at password change time), the 50076 results are even more concerning: the user set a password that passed your complexity requirements AND the banned list, but it was still in a breach database. This means the breach occurred after the password was set, or the breached service had identical complexity requirements. Either way, the password is compromised. For organizations with Entra ID P2, the "leaked credentials" risk detection in Identity Protection may also flag these users, cross-reference your hunting findings with the Identity Protection risky users report to identify gaps where the hunt caught something Identity Protection missed.

Checkpoint

Hunt window: ___ to ___

Total 50126 failures in window: ___

IPs targeting 10+ accounts: ___

50076 events (password matched, MFA blocked): ___

Users with confirmed breached password reuse: ___

Full access events (ResultType 0) from stuffing IPs: ___

Remediation: Password resets forced: ___

Remediation: MFA method verification: ___

Incidents opened: ___

References Used in This Subsection