Forum Discussion
Researching a rule template "FailedLogonToAzurePortal"
Hello,
I have the template rule "FailedLogonToAzurePortal"(https://github.com/Azure/Azure-Sentinel/blob/master/Detections/SigninLogs/FailedLogonToAzurePortal.yaml) and there is a column of data that I don't understand. The column is "FailedLogonCount" and it was showing inconclusive data because it was showing more data than it was... Here is an example:
The issue states that 38 login failures have been detected, but if I investigate in the non-interactive login logs I only see one failure which matches the error code type "50173" but this only shows me one failure, I don't understand where the remaining 37 failures come from...
Can you help me?, I am a beginner in KQL and I don't think I understand the context of the alert.
Regards.
3 Replies
- Clive_WatsonBronze Contributor
You could amend line 53 to just show the count for the specific ResultType you are interested in (otherwise all ResultTypes or rows with no ResultTypes could be counted)
| summarize TimeGenerated = makelist(TimeGenerated), Status = makelist(Status), IPAddresses = makelist(IPAddress), IPAddressCount = dcount(IPAddress), FailedLogonCount = countif(ResultType=='50173')
using countif()
Tip, if you are amending the query change mvexpand to | mv-expand- Chris_321Copper Contributor
Hi Clive_Watson
Thanks for the reply!!!! But I want to understand why the counter shows 38 failed login attempts and after checking the events in the "AADNonInteractiveUserSignInLogs" table it does not show 38 events but only one. This search was already done at the AD level looking for the Non interactive sign-in within Azure AD, in the documentation it is explained that these events can be grouped, but I can't find the logic because I can't find the reason why the user has failed 38 times.
I have tried your suggestions, in line 53 I do not understand very well what I want to indicate. I have modified line 80 with what you indicate and it doesn't give me anything new to what I already had. I attach the modified query with what you have indicated.
// The query_now parameter represents the time (in UTC) at which the scheduled analytics rule ran to produce this alert. set query_now = datetime(2022-04-12T22:27:38.0351487Z); let timeRange = 1d; let lookBack = 7d; let threshold_Failed = 5; let threshold_FailedwithSingleIP = 20; let threshold_IPAddressCount = 2; let isGUID = "[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}"; let aadFunc = (tableName: string) { let azPortalSignins = materialize(table(tableName) | where TimeGenerated >= ago(lookBack) // Azure Portal only | where AppDisplayName =~ "Azure Portal") ; let successPortalSignins = azPortalSignins | where TimeGenerated >= ago(timeRange) // Azure Portal only and exclude non-failure Result Types | where ResultType in ("0", "50125", "50140") // Tagging identities not resolved to friendly names //| extend Unresolved = iff(Identity matches regex isGUID, true, false) | distinct TimeGenerated, UserPrincipalName ; let failPortalSignins = azPortalSignins | where TimeGenerated >= ago(timeRange) // Azure Portal only and exclude non-failure Result Types | where ResultType !in ("0", "50125", "50140") // Tagging identities not resolved to friendly names | extend Unresolved = iff(Identity matches regex isGUID, true, false) ; // Verify there is no success for the same connection attempt after the fail let failnoSuccess = failPortalSignins | join kind= leftouter ( successPortalSignins ) on UserPrincipalName | where TimeGenerated > TimeGenerated1 or isempty(TimeGenerated1) | project-away TimeGenerated1, UserPrincipalName1 ; // Lookup up resolved identities from last 7 days let identityLookup = azPortalSignins | where TimeGenerated >= ago(lookBack) | where not(Identity matches regex isGUID) | summarize by UserId, lu_UserDisplayName = UserDisplayName, lu_UserPrincipalName = UserPrincipalName; // Join resolved names to unresolved list from portal signins let unresolvedNames = failnoSuccess | where Unresolved == true | join kind= inner ( identityLookup ) on UserId | extend UserDisplayName = lu_UserDisplayName, UserPrincipalName = lu_UserPrincipalName | project-away lu_UserDisplayName, lu_UserPrincipalName; // Join Signins that had resolved names with list of unresolved that now have a resolved name let u_azPortalSignins = failnoSuccess | where Unresolved == false | union unresolvedNames; u_azPortalSignins | extend DeviceDetail = todynamic(DeviceDetail), Status = todynamic(DeviceDetail), LocationDetails = todynamic(LocationDetails) | extend Status = strcat(ResultType, ": ", ResultDescription), OS = tostring(DeviceDetail.operatingSystem), Browser = tostring(DeviceDetail.browser) | extend State = tostring(LocationDetails.state), City = tostring(LocationDetails.city), Region = tostring(LocationDetails.countryOrRegion) | extend FullLocation = strcat(Region, '|', State, '|', City) | summarize TimeGenerated = makelist(TimeGenerated), Status = makelist(Status), IPAddresses = makelist(IPAddress), IPAddressCount = dcount(IPAddress), FailedLogonCount = countif(ResultType=='50173') by UserPrincipalName, UserId, UserDisplayName, AppDisplayName, Browser, OS, FullLocation, Type | mv-expand TimeGenerated, IPAddresses, Status | extend TimeGenerated = todatetime(tostring(TimeGenerated)), IPAddress = tostring(IPAddresses), Status = tostring(Status) | project-away IPAddresses | summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated) by UserPrincipalName, UserId, UserDisplayName, Status, FailedLogonCount, IPAddress, IPAddressCount, AppDisplayName, Browser, OS, FullLocation, Type | where (IPAddressCount >= threshold_IPAddressCount and FailedLogonCount >= threshold_Failed) or FailedLogonCount >= threshold_FailedwithSingleIP | extend timestamp = StartTime, AccountCustomEntity = UserPrincipalName, IPCustomEntity = IPAddress }; let aadSignin = aadFunc("SigninLogs"); let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs"); union isfuzzy=true aadSignin, aadNonInt
- Abel_GallardoCopper Contributor
Hi Chris_321,
I've faced the same issue on this rule.The results show a higher number than expected (38 in your case) because it’s calculated using a join in this part of the code:
let failnoSuccess = failPortalSignins | join kind= leftouter ( successPortalSignins ) on UserPrincipalName | where TimeGenerated > TimeGenerated1 or isempty(TimeGenerated1) | project-away TimeGenerated1, UserPrincipalName1
The result is that all failures are selected both those that eventually end in a "SUCCESS" after a few retries and those that don't (the latter are the ones we are interested in).
To show only the results where the portal access ends in "FAILED", the query needs to be modified as follows:
You have to replace failnoSuccess with the following:
// Verify there is no success for the same connection attempt after the fail let failnoSuccess = failPortalSignins | join kind=leftouter ( successPortalSignins ) on UserPrincipalName // Only keep the success if it happens after the failure | where isnull(TimeGenerated1) or TimeGenerated1 > TimeGenerated // Now: for each FailTime, make sure we only keep the **first** match | summarize arg_min(TimeGenerated1, *) by TimeGenerated, UserPrincipalName // Final filter: keep only failures with NO success after them | where isnull(TimeGenerated1) ;
Hope you have understood the issue. And hopefully fixed it