Query Details

Analytics Slow Password Spray

Query

// This query can help you to detect slow password spray events.
// If you want to check all the activity (not the new activity) over a time period, make sure query_frequency and query_period parameters are the same value.
//
// Click "Save as function", in Parameters write in the fields:
// "timespan" "query_frequency" "14d"
// "timespan" "query_period"    "14d"
//
// If you name the function "SlowPasswordSpray", you can check the function with queries like the following:
//
// SlowPasswordSpray()
//
// SlowPasswordSpray(1h, 14d)
//
// SlowPasswordSpray(14d, 14d)
//
// let query_frequency = 14d;
// let query_period = 14d;
//let Function = (query_frequency:timespan = 14d, query_period:timespan = 14d){
let ipv4_prefix_mask = 23;
let session_period_limit = 7d;
let group_by_time_period = 30m;
let excluded_success_resulttypes = dynamic(["700082"]);
let _ExpectedIPAddresses = toscalar(
    union _GetWatchlist("IP-CorporateCollaborators"), _GetWatchlist("IP-Vendors")
    | summarize make_list(IPAddress)
);
let _ExpectedLocations = toscalar(
    _GetWatchlist("Activity-ExpectedSignificantActivity")
    | where Activity == "CorporateGeolocation"
    | summarize make_list(Auxiliar)
);
let _ExpectedASNs = toscalar(
    _GetWatchlist("Activity-ExpectedSignificantActivity")
    | where Activity == "CommonUserASN"
    | summarize make_list(Auxiliar)
);
let _HomeTenantId = toscalar(
    _GetWatchlist("UUID-AADTenantIds")
    | where Notes has "[HomeTenant]"
    | summarize make_list(TenantId)
);
let _PartialSuccessResultTypes = toscalar(
    _GetWatchlist("ResultType-SignInLogsErrorCodes")
    | where Notes has_any ("[Success]", "[Expired]") and not(ResultType in (excluded_success_resulttypes))
    | summarize make_list(ResultType)
);
let _ExcludedResultTypes = toscalar(
    _GetWatchlist("ResultType-SignInLogsErrorCodes")
    | where not(Notes has_any ("[Success]", "[Failure]")) and Notes has "[Interrupt]"
    | summarize make_list(ResultType)
);
// Query authentication events
let _SigninEvents =
    union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs, ADFSSignInLogs
    | where TimeGenerated > ago(query_period)
;
let _FilteredSigninEvents =
    _SigninEvents
    | where ResourceTenantId in (_HomeTenantId)
    | where not(IPAddress == "127.0.0.1" or (isnotempty(parse_ipv4(IPAddress)) and ipv4_is_private(IPAddress)))
    // Remove expected IP addresses
    | where not((isnotempty(parse_ipv4(IPAddress)) and ipv4_is_in_any_range(IPAddress, _ExpectedIPAddresses)) 
        or (isempty(parse_ipv4(IPAddress)) and isnotempty(parse_ipv6(IPAddress)) and ipv6_is_in_any_range(IPAddress, _ExpectedIPAddresses)))
    | where not(UserType == "Guest")
    | where not(ResultType in (_ExcludedResultTypes))
    // Distinguish failed and (partially) successful authentications
    | extend PartialSuccessResultType = ResultType in (_PartialSuccessResultTypes)
