Detection rules › Kusto

Brute force attack against Azure Portal

Status
available
Severity
medium
Time window
7d
Source
github.com/Azure/Azure-Sentinel

Detects Azure Portal brute force attacks by monitoring for multiple authentication failures and a successful login within a 20-minute window. Default settings: 10 failures, 25 deviations. Ref: https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-sign-ins-error-codes.

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1110 Brute Force

Rule body kusto

id: 28b42356-45af-40a6-a0b4-a554cdfd5d8a
name: Brute force attack against Azure Portal
description: |
  Detects Azure Portal brute force attacks by monitoring for multiple authentication failures and a successful login within a 20-minute window. Default settings: 10 failures, 25 deviations.
  Ref: https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-sign-ins-error-codes.
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AADNonInteractiveUserSignInLogs
queryFrequency: 1d
queryPeriod: 7d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110
query: |
  // Set threshold value for deviation
  let threshold = 25;
  // Set the time range for the query
  let timeRange = 24h;
  // Set the authentication window duration
  let authenticationWindow = 20m;
  // Define a reusable function 'aadFunc' that takes a table name as input
  let aadFunc = (tableName: string) {
    // Query the specified table
    table(tableName)
    // Filter data within the last 24 hours
    | where TimeGenerated > ago(1d)
    // Filter records related to "Azure Portal" applications
    | where AppDisplayName has "Azure Portal"
    // Extract and transform some fields
    | extend
        DeviceDetail = todynamic(DeviceDetail),
        LocationDetails = todynamic(LocationDetails)
    | extend
        OS = tostring(DeviceDetail.operatingSystem),
        Browser = tostring(DeviceDetail.browser),
        State = tostring(LocationDetails.state),
        City = tostring(LocationDetails.city),
        Region = tostring(LocationDetails.countryOrRegion)
    // Categorize records as Success or Failure based on ResultType
    | extend FailureOrSuccess = iff(ResultType in ("0", "50125", "50140", "70043", "70044"), "Success", "Failure")
    // Sort and identify sessions
    | sort by UserPrincipalName asc, TimeGenerated asc
    | extend SessionStartedUtc = row_window_session(TimeGenerated, timeRange, authenticationWindow, UserPrincipalName != prev(UserPrincipalName) or prev(FailureOrSuccess) == "Success")
    // Summarize data
    | summarize FailureOrSuccessCount = count() by  FailureOrSuccess, UserId, UserDisplayName, AppDisplayName, IPAddress, Browser, OS, State, City, Region, Type, CorrelationId, bin(TimeGenerated, authenticationWindow), ResultType, UserPrincipalName, SessionStartedUtc
    | summarize FailureCountBeforeSuccess = sumif(FailureOrSuccessCount, FailureOrSuccess == "Failure"), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), makelist(FailureOrSuccess), IPAddress = make_set(IPAddress, 15), make_set(Browser, 15), make_set(City, 15), make_set(State, 15), make_set(Region, 15), make_set(ResultType, 15) by SessionStartedUtc, UserPrincipalName, CorrelationId, AppDisplayName, UserId, Type
    // Filter records where "Success" occurs in the middle of a session
    | where array_index_of(list_FailureOrSuccess, "Success") != 0
    | where array_index_of(list_FailureOrSuccess, "Success") == array_length(list_FailureOrSuccess) - 1
    // Remove unnecessary columns from the output
    | project-away SessionStartedUtc, list_FailureOrSuccess
    // Join with another table and calculate deviation
    | join kind=inner (
        table(tableName)
        | where TimeGenerated > ago(7d)
        | where AppDisplayName has "Azure Portal"
        | extend FailureOrSuccess = iff(ResultType in ("0", "50125", "50140", "70043", "70044"), "Success", "Failure")
        | summarize avgFailures = avg(todouble(FailureOrSuccess == "Failure")) by UserPrincipalName
    ) on UserPrincipalName
    | extend Deviation = abs(FailureCountBeforeSuccess - avgFailures) / avgFailures
    // Filter records based on deviation and failure count criteria
    | where Deviation > threshold and FailureCountBeforeSuccess >= 10
    // Expand the IPAddress array
    | mv-expand IPAddress
    | extend IPAddress = tostring(IPAddress)
    | extend timestamp = StartTime
  };
  // Call 'aadFunc' with different table names and union the results
  let aadSignin = aadFunc("SigninLogs");
  let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");
  union isfuzzy=true aadSignin, aadNonInt
  // Additional transformation - Split UserPrincipalName
  | 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: IPAddress
