Detection rules › Kusto

Authentication Attempt from New Country

Severity
medium
Time window
14d
Group by
Category, Location, UserPrincipalName
Author
Microsoft Security Research
Source
github.com/Azure/Azure-Sentinel

Detects when there is a login attempt from a country that has not seen a successful login in the previous 14 days. Threat actors may attempt to authenticate with credentials from compromised accounts - monitoring attempts from anomalous locations may help identify these attempts. Authentication attempts should be investigated to ensure the activity was legitimate and if there is other similar activity. Ref: https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-user-accounts#monitoring-for-failed-unusual-sign-ins

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1078.004 Valid Accounts: Cloud Accounts

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

id: ef895ada-e8e8-4cf0-9313-b1ab67fab69f
name: Authentication Attempt from New Country
description: |
  Detects when there is a login attempt from a country that has not seen a successful login in the previous 14 days.
  Threat actors may attempt to authenticate with credentials from compromised accounts - monitoring attempts from anomalous locations may help identify these attempts.
  Authentication attempts should be investigated to ensure the activity was legitimate and if there is other similar activity.
  Ref: https://docs.microsoft.com/azure/active-directory/fundamentals/security-operations-user-accounts#monitoring-for-failed-unusual-sign-ins
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
      - AADNonInteractiveUserSignInLogs
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - InitialAccess
relevantTechniques:
  - T1078.004
tags:
  - AADSecOpsGuide
query: |
  let CombinedSignInLogs = union isfuzzy=True AADNonInteractiveUserSignInLogs, SigninLogs;
    // Combine AADNonInteractiveUserSignInLogs and SigninLogs into a single table
    // Fetch Azure IP address ranges data from a JSON file hosted on GitHub
    let AzureRanges = externaldata(changeNumber: string, cloud: string, values: dynamic)
    ["https://raw.githubusercontent.com/microsoft/mstic/master/PublicFeeds/MSFTIPRanges/ServiceTags_Public.json"] with(format='multijson')
    // Load Azure IP address ranges from the JSON file hosted on GitHub
    | mv-expand values
    // Expand the values column into separate rows
    | extend Name = values.name, AddressPrefixes = tostring(values.properties.addressPrefixes);
    // Create additional columns for the name and address prefixes
    // Identify known locations to be excluded from analysis
    let ExcludedKnownLocations = CombinedSignInLogs
    // Filter the combined logs based on the specified time range
    | where TimeGenerated between (ago(14d)..ago(1d))
    // Filter by specific ResultType
    | where ResultType == 0
    // Summarize the logs by location
    | summarize by Location;
    // Find sign-in locations matching specific criteria
    let MatchedLocations = materialize(CombinedSignInLogs
    // Filter the combined logs based on the specified time range
    | where TimeGenerated > ago(1d)
    // Exclude specific ResultTypes
    | where ResultType !in (50126, 50053, 50074, 70044)
    // Exclude known locations
    | where Location !in (ExcludedKnownLocations));
    // Match IP addresses of matched locations with Azure IP address ranges
    let MatchedIPs = MatchedLocations
    // Use the 'ipv4_lookup' function to match IP addresses with Azure IP address ranges
    | evaluate ipv4_lookup(AzureRanges, IPAddress, AddressPrefixes)
    // Project only the IPAddress column
    | project IPAddress;
    // Exclude IP addresses that are already matched with Azure IP address ranges
    let MaxSetSize = 5; // Set the maximum size limit for make_set
    let ExcludedIPs = MatchedLocations
    // Filter out IP addresses that are already matched
    | where not (IPAddress in (MatchedIPs))
    // Exclude empty or null Location values
    | where isnotempty(Location)
    // Handle dynamic and string column values for LocationDetails and DeviceDetail
    | extend LocationDetails_dynamic = column_ifexists("LocationDetails_dynamic", "")
    | extend DeviceDetail_dynamic = column_ifexists("DeviceDetail_dynamic", "")
    | extend LocationDetails = iif(isnotempty(LocationDetails_dynamic), LocationDetails_dynamic, parse_json(LocationDetails_string))
    | extend DeviceDetail = iif(isnotempty(DeviceDetail_dynamic), DeviceDetail_dynamic, parse_json(DeviceDetail_string))
    // Extract location details (city and state)
    | extend City = tostring(LocationDetails.city)
    | extend State = tostring(LocationDetails.state)
    | extend Place = strcat(City, " - ", State)
    | extend DeviceId = tostring(DeviceDetail.deviceId)
    | extend Result = strcat(tostring(ResultType), " - ", ResultDescription)
    // Summarize the data based on UserPrincipalName, Location, and Category
    | summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated),
    make_set(Result, MaxSetSize), make_set(IPAddress, MaxSetSize),
    make_set(UserAgent, MaxSetSize), make_set(Place, MaxSetSize),
    make_set(DeviceId, MaxSetSize) by UserPrincipalName, Location, Category
    // Extract the username prefix and suffix from UserPrincipalName
    | extend Name = tostring(split(UserPrincipalName,'@',0)[0]), UPNSuffix = tostring(split(UserPrincipalName,'@',1)[0]);
    ExcludedIPs // Output the final result set
    | extend IP = set_IPAddress[0]
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
      - identifier: Name
        columnName: Name
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IP
version: 1.1.2
kind: Scheduled
metadata:
  source:
    kind: Community
  author:
    name: Microsoft Security Research
  support:
    tier: Community
  categories:
    domains: [ "Security - Others" ]