;
let _GetSprayStages = (start_time:datetime, end_time:datetime){
    // Query events where distinct accounts were observed from the same address range or CorrelationId
    let _SprayEvents = materialize(
        union
        (
            _FilteredSigninEvents
            | where IsInteractive and TimeGenerated between(start_time .. end_time) and isnotempty(IPAddress)
            // Compute the first time an account had activity from a specific IP address
            | summarize hint.shufflekey=IPAddress
                StartTime = min(TimeGenerated)
                by UserId, IPAddress, PartialSuccessResultType
            | extend IPRange = parse_ipv6_mask(IPAddress, 128 - (32 - ipv4_prefix_mask))
            // Group the first times by address range
            | summarize hint.shufflekey=IPRange
                minTimeGeneratedList = make_list(tostring(pack_array(StartTime, UserId))),
                IPAddresses = make_set(IPAddress, tolong(min_of(pow(2, (32 - ipv4_prefix_mask)), 10000))),
                DistinctAccountCount = dcount(UserId)
                by IPRange, PartialSuccessResultType
        ),
        (
            _FilteredSigninEvents
            | where IsInteractive and TimeGenerated between(start_time .. end_time) and isnotempty(CorrelationId)
            // Compute the first time an account had activity from a specific IP address
            | summarize hint.shufflekey=CorrelationId
                StartTime = min(TimeGenerated)
                by UserId, CorrelationId, IPAddress, PartialSuccessResultType
            // Group the first times by address range
            | summarize hint.shufflekey=CorrelationId
                minTimeGeneratedList = make_list(tostring(pack_array(StartTime, UserId))),
                IPAddresses = make_set(IPAddress, 10000),
                DistinctAccountCount = dcount(UserId)
                by CorrelationId, PartialSuccessResultType
        )
        // Remove ranges with failures and less than 2 accounts or 2 events
        | where not(not(PartialSuccessResultType) and (DistinctAccountCount < 2 or array_length(minTimeGeneratedList) < 2))
        // Compute time periods between different accounts
        | mv-apply minTimeGeneratedElement = minTimeGeneratedList on (
            extend minTimeGeneratedElement = todynamic(dynamic_to_json(minTimeGeneratedElement))
            | extend
                TimeGenerated = todatetime(minTimeGeneratedElement[0]),
                UserId = tostring(minTimeGeneratedElement[1])
            | sort by TimeGenerated asc
            // Remove cases where the previous event was the same account (assumedly from another IP address)
            | where not(isnotempty(prev(UserId)) and UserId == prev(UserId))
            // Compute how much time passed until the next event
            | extend NextEventTimeDiff = next(TimeGenerated) - TimeGenerated
            | project-away minTimeGeneratedElement, TimeGenerated, UserId
            // Keep events where the next event happened within x time
            | where isnotempty(NextEventTimeDiff) and NextEventTimeDiff between (time(0s)..session_period_limit)
            // Count how many events happened with a specific frequency (e.g. from 0 to 30m, 30m to 1h, 1h to 1h30m)
            | summarize
                AdditionalEvents = count(),
                take_any(PartialSuccessResultType)
                by Frequency = bin(NextEventTimeDiff, group_by_time_period)
            // Remove frequencies of successful authentications that have lesser activity that 2 additional events per 5 hours
            // (thus if long periods between events, take only cases with many events)
            | where not(PartialSuccessResultType and AdditionalEvents < 2*(Frequency / 5h))
            | summarize
                SprayCount = 1 + sum(AdditionalEvents),
                SprayFrequencies = make_bag(pack(tostring(Frequency), AdditionalEvents)),
                FrequenciesList = make_list(AdditionalEvents)
        )
        | project-away minTimeGeneratedList
        // Compute the spray consistency based on Shannon entropy
        | extend FrequenciesList = array_concat(FrequenciesList, pack_array(1)) // Add noise
        | mv-apply Frequency = FrequenciesList to typeof(int) on (
            extend AuxProb = Frequency / toreal(SprayCount)
            | summarize SprayConsistency = -sum(AuxProb*log2(AuxProb))
        )
        | project-away FrequenciesList
        // Underestimate spray consistency of grouped events with less than 5 events
        | extend SprayConsistency = SprayConsistency + iff(SprayCount <= 5, (5-SprayCount) / toreal(5), 0.0)
        // Fix spray values where there wasn't spray activity
        | extend
            SprayCount = iff(SprayCount == 1 and array_length(bag_keys(SprayFrequencies)) == 0, int(null), SprayCount),
            SprayConsistency = iff(SprayCount == 1 and array_length(bag_keys(SprayFrequencies)) == 0, real(null), SprayConsistency)
        // Remove ranges with failures but without spray activity
        | where not(not(PartialSuccessResultType) and isempty(SprayCount))
        // Pack the useful information
        | extend Properties = pack(
            "PartialSuccessResultType", PartialSuccessResultType,
            "DistinctAccountCount", DistinctAccountCount,
            "DistinctAddressCount", array_length(IPAddresses),
            "SprayCount", SprayCount,
            "SprayConsistency", SprayConsistency,
            "SprayFrequencies", SprayFrequencies,
            "IPAddresses", IPAddresses
            )
        | project PartialSuccessResultType, IPRange, CorrelationId, IPAddresses, Properties, Source = coalesce(IPRange, CorrelationId)
    );
    // Join together failed and successful activity from the same "source"
    let _JoinedEvents =
        _SprayEvents
        | where PartialSuccessResultType
        | project-rename Success_IPAddresses = IPAddresses
        | lookup kind=inner (
            _SprayEvents
            | where not(PartialSuccessResultType)
            | project-rename Failure_IPAddresses = IPAddresses
        ) on Source
        // Join successful and failure events ONLY if they share the same CorrelationId OR set of addresses
        // Please, create another detection that will check successful authentications from password spray ranges
        | where isnotempty(CorrelationId) or not(array_length(set_intersect(Success_IPAddresses, Failure_IPAddresses)) == 0)
        | project Source, Joined = true
    ;
    _SprayEvents
    | lookup kind=leftouter _JoinedEvents on Source
    | extend Joined = coalesce(Joined, PartialSuccessResultType)
    | summarize
        take_any(IPRange, CorrelationId),
        IPAddresses = make_set(IPAddresses, 10000),
        Activity = make_bag(pack(iff(PartialSuccessResultType, "SuccessEvents", "FailureEvents"), Properties))
        by Source, Joined
    | project-away Joined
    // Tag the potential stage of an attack
    | extend Stage = case(
        isnotempty(Activity["FailureEvents"]) and isnotempty(Activity["SuccessEvents"]), "Password Spray + Initial Access",
        isnotempty(Activity["FailureEvents"]) and isempty(Activity["SuccessEvents"]), "Password Spray",
        isempty(Activity["FailureEvents"]) and isnotempty(Activity["SuccessEvents"]), "Initial Access",
        ""
        )
    // Remove ranges without failures nor spray activity
    | where not(
        Stage == "Initial Access"
        and isempty(Activity["SuccessEvents"]["SprayCount"])
        )
    // Remove ranges, with failures and successes, with low activity
    | where not(
        Stage == "Password Spray + Initial Access"
        and toint(Activity["SuccessEvents"]["SprayCount"]) < 3
        and toint(Activity["FailureEvents"]["SprayCount"]) < 3
        )
    // Remove ranges where the failures don't cover extensively the successes
    | extend Auxiliar_FailureOverSuccessCoverage =
        100 *
        (toreal(Activity["FailureEvents"]["SprayCount"]) / toreal(Activity["SuccessEvents"]["DistinctAccountCount"])) *
        (toreal(Activity["FailureEvents"]["SprayCount"]) / toreal(Activity["FailureEvents"]["DistinctAccountCount"])) *
        (toreal(Activity["FailureEvents"]["DistinctAddressCount"]) / toreal(Activity["SuccessEvents"]["DistinctAddressCount"])) *
        iff(isnotempty(Activity["SuccessEvents"]["SprayConsistency"]), (toreal(Activity["SuccessEvents"]["SprayConsistency"]) / toreal(Activity["FailureEvents"]["SprayConsistency"])), 1.0)
    | where not(
        Stage == "Password Spray + Initial Access"
        and isnotempty(Auxiliar_FailureOverSuccessCoverage) and Auxiliar_FailureOverSuccessCoverage < 1
        )
    | project-away Auxiliar_FailureOverSuccessCoverage
};
// Get the events relevant to query_frequency
let _SprayStages = materialize(
    _GetSprayStages(ago(query_period), now())
    | lookup kind=leftouter (
        _GetSprayStages(ago(query_period), ago(query_frequency))
        | project Source, Stage, Recurrent = true
    ) on Source, Stage
    | extend Recurrent = coalesce(Recurrent, false)
);
// Get the list of spraying IP addresses
let _SprayingAddresses = toscalar(
    _SprayStages
    | where isnotempty(IPRange)
    | summarize make_set(IPAddresses)
);
// Get the list of spraying CorrelationIds
let _SprayingCorrelationIds = toscalar(
    _SprayStages
    | where isnotempty(CorrelationId)
    | summarize make_set(CorrelationId)
);
// Add information about start time, location, ASNs, result types, devices...
_SprayStages
| lookup kind=leftouter (
    union
    (
        _SigninEvents
        | where IPAddress in (_SprayingAddresses)
        | extend Auxiliar = IPAddress
    ),
    (
        _SigninEvents
        | where CorrelationId in (_SprayingCorrelationIds)
        | extend Auxiliar = CorrelationId
    )
    //| where not(isempty(DeviceDetail_string) and isempty(DeviceDetail_dynamic) and isempty(UserAgent))
    | project
        Auxiliar,
        TimeGenerated,
        CorrelationId,
        IPAddress,
        Location,
        AutonomousSystemNumber,
        UserDisplayName,
        UserId,
        ResultType,
        UserAgent,
        DeviceDetail = iff(isnotempty(DeviceDetail_string), DeviceDetail_string, tostring(DeviceDetail_dynamic)),
        SampleBy = strcat(IPAddress, "|", UserAgent)
    // Take 1 sample event per "key"
    | summarize hint.shufflekey=SampleBy
        take_any(*),
        StartTime = min(TimeGenerated),
        EndTime = max(TimeGenerated),
        UserDisplayNames = make_set_if(UserDisplayName, not(UserDisplayName == UserId), 100)
        by SampleBy, Auxiliar
    | project-away SampleBy, UserId
    | extend
        IPRange = iff(Auxiliar == IPAddress, parse_ipv6_mask(IPAddress, 128 - (32 - ipv4_prefix_mask)), ""),
        CorrelationId = iff(Auxiliar == CorrelationId, CorrelationId, "")
    | extend
        Source = coalesce(IPRange, CorrelationId),
        DeviceDetail = todynamic(DeviceDetail),
        ParsedUserAgent = parse_user_agent(UserAgent, dynamic(["os", "browser"]))
    | extend
        DeviceId = tostring(DeviceDetail["deviceId"]),
        DeviceName = tostring(DeviceDetail["displayName"]),
        DeviceIsManaged = tostring(DeviceDetail["isManaged"]),
        DeviceTrustType = tostring(DeviceDetail["trustType"]),
        DeviceDetailOS = tostring(DeviceDetail["operatingSystem"]),
        UserAgentOS = tostring(ParsedUserAgent["OperatingSystem"]["Family"]),
        Browser = tostring(ParsedUserAgent["Browser"]["Family"])
    | extend
        OperatingSystem = case(
            isempty(DeviceDetailOS), UserAgentOS,
            isempty(UserAgent), extract(@"^([A-Za-z]+)", 1, DeviceDetailOS),
            UserAgentOS == "Other", extract(@"^([A-Za-z]+)", 1, DeviceDetailOS),
            UserAgentOS
        ),
        Browser = case(
            Browser == "Other", iff(UserAgent != "-", extract(@"^([^\/\s]+)", 1, UserAgent), ""),
            Browser
        )
    | extend
        OperatingSystem = case(
            OperatingSystem has "ios", "iOS",
            OperatingSystem has_any ("mac", "macos"), "macOS",
            OperatingSystem == "Ubuntu", "Linux",
            Browser == "Samsung Internet", "Android",
            Browser == "MacOutlook", "macOS",
            OperatingSystem
        )
    | project Source, StartTime, EndTime, UserDisplayNames, ResultType, Location, AutonomousSystemNumber, DeviceId, DeviceName, DeviceIsManaged, DeviceTrustType, Browser, OperatingSystem
    | as hint.materialized=true _SampleEvents
    | summarize
        StartTime = min(StartTime),
        EndTime = max(EndTime),
        UserDisplayNamesSample = make_set(UserDisplayNames, 100),
        ResultTypesSample = make_set(ResultType, 100),
        Locations = make_set_if(Location, isnotempty(Location), 100),
        AutonomousSystemNumbers = make_set_if(AutonomousSystemNumber, isnotempty(AutonomousSystemNumber), 100),
        //DeviceIdsSample = make_set_if(DeviceId, isnotempty(DeviceId), 100),
        DeviceNamesSample = make_set_if(DeviceName, isnotempty(DeviceName), 100),
        DeviceIsManagedSample = make_set_if(DeviceIsManaged, isnotempty(DeviceIsManaged), 100),
        DeviceTrustTypesSample = make_set_if(DeviceTrustType, isnotempty(DeviceTrustType), 100),
        Browsers = make_set_if(Browser, isnotempty(Browser), 100)
        by Source
    | lookup kind=leftouter (
        _SampleEvents
        | summarize OSCount = count() by OperatingSystem, Source
        | summarize
            OperatingSystems = make_bag(pack(OperatingSystem, OSCount)),
            OperatingSystemsCount = sum(OSCount)
            by Source
        ) on Source
    | extend
        Location = case(
            array_length(Locations) == 1, tostring(Locations[0]),
            ""
        ),
        ASN = case(
            array_length(AutonomousSystemNumbers) == 1, tostring(AutonomousSystemNumbers[0]),
            ""
        )
) on Source
// Remove ranges without failures that might be mobile ISP ranges
| where not(
    Stage == "Initial Access"
    and Location in (_ExpectedLocations)
    and ASN in (_ExpectedASNs)
    and (toint(Activity["SuccessEvents"]["DistinctAddressCount"]) > 10 or toint(Activity["SuccessEvents"]["DistinctAccountCount"]) > 10)
    and (toint(Activity["SuccessEvents"]["DistinctAddressCount"]) / toreal(Activity["SuccessEvents"]["DistinctAccountCount"])) >= 0.95
    and (toint(OperatingSystems["iOS"]) + toint(OperatingSystems["Android"])) >= 0.95*OperatingSystemsCount
    )