version: 2.1.4
kind: Scheduled

Stages and Predicates

Parameters

let threshold = 25;
let timeRange = 24h;
let authenticationWindow = 20m;
let aadSignin = aadFunc("SigninLogs");
let aadNonInt = aadFunc("AADNonInteractiveUserSignInLogs");

Let binding: aadFunc

let aadFunc = (tableName: string) {
  table(tableName)
  | where TimeGenerated > ago(1d)
  | where AppDisplayName has "Azure Portal"
  | extend
      DeviceDetail = todynamic(DeviceDetail),
      LocationDetails = todynamic(LocationDetails)
  | extend
      OS = tostring(DeviceDetail.operatingSystem),
      Browser = tostring(DeviceDetail.browser),
      State = tostring(LocationDetails.state),
      City = tostring(LocationDetails.city),
      Region = tostring(LocationDetails.countryOrRegion)
  | extend FailureOrSuccess = iff(ResultType in ("0", "50125", "50140", "70043", "70044"), "Success", "Failure")
  | sort by UserPrincipalName asc, TimeGenerated asc
  | extend SessionStartedUtc = row_window_session(TimeGenerated, timeRange, authenticationWindow, UserPrincipalName != prev(UserPrincipalName) or prev(FailureOrSuccess) == "Success")
  | summarize FailureOrSuccessCount = count() by  FailureOrSuccess, UserId, UserDisplayName, AppDisplayName, IPAddress, Browser, OS, State, City, Region, Type, CorrelationId, bin(TimeGenerated, authenticationWindow), ResultType, UserPrincipalName, SessionStartedUtc
  | summarize FailureCountBeforeSuccess = sumif(FailureOrSuccessCount, FailureOrSuccess == "Failure"), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), makelist(FailureOrSuccess), IPAddress = make_set(IPAddress, 15), make_set(Browser, 15), make_set(City, 15), make_set(State, 15), make_set(Region, 15), make_set(ResultType, 15) by SessionStartedUtc, UserPrincipalName, CorrelationId, AppDisplayName, UserId, Type
  | where array_index_of(list_FailureOrSuccess, "Success") != 0
  | where array_index_of(list_FailureOrSuccess, "Success") == array_length(list_FailureOrSuccess) - 1
  | project-away SessionStartedUtc, list_FailureOrSuccess
  | join kind=inner (
      table(tableName)
      | where TimeGenerated > ago(7d)
      | where AppDisplayName has "Azure Portal"
      | extend FailureOrSuccess = iff(ResultType in ("0", "50125", "50140", "70043", "70044"), "Success", "Failure")
      | summarize avgFailures = avg(todouble(FailureOrSuccess == "Failure")) by UserPrincipalName
  ) on UserPrincipalName
  | extend Deviation = abs(FailureCountBeforeSuccess - avgFailures) / avgFailures
  | where Deviation > threshold and FailureCountBeforeSuccess >= 10
  | mv-expand IPAddress
  | extend IPAddress = tostring(IPAddress)
  | extend timestamp = StartTime
};

Derived from threshold, timeRange, authenticationWindow.

union isfuzzy=true (2 sources)

Each leg below queries one source; the rule matches if any leg does. Sources: aadSignin, aadNonInt

Leg 1: aadSignin

Leg 2: aadNonInt

Applied to the combined result

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

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
Nameextend
UPNSuffixextend