Detection rules › Kusto

New country signIn with correct password

Severity
medium
Time window
7d
Group by
AuthSucceeded, PasswordResult, UserPrincipalName, failureReason
Author
Juanse
Source
github.com/Azure/Azure-Sentinel

'Identifies an interrupted sign-in session from a country the user has not sign-in before in the last 7 days, where the password was correct. Although the session is interrupted by other controls such as multi factor authentication or conditional access policies, the user credentials should be reset due to logs indicating a correct password was observed during sign-in.'

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1078 Valid Accounts
Credential AccessT1110 Brute Force

Rules detecting the same action

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

Rule body kusto

id: 7808c05a-3afd-4d13-998a-a59e2297693f
name: New country signIn with correct password
description: |
  'Identifies an interrupted sign-in session from a country the user has not sign-in before in the last 7 days, where the password was correct. Although the session is interrupted by other controls such as multi factor authentication or conditional access policies, the user credentials should be reset due to logs indicating a correct password was observed during sign-in.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs 
queryFrequency: 1d
queryPeriod: 7d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - InitialAccess
  - CredentialAccess
relevantTechniques:
  - T1078
  - T1110
query: |
  // Creating a list of successful sign-in by users in the last 7 days.
  let KnownUserCountry = (
  SigninLogs
  | where TimeGenerated between (ago(7d) .. ago(1d) ) 
  | where ResultType == 0
  | summarize KnownCountry = make_set(Location,1048576) by UserPrincipalName
  );
  // Identify sign-ins that are no successful but have the auth details indicating a correct password.
  SigninLogs
  | where TimeGenerated >= ago(1d)
  | where ResultType != 0
  | extend ParseAuth = parse_json(AuthenticationDetails)
  | extend AuthMethod = tostring(ParseAuth.[0].authenticationMethod),
      PasswordResult = tostring(ParseAuth.[0].authenticationStepResultDetail),
      AuthSucceeded = tostring(ParseAuth.[0].succeeded)
  | where PasswordResult == "Correct Password" or AuthSucceeded == "true"
  | where AuthMethod == "Password"
  | extend failureReason = tostring(Status.failureReason)
  | summarize NewCountry = make_set(Location,1048576), LastObservedTime = max(TimeGenerated), AppName = make_set(AppDisplayName,1048576) by UserPrincipalName, PasswordResult, AuthSucceeded, failureReason
  // Combining both tables by user
  | join kind=inner KnownUserCountry on UserPrincipalName
  // Compare both arrays and identify if the country has been observed in the past.
  | extend CountryDiff = set_difference(NewCountry,KnownCountry)
  | extend CountryDiffCount = array_length(CountryDiff)
  // Count the new column to only alert if there is a difference between both arrays
  | where CountryDiffCount != 0
  | extend NewCountryEvent = CountryDiff
  // Getting UserName and Domain
  | extend Name = split(UserPrincipalName,"@",0),
      Domain = split(UserPrincipalName,"@",1)
  | mv-expand Name,Domain
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
      - identifier: Name
        columnName: Name
      - identifier: NTDomain
        columnName: Domain     
customDetails:
  LastObservedTime: LastObservedTime
  AppName: AppName
  NewCountryEvent: NewCountryEvent
  PasswordResult: PasswordResult
  AuthSucceeded: AuthSucceeded
  failureReason: failureReason
eventGroupingSettings:
  aggregationKind: SingleAlert
version: 1.0.2
kind: Scheduled
metadata:
    source:
        kind: Community
    author:
        name: Juanse
    support:
        tier: Community
    categories:
        domains: [ "Identity", "Security - Threat Protection" ]

Stages and Predicates

Let binding: KnownUserCountry

let KnownUserCountry = (
SigninLogs
| where TimeGenerated between (ago(7d) .. ago(1d) ) 
| where ResultType == 0
| summarize KnownCountry = make_set(Location,1048576) by UserPrincipalName
);

Stage 1: source

SigninLogs

Stage 2: where

| where TimeGenerated >= ago(1d)

Stage 3: where

| where ResultType != 0

Stage 4: extend

| extend ParseAuth = parse_json(AuthenticationDetails)

Stage 5: extend

| extend AuthMethod = tostring(ParseAuth.[0].authenticationMethod),
    PasswordResult = tostring(ParseAuth.[0].authenticationStepResultDetail),
    AuthSucceeded = tostring(ParseAuth.[0].succeeded)

Stage 6: where

| where PasswordResult == "Correct Password" or AuthSucceeded == "true"

Stage 7: where

| where AuthMethod == "Password"

Stage 8: extend

| extend failureReason = tostring(Status.failureReason)

Stage 9: summarize

| summarize NewCountry = make_set(Location,1048576), LastObservedTime = max(TimeGenerated), AppName = make_set(AppDisplayName,1048576) by UserPrincipalName, PasswordResult, AuthSucceeded, failureReason

Stage 10: join

| join kind=inner KnownUserCountry on UserPrincipalName

Stage 11: extend

| extend CountryDiff = set_difference(NewCountry,KnownCountry)

Stage 12: extend

| extend CountryDiffCount = array_length(CountryDiff)

Stage 13: where

| where CountryDiffCount != 0

Stage 14: extend

| extend NewCountryEvent = CountryDiff

Stage 15: extend

| extend Name = split(UserPrincipalName,"@",0),
    Domain = split(UserPrincipalName,"@",1)

Stage 16: mv-expand

| mv-expand Name,Domain

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
AuthMethodeq
  • Password transforms: cased
AuthSucceededeq
  • true transforms: cased
CountryDiffCountne
  • 0 transforms: cased
PasswordResulteq
  • Correct Password transforms: cased
ResultTypeeq
  • 0 transforms: cased
ResultTypene
  • 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
AppNamesummarize
AuthSucceededsummarize
LastObservedTimesummarize
NewCountrysummarize
PasswordResultsummarize
UserPrincipalNamesummarize
failureReasonsummarize
CountryDiffextend
CountryDiffCountextend
NewCountryEventextend
Domainextend
Nameextend