// Remove ranges without failures that might be common user ISP ranges
| where not(
    Stage == "Initial Access"
    and Location in (_ExpectedLocations)
    and ASN in (_ExpectedASNs)
    and (toint(Activity["SuccessEvents"]["DistinctAccountCount"]) - toint(Activity["SuccessEvents"]["DistinctAddressCount"])) <= 2
    and (toint(Activity["SuccessEvents"]["SprayCount"]) - toint(Activity["SuccessEvents"]["DistinctAddressCount"])) <= 2
    and (toint(OperatingSystems["iOS"]) >= 2 or toint(OperatingSystems["Android"]) >= 2)
    )
| where not(
    Stage == "Initial Access"
    and Location in (_ExpectedLocations)
    and ASN in (_ExpectedASNs)
    and (toint(Activity["SuccessEvents"]["SprayCount"]) <= 2
        or (toint(Activity["SuccessEvents"]["SprayCount"]) / toreal(Activity["SuccessEvents"]["SprayConsistency"]) / toreal(Activity["SuccessEvents"]["DistinctAccountCount"])) < 0.2)
    and (toint(OperatingSystems["iOS"]) >= 2 or toint(OperatingSystems["Android"]) >= 2)
    )
// Remove ranges without failures from expected locations with few accounts per address
| where not(
    Stage == "Initial Access"
    and Location in (_ExpectedLocations)
    and (toint(Activity["SuccessEvents"]["DistinctAccountCount"]) - toint(Activity["SuccessEvents"]["DistinctAddressCount"])) <= 1
    and (toint(Activity["SuccessEvents"]["SprayCount"]) - toint(Activity["SuccessEvents"]["DistinctAddressCount"])) <= 1
    )
