Forum Discussion

Chris_321's avatar
Chris_321
Copper Contributor
Apr 13, 2022

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_Watson's avatar
    Clive_Watson
    Bronze Contributor

    Chris_321 

     

    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_321's avatar
      Chris_321
      Copper 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_Gallardo's avatar
        Abel_Gallardo
        Copper 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

Resources