Stages and Predicates

Parameters

let CombinedSignInLogs = union isfuzzy=True AADNonInteractiveUserSignInLogs, SigninLogs;
let MaxSetSize = 5;

Let binding: AzureRanges

let AzureRanges = externaldata(changeNumber: string, cloud: string, values: dynamic)
  ["https://raw.githubusercontent.com/microsoft/mstic/master/PublicFeeds/MSFTIPRanges/ServiceTags_Public.json"] with(format='multijson')
  | mv-expand values
  | extend Name = values.name, AddressPrefixes = tostring(values.properties.addressPrefixes);

Let binding: ExcludedKnownLocations

let ExcludedKnownLocations = CombinedSignInLogs
  | where TimeGenerated between (ago(14d)..ago(1d))
  | where ResultType == 0
  | summarize by Location;

Derived from CombinedSignInLogs.

Let binding: MatchedIPs

let MatchedIPs = MatchedLocations
  | evaluate ipv4_lookup(AzureRanges, IPAddress, AddressPrefixes)
  | project IPAddress;

Derived from AzureRanges, MatchedLocations.

The stages below define let ExcludedIPs (the rule's main pipeline source).

Stage 1: source

CombinedSignInLogs

Stage 2: where

| where TimeGenerated > ago(1d)

Stage 3: where

| where ResultType !in (50126, 50053, 50074, 70044)

Stage 4: where

| where Location !in (ExcludedKnownLocations)

References ExcludedKnownLocations (defined above).

Stage 5: where

| where not (IPAddress in (MatchedIPs))

References MatchedIPs (defined above).

Stage 6: where

| where isnotempty(Location)

Stage 7: extend (9 consecutive steps)

| extend LocationDetails_dynamic = column_ifexists("LocationDetails_dynamic", "")
| extend DeviceDetail_dynamic = column_ifexists("DeviceDetail_dynamic", "")
| extend LocationDetails = iif(isnotempty(LocationDetails_dynamic), LocationDetails_dynamic, parse_json(LocationDetails_string))
| extend DeviceDetail = iif(isnotempty(DeviceDetail_dynamic), DeviceDetail_dynamic, parse_json(DeviceDetail_string))
| extend City = tostring(LocationDetails.city)
| extend State = tostring(LocationDetails.state)
| extend Place = strcat(City, " - ", State)
| extend DeviceId = tostring(DeviceDetail.deviceId)
| extend Result = strcat(tostring(ResultType), " - ", ResultDescription)

Stage 8: summarize

| summarize FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated),
  make_set(Result, MaxSetSize), make_set(IPAddress, MaxSetSize),
  make_set(UserAgent, MaxSetSize), make_set(Place, MaxSetSize),
  make_set(DeviceId, MaxSetSize) by UserPrincipalName, Location, Category

Stage 9: extend

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

The stages below run on ExcludedIPs (the outer pipeline).

Stage 10: extend

ExcludedIPs
| extend IP = set_IPAddress[0]

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
ResultTypein50053, 50074, 50126, 70044
LocationeqExcludedKnownLocations
IPAddresseqMatchedIPs

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
Locationis_not_null
  • (no value, null check)

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
Categorysummarize
FirstSeensummarize
LastSeensummarize
Locationsummarize
UserPrincipalNamesummarize
Nameextend
UPNSuffixextend
IPextend