Detection rules › Kusto

Dataverse - Unusual sign-in following disabled IP address-based cookie binding protection

Status
available
Severity
medium
Time window
14d
Group by
ClientIp, CookieBindingDisabled, CookieBindingDisabledBy, InstanceUrl, LatestEvent, UserAgent, UserId
Source
github.com/Azure/Azure-Sentinel

Identifies previously unseen IP and user agents in a Dataverse instance following disabling of cookie binding protection. See https://docs.microsoft.com/power-platform/admin/block-cookie-replay-attack

MITRE ATT&CK coverage

TacticTechniques
Defense EvasionT1629 Impair Defenses

Rule body kusto

id: d7c9549c-7246-4555-8e53-d7b0db546764
kind: Scheduled
name: Dataverse - Unusual sign-in following disabled IP address-based cookie binding
  protection
description: Identifies previously unseen IP and user agents in a Dataverse instance
  following disabling of cookie binding protection. See https://docs.microsoft.com/power-platform/admin/block-cookie-replay-attack
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: Dataverse
    dataTypes:
      - DataverseActivity
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - DefenseEvasion
relevantTechniques:
  - T1629
query: |
  let query_frequency = 1h;
  let query_lookback = 14d;
  let cookie_lifetime = 24h;
  let cookie_binding_disabled_events = DataverseActivity
      | where TimeGenerated >= ago(query_lookback)
      | where Message == "Update" and EntityName == "organization"
      | mv-expand Fields
      | where Fields.Name == "enableipbasedcookiebinding" and Fields.Value == 'False'
      | summarize CookieBindingDisabled = min(TimeGenerated) by CookieBindingDisabledBy = UserId, InstanceUrl;
  let current_activity = cookie_binding_disabled_events
      | join kind=inner(DataverseActivity
          | where UserId !endswith "@onmicrosoft.com" and UserId !endswith "@microsoft.com"
          | where isnotempty(ClientIp) and isnotempty(UserAgent)
          | where TimeGenerated >= ago(query_frequency + cookie_lifetime)
          | summarize LatestEvent = arg_max(TimeGenerated, *) by UserId, ClientIp, InstanceUrl)
          on InstanceUrl;
  let users_switched_ip = current_activity
      | summarize IPCount = count() by UserId, InstanceUrl
      | where IPCount > 1
      | join kind=inner (current_activity) on UserId, InstanceUrl
      | summarize arg_max(LatestEvent, *) by UserId, InstanceUrl;
  users_switched_ip
  | join kind = inner (DataverseActivity
      | where TimeGenerated >= ago (query_lookback)
      | where UserId !endswith "@onmicrosoft.com" and UserId !endswith "@microsoft.com"
      | where isnotempty(ClientIp) and isnotempty(UserAgent)
      | project-rename
          HistoricalTime = TimeGenerated,
          HistoricalIP = ClientIp,
          HistoricalAgent = UserAgent)
      on UserId, InstanceUrl
  | where HistoricalTime >= ago(query_lookback) and HistoricalTime < LatestEvent
  | summarize
      HistoricalIPs = make_set(HistoricalIP, 100),
      HistoricalAgents = make_set(HistoricalAgent, 100)
      by
      UserId,
      UserAgent,
      ClientIp,
      InstanceUrl,
      LatestEvent,
      CookieBindingDisabled,
      CookieBindingDisabledBy
  | where (HistoricalIPs !has ClientIp) and (HistoricalAgents !has UserAgent)
  | extend
      CloudAppId = int(32780),
      AccountName = tostring(split(UserId, '@')[0]),
      UPNSuffix = tostring(split(UserId, '@')[1])
  | project
      LatestEvent,
      UserId,
      ClientIp,
      UserAgent,
      InstanceUrl,
      HistoricalIPs,
      HistoricalAgents,
      CookieBindingDisabled,
      CookieBindingDisabledBy,
      AccountName,
      UPNSuffix,
      CloudAppId
eventGroupingSettings:
  aggregationKind: AlertPerResult
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: ClientIp
  - entityType: CloudApplication
    fieldMappings:
      - identifier: AppId
        columnName: CloudAppId
      - identifier: InstanceName
        columnName: InstanceUrl
alertDetailsOverride:
  alertDisplayNameFormat: Dataverse - Unusual sign-in after IP address-based cookie
    binding disabled
  alertDescriptionFormat: IP address-based cookie binding was disabled by in {{InstanceUrl}}.
    Following this, sign-in events from new IP {{ClientIp}}  for {{UserId}} were detected.
