Detection rules › Kusto

Brute force attack against an Entra-authenticated Windows device

Status
available
Severity
medium
Time window
20m
Group by
Anomalies, Baseline, FailureToSuccessDiff, Score, UserDisplayName, UserPrincipalName
Source
github.com/Azure/Azure-Sentinel

'Identifies evidence of brute force activity against Windows devices authenticated via Entra ID (including Entra-joined, hybrid-joined, and Windows 365 Cloud PCs) by detecting multiple authentication failures followed by a successful authentication within a defined time window.'

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1110 Brute Force

Rule body kusto

id: 3fbc20a4-04c4-464e-8fcb-6667f53e4987
name: Brute force attack against an Entra-authenticated Windows device
description: |
  'Identifies evidence of brute force activity against Windows devices authenticated via Entra ID (including Entra-joined, hybrid-joined, and Windows 365 Cloud PCs) by detecting multiple authentication failures followed by a successful authentication within a defined time window.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110
query: |
  let authenticationWindow = 20m;
  let sensitivity = 2.5;
  SigninLogs
  | where AppDisplayName =~ "Windows Sign In"
  | extend FailureOrSuccess = iff(ResultType in ("0", "50125", "50140", "70043", "70044"), "Success", "Failure")
  | summarize FailureCount = countif(FailureOrSuccess=="Failure"), SuccessCount = countif(FailureOrSuccess=="Success"), IPAddresses = make_set(IPAddress,1000)
                            by bin(TimeGenerated, authenticationWindow), UserDisplayName, UserPrincipalName
  | extend FailureSuccessDiff = FailureCount - SuccessCount
  | where FailureSuccessDiff > 0
  | summarize Diff = make_list(FailureSuccessDiff, 10000), TimeStamp = make_list(TimeGenerated, 10000) by UserDisplayName, UserPrincipalName//, tostring(IPAddresses)
  | extend (Anomalies, Score, Baseline) = series_decompose_anomalies(Diff, sensitivity, -1, 'linefit')  
  | mv-expand Diff to typeof(double), TimeStamp to typeof(datetime), Anomalies to typeof(double), Score to typeof(double), Baseline to typeof(long)
  | where Anomalies > 0
  | summarize by UserDisplayName, UserPrincipalName, Anomalies, Score, Baseline, FailureToSuccessDiff = Diff
  | join kind=leftouter (
        SigninLogs
        | where AppDisplayName =~ "Windows Sign In"
        | extend OS = DeviceDetail.operatingSystem, Browser = DeviceDetail.browser
        | extend StatusCode = tostring(Status.errorCode), StatusDetails = tostring(Status.additionalDetails)
        | extend State = tostring(LocationDetails.state), City = tostring(LocationDetails.city)
        | summarize StartTime = min(TimeGenerated), 
                    EndTime = max(TimeGenerated), 
                    IPAddresses = make_set(IPAddress,100), 
                    OS = make_set(OS,20), 
                    Browser = make_set(Browser,20), 
                    City = make_set(City,100), 
                    ResultType = make_set(ResultType,100)
                by UserDisplayName, UserPrincipalName, UserId, AppDisplayName
    ) on UserDisplayName, UserPrincipalName
  | project-away UserDisplayName1, UserPrincipalName1
  | extend IPAddressFirst = tostring(IPAddresses[0])
  | extend Name = tostring(split(UserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName,'@',1)[0])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
      - identifier: Name
        columnName: Name
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: UserId
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPAddressFirst    
version: 2.0.2
kind: Scheduled

Stages and Predicates

Parameters

let authenticationWindow = 20m;
let sensitivity = 2.5;

Stage 1: source

SigninLogs

Stage 2: where

| where AppDisplayName =~ "Windows Sign In"

Stage 3: extend

| extend FailureOrSuccess = iff(ResultType in ("0", "50125", "50140", "70043", "70044"), "Success", "Failure")
FailureOrSuccess =
ifResultType in (0, 50125, 50140, 70043, 70044)"Success"
else"Failure"

Stage 4: summarize

| summarize FailureCount = countif(FailureOrSuccess=="Failure"), SuccessCount = countif(FailureOrSuccess=="Success"), IPAddresses = make_set(IPAddress,1000)
                          by bin(TimeGenerated, authenticationWindow), UserDisplayName, UserPrincipalName

Stage 5: extend

| extend FailureSuccessDiff = FailureCount - SuccessCount

Stage 6: where

| where FailureSuccessDiff > 0

Stage 7: summarize

| summarize Diff = make_list(FailureSuccessDiff, 10000), TimeStamp = make_list(TimeGenerated, 10000) by UserDisplayName, UserPrincipalName

The stages below score time-series anomalies (make-series, series_decompose_anomalies).

Stage 8: extend

| extend (Anomalies, Score, Baseline) = series_decompose_anomalies(Diff, sensitivity, -1, 'linefit')

Stage 9: mv-expand

| mv-expand Diff to typeof(double), TimeStamp to typeof(datetime), Anomalies to typeof(double), Score to typeof(double), Baseline to typeof(long)

Stage 10: where

| where Anomalies > 0

Stage 11: summarize

| summarize by UserDisplayName, UserPrincipalName, Anomalies, Score, Baseline, FailureToSuccessDiff = Diff

Stage 12: join

| join kind=leftouter (
      SigninLogs
      | where AppDisplayName =~ "Windows Sign In"
      | extend OS = DeviceDetail.operatingSystem, Browser = DeviceDetail.browser
      | extend StatusCode = tostring(Status.errorCode), StatusDetails = tostring(Status.additionalDetails)
      | extend State = tostring(LocationDetails.state), City = tostring(LocationDetails.city)
      | summarize StartTime = min(TimeGenerated), 
                  EndTime = max(TimeGenerated), 
                  IPAddresses = make_set(IPAddress,100), 
                  OS = make_set(OS,20), 
                  Browser = make_set(Browser,20), 
                  City = make_set(City,100), 
                  ResultType = make_set(ResultType,100)
              by UserDisplayName, UserPrincipalName, UserId, AppDisplayName
  ) on UserDisplayName, UserPrincipalName

Stage 13: project-away

| project-away UserDisplayName1, UserPrincipalName1

Stage 14: extend

| extend IPAddressFirst = tostring(IPAddresses[0])

Stage 15: extend

| extend Name = tostring(split(UserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName,'@',1)[0])

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Anomaliesgt
  • 0 transforms: cased
AppDisplayNameeq
  • Windows Sign In
FailureSuccessDiffgt
  • 0 transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
Anomaliessummarize
Baselinesummarize
FailureToSuccessDiffsummarize
Scoresummarize
UserDisplayNamesummarize
UserPrincipalNamesummarize
IPAddressFirstextend
Nameextend
UPNSuffixextend