| mv-apply UserDisplayName = UserDisplayNamesSample to typeof(string) on (
    reduce by UserDisplayName with characters=" "
    | top 1 by Count desc
    | extend UserDisplayNamesSample = case(
        Pattern != "others", pack("UserDisplayNamePattern", Pattern, "Count", Count),
        dynamic(null)
        )
    | project-away Pattern, Count, Representative
    )
// Optional tags based on the addresses
// | extend SuspiciousAddresses = _SuspiciousAddresses
// | mv-apply SuspiciousIP = SuspiciousAddresses to typeof(string) on (
//     extend Match = ipv6_compare(SuspiciousIP, IPRange,  128 - (32 - ipv4_prefix_mask))
//     | summarize Match = make_set(Match)
//     | extend MaliciousRange = Match has "0"
// )
// | project-away Match, SuspiciousAddresses
// | extend KnownAddresses = _ExpectedIPAddresses
// | mv-apply KnownIP = KnownAddresses to typeof(string) on (
//     extend Match = ipv6_compare(KnownIP, IPRange,  128 - (32 - ipv4_prefix_mask))
//     | summarize Match = make_set(Match)
//     | extend KnownRange = Match has "0"
// )
// | project-away Match, KnownAddresses
// Unpack useful information
// | extend FailureActivity = Activity["FailureEvents"]
// | evaluate bag_unpack(FailureActivity, OutputColumnPrefix="Failure_", ignoredProperties=dynamic(["PartialSuccessResultType", "IPAddresses"]))
// | extend SuccessActivity = Activity["SuccessEvents"]
// | evaluate bag_unpack(SuccessActivity, OutputColumnPrefix="Success_", ignoredProperties=dynamic(["PartialSuccessResultType", "IPAddresses"]))
// Format address range to IPv4 if possible
| extend HexCodes = split(extract(@"^(?i:0+\:0+\:0+\:0+\:0+\:ffff\:([a-f0-9]+\:[a-f0-9]+))$", 1, IPRange), ":")
| extend
    IPRange = case(
        array_length(HexCodes) == 2, format_ipv4_mask(tolong(strcat("0x", tostring(HexCodes[0])))*65536 + tolong(strcat("0x", tostring(HexCodes[1]))), ipv4_prefix_mask),
        IPRange
    ),
    AddressScope = case(
        array_length(HexCodes) == 2, ipv4_prefix_mask,
        128 - (32 - ipv4_prefix_mask)
    )
