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 Access

Telemetry coverage

Rule body

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
  | sort by UserPrincipalName asc, TimeGenerated asc
  | serialize
  | 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.3
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: sort

| sort by UserPrincipalName asc, TimeGenerated asc

Stage 8: kusto:serialize

| serialize

Stage 9: 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 10: extend

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

Stage 11: 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 12: where

| where Anomalies > 0

Stage 13: summarize

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

Stage 14: 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 15: project-away

| project-away UserDisplayName1, UserPrincipalName1

Stage 16: extend

| extend IPAddressFirst = tostring(IPAddresses[0])

Stage 17: extend

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

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
Anomaliessummarize
Baselinesummarize
FailureToSuccessDiffsummarize
Scoresummarize
UserDisplayNamesummarize
UserPrincipalNamesummarize
IPAddressFirstextend
Nameextend
UPNSuffixextend