Adversaries may be using permutations of UserPrincipalNames to perform brute force attacks by systematically testing variations of valid usernames. SOC teams should proactively hunt for this behavior in Azure Sentinel to identify and mitigate potential credential compromise attempts before they lead to successful account access.
KQL Query
let fl_Min = 3;
let un_MatchMin = 2;
let upnFunc = (tableName:string){
table(tableName)
| extend Operation = columnifexists("Operation", "Sign-in activity")
| where Operation == "UserLoginFailed" or Operation == "Sign-in activity"
| extend Result = columnifexists("ResultType", "tempValue")
| extend Result = iff(Result == "tempValue", columnifexists("ResultStatus", Result), Result)
| extend ResultValue = case(Result == "0", "Success", Result == "Success" or Result == "Succeeded", "Success", Result)
| where ResultValue != "Success"
| extend UserPrincipalName = columnifexists("UserPrincipalName", "tempValue")
| extend UserPrincipalName = iff(tableName == "OfficeActivity", tolower(UserId), tolower(UserPrincipalName))
| extend UPN = split(UserPrincipalName, "@")
| extend UserNameOnly = tostring(UPN[0]), DomainOnly = tostring(UPN[1])
| where UserNameOnly contains "." or UserPrincipalName contains "-" or UserPrincipalName contains "_"
// Verify we only get accounts without other separators, it would be difficult to identify multi-level separators
// Count of any that are not alphanumeric
| extend charcount = countof(UserNameOnly, '[^0-9A-Za-z]', "regex")
// Drop any that have non-alphanumeric characters still included
| where charcount < 2
// Creating array of name pairs that include the separators we are interested in, this can be added to if needed.
| extend unoArray = case(
UserNameOnly contains ".", split(UserNameOnly, "."),
UserNameOnly contains "-", split(UserNameOnly, "-"),
UserNameOnly contains "_", split(UserNameOnly, "_"),
UserNameOnly)
| extend First = iff(isnotempty(tostring(parse_json(unoArray)[0])), tostring(parse_json(unoArray)[0]),tostring(unoArray))
| extend Last = tostring(parse_json(unoArray)[1])
| extend First4char = iff(countof(substring(First, 0,4), '[0-9A-Za-z]', "regex") >= 4, substring(First, 0,4), "LessThan4"),
First6char = iff(countof(substring(First, 0,6), '[0-9A-Za-z]', "regex") >= 6, substring(First, 0,6), "LessThan6"),
First8char = iff(countof(substring(First, 0,8), '[0-9A-Za-z]', "regex") >= 8, substring(First, 0,8), "LessThan8"),
Last4char = iff(countof(substring(Last, 0,4), '[0-9A-Za-z]', "regex") >= 4, substring(Last, 0,4), "LessThan4"),
Last6char = iff(countof(substring(Last, 0,6), '[0-9A-Za-z]', "regex") >= 6, substring(Last, 0,6), "LessThan6"),
Last8char = iff(countof(substring(Last, 0,8), '[0-9A-Za-z]', "regex") >= 8, substring(Last, 0,8), "LessThan8")
| where First != Last
| summarize UserNames = makeset(UserNameOnly),
fl_Count = count() by bin(TimeGenerated, 10m), First4char, First6char, First8char, Last4char, Last6char, Last8char, Type
};
let SigninList = upnFunc("SigninLogs");
let OffActList = upnFunc("OfficeActivity");
let UserNameList = (union isfuzzy=true SigninList, OffActList);
let Char4List = UserNameList
| project TimeGenerated, First4char, Last4char, UserNames, fl_Count, Type
| where First4char != "LessThan4" and Last4char != "LessThan4";
// Break out first and last so we can then join and see where a first and last match.
let First4charList = Char4List | where isnotempty(First4char)
| summarize un_MatchOnFirst = makeset(UserNames),
fl_CountForFirst = sum(fl_Count) by TimeGenerated, CharSet = First4char, Type
| project TimeGenerated, CharSet, un_MatchOnFirst, un_MatchOnFirstCount = array_length(un_MatchOnFirst), fl_CountForFirst, Type;
let Last4charList = Char4List | where isnotempty(Last4char)
| summarize un_MatchOnLast = makeset(UserNames), fl_CountForLast = sum(fl_Count) by TimeGenerated, CharSet = Last4char, Type
| project TimeGenerated, CharSet, un_MatchOnLast, un_MatchOnLastCount = array_length(un_MatchOnLast), fl_CountForLast, Type;
let char4 = First4charList | join Last4charList on CharSet and TimeGenerated
| project-away TimeGenerated1, CharSet1
// Make sure that we get more than a single match for First or Last
| where un_MatchOnFirstCount >= un_MatchMin or un_MatchOnLastCount >= un_MatchMin
| where fl_CountForFirst >= fl_Min or fl_CountForLast >= fl_Min;
let Char6List = UserNameList
| project TimeGenerated, First6char, Last6char, UserNames, fl_Count, Type
| where First6char != "LessThan6" and Last6char != "LessThan6";
// Break out first and last so we can then join and see where a first and last match.
let First6charList = Char6List | where isnotempty(First6char)
| summarize un_MatchOnFirst = makeset(UserNames), fl_CountForFirst = sum(fl_Count) by TimeGenerated, CharSet = First6char, Type
| project TimeGenerated, CharSet, un_MatchOnFirst, un_MatchOnFirstCount = array_length(un_MatchOnFirst), fl_CountForFirst, Type;
let Last6charList = Char6List | where isnotempty(Last6char)
| summarize un_MatchOnLast = makeset(UserNames), fl_CountForLast = sum(fl_Count) by TimeGenerated, CharSet = Last6char, Type
| project TimeGenerated, CharSet, un_MatchOnLast, un_MatchOnLastCount = array_length(un_MatchOnLast), fl_CountForLast, Type;
let char6 = First6charList | join Last6charList on CharSet and TimeGenerated
| project-away TimeGenerated1, CharSet1
// Make sure that we get more than a single match for First or Last
| where un_MatchOnFirstCount >= un_MatchMin or un_MatchOnLastCount >= un_MatchMin
| where fl_CountForFirst >= fl_Min or fl_CountForLast >= fl_Min;
let Char8List = UserNameList
| project TimeGenerated, First8char, Last8char, UserNames, fl_Count, Type
| where First8char != "LessThan8" and Last8char != "LessThan8";
// Break out first and last so we can then join and see where a first and last match.
let First8charList = Char8List | where isnotempty(First8char)
| summarize un_MatchOnFirst = makeset(UserNames), fl_CountForFirst = sum(fl_Count) by TimeGenerated, CharSet = First8char, Type
| project TimeGenerated, CharSet, un_MatchOnFirst, un_MatchOnFirstCount = array_length(un_MatchOnFirst), fl_CountForFirst, Type;
let Last8charList = Char8List | where isnotempty(Last8char)
| summarize un_MatchOnLast = makeset(UserNames), fl_CountForLast = sum(fl_Count) by TimeGenerated, CharSet = Last8char, Type
| project TimeGenerated, CharSet, un_MatchOnLast, un_MatchOnLastCount = array_length(un_MatchOnLast), fl_CountForLast, Type;
let char8 = First8charList | join Last8charList on CharSet and TimeGenerated
| project-away TimeGenerated1, CharSet1
// Make sure that we get more than a single match for First or Last
| where un_MatchOnFirstCount >= un_MatchMin or un_MatchOnLastCount >= un_MatchMin
| where fl_CountForFirst >= fl_Min or fl_CountForLast >= fl_Min;
(union isfuzzy=true char4, char6, char8)
| project Type, TimeGenerated, CharSet, UserNameMatchOnFirst = un_MatchOnFirst, UserNameMatchOnFirstCount = un_MatchOnFirstCount,
FailedLogonCountForFirst = fl_CountForFirst, UserNameMatchOnLast = un_MatchOnLast, UserNameMatchOnLastCount = un_MatchOnLastCount,
FailedLogonCountForLast = fl_CountForLast
| sort by UserNameMatchOnFirstCount desc, UserNameMatchOnLastCount desc
| extend timestamp = TimeGenerated
id: 472e83d6-ccec-47b8-b1cd-75500f936981
name: Permutations on logon attempts by UserPrincipalNames indicating potential brute force
description: |
'This identifies failed logon attempts using permutations based on known first and last names within 10m time windows. Iteration through separators or order changes in the logon name may indicate potential Brute Force logon attempts.'
description_detailed: |
'Attackers sometimes try variations on account logon names, this will identify failed attempts on logging in using permutations
based on known first and last name within 10m time windows, for UserPrincipalNames that separated by hyphen(-), underscore(_) and dot(.).
If there is iteration through these separators or order changes in the logon name it may indicate potential Brute Force logon attempts.
For example, attempts with [email protected], [email protected], [email protected] and so on.'
requiredDataConnectors:
- connectorId: AzureActiveDirectory
dataTypes:
- SigninLogs
- connectorId: Office365
dataTypes:
- OfficeActivity
tactics:
- CredentialAccess
relevantTechniques:
- T1110
query: |
let fl_Min = 3;
let un_MatchMin = 2;
let upnFunc = (tableName:string){
table(tableName)
| extend Operation = columnifexists("Operation", "Sign-in activity")
| where Operation == "UserLoginFailed" or Operation == "Sign-in activity"
| extend Result = columnifexists("ResultType", "tempValue")
| extend Result = iff(Result == "tempValue", columnifexists("ResultStatus", Result), Result)
| extend ResultValue = case(Result == "0", "Success", Result == "Success" or Result == "Succeeded", "Success", Result)
| where ResultValue != "Success"
| extend UserPrincipalName = columnifexists("UserPrincipalName", "tempValue")
| extend UserPrincipalName = iff(tableName == "OfficeActivity", tolower(UserId), tolower(UserPrincipalName))
| extend UPN = split(UserPrincipalName, "@")
| extend UserNameOnly = tostring(UPN[0]), DomainOnly = tostring(UPN[1])
| where UserNameOnly contains "." or UserPrincipalName contains "-" or UserPrincipalName contains "_"
// Verify we only get accounts without other separators, it would be difficult to identify multi-level separators
// Count of any that are not alphanumeric
| extend charcount = countof(UserNameOnly, '[^0-9A-Za-z]', "regex")
// Drop any that have non-alphanumeric characters still included
| where charcount < 2
// Creating array of name pairs that include the separators we are interested in, this can be added to if needed.
| extend unoArray = case(
UserNameOnly contains ".", split(UserNameOnly, "."),
UserNameOnly contains "-", split(UserNameOnly, "-"),
UserNameOnly contains "_", split(UserNameOnly, "_"),
UserNameOnly)
| extend First = iff(isnotempty(tostring(parse_json(unoArray)[0])), tostring(parse_json(unoArray)[0]),tostring(unoArray))
| extend Last = tostring(parse_json(unoArray)[1])
| extend First4char = iff(countof(substri
| Sentinel Table | Notes |
|---|---|
OfficeActivity | Ensure this data connector is enabled |
SigninLogs | Ensure this data connector is enabled |
Scenario: Scheduled System Maintenance Job Using UserPrincipalName Variations
Description: A system maintenance script or job (e.g., PowerShell or Ansible) iterates through user logon names to perform configuration updates or patching.
Filter/Exclusion: Check for Event ID 41 or Event ID 10000 related to scheduled tasks, or filter by SourceName like Microsoft-Windows-ScheduledTasks or PowerShell.
Scenario: User-Driven Password Reset via Self-Service Portal
Description: A user attempts to reset their password using a self-service portal (e.g., Azure AD Password Reset, Okta), which may trigger logon attempts with variations of their UPN.
Filter/Exclusion: Filter by UserAgent containing “Okta” or “Azure AD”, or check for Event ID 4740 related to password reset requests.
Scenario: Automated User Provisioning Tool Using UPN Variations
Description: An automated provisioning tool (e.g., Microsoft Identity Manager, Azure AD Connect, or PowerShell scripts) generates temporary user accounts with UPN permutations for testing or onboarding.
Filter/Exclusion: Filter by Event ID 4724 (User Account Created), or check for SourceName like Microsoft-IdentityManager or AzureADConnect.
Scenario: Multi-Factor Authentication (MFA) Retry Attempts
Description: A user repeatedly attempts to log in after MFA failure, which may involve trying different UPN formats (e.g., [email protected], user.domain.com).
Filter/Exclusion: Filter by Event ID 4746 (User Account Locked Out) or check for `Event ID 472