// Prefill entities
| mv-apply with_itemindex = Index_aux IPAddress = IPAddresses to typeof(string) on (
    extend
        Range = parse_ipv6_mask(IPAddress, 128 - (32 - ipv4_prefix_mask))
    | extend
        RangeHexCodes = split(extract(@"^(?i:0+\:0+\:0+\:0+\:0+\:ffff\:([a-f0-9]+\:[a-f0-9]+))$", 1, Range), ":")
    | extend
        Range = case(
            array_length(RangeHexCodes) == 2, format_ipv4_mask(tolong(strcat("0x", tostring(RangeHexCodes[0])))*65536 + tolong(strcat("0x", tostring(RangeHexCodes[1]))), ipv4_prefix_mask),
            Range
            ),
        RangeScope = case(
            array_length(RangeHexCodes) == 2, ipv4_prefix_mask,
            128 - (32 - ipv4_prefix_mask)
        )
    | extend
        Range = tostring(split(Range, "/", 0))
    | extend
        AddressRange = pack("$id", tostring(Index_aux + 3), "Address", todynamic(Range)[0], "AddressScope", tostring(RangeScope), "Type", "ip"),
        Address = pack("$id", tostring(Index_aux + 3), "Address", tostring(IPAddress), "Type", "ip")
    | project-away Range, RangeHexCodes, RangeScope
    | summarize CorrelationIdEntities = make_list(pack_array(Address))
    )