customDetails: {}
version: 3.2.0

Stages and Predicates

Parameters

let query_frequency = 1h;
let query_lookback = 14d;
let cookie_lifetime = 24h;

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

Stage 1: source

DataverseActivity

Stage 2: where

| where TimeGenerated >= ago(query_lookback)

Stage 3: where

| where Message == "Update" and EntityName == "organization"

Stage 4: mv-expand

| mv-expand Fields

Stage 5: where

| where Fields.Name == "enableipbasedcookiebinding" and Fields.Value == 'False'

Stage 6: summarize

| summarize CookieBindingDisabled = min(TimeGenerated) by CookieBindingDisabledBy = UserId, InstanceUrl

Stage 7: join

| join kind=inner(DataverseActivity
        | where UserId !endswith "@onmicrosoft.com" and UserId !endswith "@microsoft.com"
        | where isnotempty(ClientIp) and isnotempty(UserAgent)
        | where TimeGenerated >= ago(query_frequency + cookie_lifetime)
        | summarize LatestEvent = arg_max(TimeGenerated, *) by UserId, ClientIp, InstanceUrl)
        on InstanceUrl

Stage 8: summarize

| summarize IPCount = count() by UserId, InstanceUrl
Threshold
gt 1

Stage 9: where

| where IPCount > 1

Stage 10: join

| join kind=inner (current_activity) on UserId, InstanceUrl

Stage 11: summarize

| summarize arg_max(LatestEvent, *) by UserId, InstanceUrl

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

Stage 12: join

users_switched_ip
| join kind = inner (DataverseActivity
    | where TimeGenerated >= ago (query_lookback)
    | where UserId !endswith "@onmicrosoft.com" and UserId !endswith "@microsoft.com"
    | where isnotempty(ClientIp) and isnotempty(UserAgent)
    | project-rename
        HistoricalTime = TimeGenerated,
        HistoricalIP = ClientIp,
        HistoricalAgent = UserAgent)
    on UserId, InstanceUrl

Stage 13: where

| where HistoricalTime >= ago(query_lookback) and HistoricalTime < LatestEvent

Stage 14: summarize

| summarize
    HistoricalIPs = make_set(HistoricalIP, 100),
    HistoricalAgents = make_set(HistoricalAgent, 100)
    by
    UserId,
    UserAgent,
    ClientIp,
    InstanceUrl,
    LatestEvent,
    CookieBindingDisabled,
    CookieBindingDisabledBy

Stage 15: where

| where (HistoricalIPs !has ClientIp) and (HistoricalAgents !has UserAgent)

Stage 16: extend

| extend
    CloudAppId = int(32780),
    AccountName = tostring(split(UserId, '@')[0]),
    UPNSuffix = tostring(split(UserId, '@')[1])

Stage 17: project

| project
    LatestEvent,
    UserId,
    ClientIp,
    UserAgent,
    InstanceUrl,
    HistoricalIPs,
    HistoricalAgents,
    CookieBindingDisabled,
    CookieBindingDisabledBy,
    AccountName,
    UPNSuffix,
    CloudAppId

Exclusions

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

FieldKindExcluded values
UserIdends_with@microsoft.com
UserIdends_with@onmicrosoft.com
UserIdends_with@microsoft.com
UserIdends_with@onmicrosoft.com
UserIdends_with@microsoft.com
UserIdends_with@onmicrosoft.com
HistoricalAgentsmatchUserAgent
HistoricalIPsmatchClientIp

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
ClientIpis_not_null
  • (no value, null check)
EntityNameeq
  • organization transforms: cased corpus 4 (kusto 4)
HistoricalTimelt
  • LatestEvent transforms: cased
IPCountgt
  • 1 transforms: cased
Messageeq
  • Update transforms: cased corpus 5 (kusto 5)
Nameeq
  • enableipbasedcookiebinding transforms: cased
UserAgentis_not_null
  • (no value, null check)
Valueeq
  • False transforms: cased corpus 3 (kusto 3)

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
AccountNameproject
ClientIpproject
CloudAppIdproject
CookieBindingDisabledproject
CookieBindingDisabledByproject
HistoricalAgentsproject
HistoricalIPsproject
InstanceUrlproject
LatestEventproject
UPNSuffixproject
UserAgentproject
UserIdproject