| mv-apply with_itemindex = Index_aux IPAddress = IPAddresses to typeof(string) on (
    extend Entities = pack("$id", tostring(Index_aux + 3), "Address", tostring(IPAddress), "Type", "ip")
    | summarize IPRangeEntities = make_list(Entities)
    )
| extend IPRangeEntities = array_concat(pack_array(pack("$id", tostring(2), "Address", todynamic(tostring(split(IPRange, "/", 0)))[0], "AddressScope", tostring(AddressScope), "Type", "ip")), IPRangeEntities)
| extend Entities = tostring(array_sort_asc(case(
    isnotempty(IPRange), IPRangeEntities,
    CorrelationIdEntities
    )))
| extend
    Entities = case(
        Stage == "Password Spray", Entities,
        Stage == "Password Spray + Initial Access", "",
        Stage == "Initial Access", "",
        Entities
    ),
    AlertName = case(
        Stage == "Password Spray" and Recurrent, "Slow password spray attack - Recurrent address range",
        Stage == "Password Spray" and not(Recurrent), "Slow password spray attack",
        Stage == "Password Spray + Initial Access", "Slow password spray attack - Potential compromised account",
        Stage == "Initial Access", "Authentication of several accounts from unexpected source",
        "Slow password spray attack"
    ),
    AlertSeverity = case(
        Stage == "Password Spray", "Informational",
        Stage == "Password Spray + Initial Access" and Location in (_ExpectedLocations), "Medium",
        Stage == "Initial Access" and Location in (_ExpectedLocations), "Low",
        "High"
    ),
    BenignAlert = case(
        Stage == "Initial Access" and Recurrent, true,
        Stage == "Password Spray + Initial Access" and Recurrent, true,
        false
    )
| where not(BenignAlert)
// If there are more than 150 different IP ranges doing password spray, Sentinel will only generate 150 alerts (1 alert for each row) and 1 of those alerts will contain the data from several rows. If this "grouped" alert contains the data from more than 10 rows, some Entities data will be lost.
// So this query should try to group the rows and Entities by itself, instead of Sentinel. At least rows that won't generate an incident to be reviewed (e.g. Stage == "Password Spray") should be grouped.
| as hint.materialized=true _Events
| where not(Stage == "Password Spray")
| union (
    _Events
    | where Stage == "Password Spray"
    | summarize Entities = tostring(make_list(todynamic(Entities))) by Stage, AlertName, AlertSeverity, AuxiliarKey = case(isnotempty(CorrelationId), "CorrelationId", "IPRange")
    )
| sort by Stage desc, CorrelationId desc, toint(ASN) asc, toint(extract(@"^(\d+)", 1, IPRange)) asc, toint(extract(@"^\d+\.(\d+)", 1, IPRange)) asc, toint(extract(@"^\d+\.\d+\.(\d+)", 1, IPRange)) asc, toint(extract(@"^\d+\.\d+\.\d+\.(\d+)", 1, IPRange)) asc
| project-away HexCodes, AddressScope, OperatingSystemsCount, AutonomousSystemNumbers, Source, CorrelationIdEntities, IPRangeEntities, AuxiliarKey//, Locations
| project-reorder
    Stage,
    StartTime,
    EndTime,
    MaliciousRange*,
    KnownRange*,
    ResultTypesSample,
    Location,
    ASN,
    IPRange,
    CorrelationId,
    IPAddresses,
    Success_*,
    Failure_*,
    OperatingSystems,
    Browsers,
    //DeviceIds,
    DeviceNamesSample,
    DeviceIsManagedSample,
    DeviceTrustTypesSample,
    UserDisplayNamesSample,
    Activity,
    AlertName,
    AlertSeverity,
    Entities
//};
//Function(query_frequency, query_period)

Explanation

This query helps detect slow password spray events by analyzing authentication events over a specified time period. It identifies patterns of multiple account logins from the same IP address or correlation ID, indicating a potential attack. The query considers various factors like IP addresses, locations, ASNs, result types, devices, and more to determine the likelihood of a password spray attack. It generates alerts with information about the attack stage, severity, and entities involved.

Details

Jose Sebastián Canós profile picture

Jose Sebastián Canós

Released: November 14, 2023

Tables

SigninLogsAADNonInteractiveUserSignInLogsADFSSignInLogs_GetWatchlist_SampleEvents

Keywords

Devices,Intune,User

Operators

unionisfuzzywheresummarizehint.shufflekeybyextendmake_listmake_setdcountmv-applynextproject-awaybincounttake_anyifftorealtointlog2array_lengthbag_keysarray_concatpackmake_bagtodynamicdynamic_to_jsontodatetimesortprevextractstrcatreducetopipv6_comparearray_sort_ascmake_listmake_setarray_concatarray_lengtharray_sort_ascproject-awayproject-reorder

Actions