Detection rules › Kusto

Azure Portal sign in from another Azure Tenant

Status
available
Severity
medium
Time window
1h
Group by
HomeTenantId, IPAddress, Location, ResourceTenantId, UserAgent, UserId, UserPrincipalName
Source
github.com/Azure/Azure-Sentinel

'This query looks for successful sign in attempts to the Azure Portal where the user who is signing in from another Azure tenant, and the IP address the login attempt is from is an Azure IP. A threat actor who compromises an Azure tenant may look to pivot to other tenants leveraging cross-tenant delegated access in this manner.'

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1199 Trusted Relationship

Rules detecting the same action

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

Rule body kusto

id: 87210ca1-49a4-4a7d-bb4a-4988752f978c
name: Azure Portal sign in from another Azure Tenant
description: |
  'This query looks for successful sign in attempts to the Azure Portal where the user who is signing in from another Azure tenant, and the IP address the login attempt is from is an Azure IP. A threat actor who compromises an Azure tenant may look to pivot to other tenants leveraging cross-tenant delegated access in this manner.'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - InitialAccess
relevantTechniques:
  - T1199
query: |
  // Get details of current Azure Ranges (note this URL updates regularly so will need to be manually updated over time)
  // You may find the name of the new JSON here: https://www.microsoft.com/download/details.aspx?id=56519
  // On the downloads page, click the 'details' button, and then replace just the filename in the URL below
  let azure_ranges = 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
  | mv-expand values.properties.addressPrefixes
  | mv-expand values_properties_addressPrefixes
  | summarize by tostring(values_properties_addressPrefixes)
  | extend isipv4 = parse_ipv4(values_properties_addressPrefixes)
  | extend isipv6 = parse_ipv6(values_properties_addressPrefixes)
  | extend ip_type = case(isnotnull(isipv4), "v4", "v6")
  | summarize make_list(values_properties_addressPrefixes) by ip_type
  ;
  SigninLogs
  // Limiting to Azure Portal really reduces false positives and helps focus on potential admin activity
  | where ResultType == 0
  | where AppDisplayName =~ "Azure Portal"
  | extend isipv4 = parse_ipv4(IPAddress)
  | extend ip_type = case(isnotnull(isipv4), "v4", "v6")
   // Only get logons where the IP address is in an Azure range
  | join kind=fullouter (azure_ranges) on ip_type
  | extend ipv6_match = ipv6_is_in_any_range(IPAddress,  list_values_properties_addressPrefixes)
  | extend ipv4_match = ipv4_is_in_any_range(IPAddress,  list_values_properties_addressPrefixes)
  | where ipv4_match or ipv6_match 
  // Limit to where the user is external to the tenant
  | where HomeTenantId != ResourceTenantId
  // Further limit it to just access to the current tenant (you can drop this if you wanted to look elsewhere as well but it helps reduce FPs)
  | where ResourceTenantId == AADTenantId
  | summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), make_set(ResourceDisplayName) by UserPrincipalName, IPAddress, UserAgent, Location, HomeTenantId, ResourceTenantId, UserId
  | extend AccountName = split(UserPrincipalName, "@")[0]
  | extend UPNSuffix = split(UserPrincipalName, "@")[1]
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserPrincipalName
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: UserId
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IPAddress
alertDetailsOverride:
  alertDisplayNameFormat: Azure Portal sign in by {{UserPrincipalName}} from another Azure Tenant with IP Address {{IPAddress}}
  alertDescriptionFormat: |
    This query looks for successful sign in attempts to the Azure Portal where the user who is signing in from another Azure tenant,
    and the IP address the login attempt is from is an Azure IP. A threat actor who compromises an Azure tenant may look
    to pivot to other tenants leveraging cross-tenant delegated access in this manner.
    In this instance {{UserPrincipalName}} logged in at {{FirstSeen}} from IP Address {{IPAddress}}.
version: 2.0.3
kind: Scheduled

Stages and Predicates

Let binding: azure_ranges

let azure_ranges = 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
| mv-expand values.properties.addressPrefixes
| mv-expand values_properties_addressPrefixes
| summarize by tostring(values_properties_addressPrefixes)
| extend isipv4 = parse_ipv4(values_properties_addressPrefixes)
| extend isipv6 = parse_ipv6(values_properties_addressPrefixes)
| extend ip_type = case(isnotnull(isipv4), "v4", "v6")
| summarize make_list(values_properties_addressPrefixes) by ip_type;

Stage 1: source

SigninLogs

Stage 2: where

| where ResultType == 0

Stage 3: where

| where AppDisplayName =~ "Azure Portal"

Stage 4: extend

| extend isipv4 = parse_ipv4(IPAddress)

Stage 5: extend

| extend ip_type = case(isnotnull(isipv4), "v4", "v6")
ip_type =
ifisnotempty(isipv4)"v4"
else"v6"

Stage 6: join

| join kind=fullouter (azure_ranges) on ip_type

Stage 7: extend

| extend ipv6_match = ipv6_is_in_any_range(IPAddress,  list_values_properties_addressPrefixes)

Stage 8: extend

| extend ipv4_match = ipv4_is_in_any_range(IPAddress,  list_values_properties_addressPrefixes)

Stage 9: where

| where ipv4_match or ipv6_match

Stage 10: where

| where HomeTenantId != ResourceTenantId

Stage 11: where

| where ResourceTenantId == AADTenantId

Stage 12: summarize

| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), make_set(ResourceDisplayName) by UserPrincipalName, IPAddress, UserAgent, Location, HomeTenantId, ResourceTenantId, UserId

Stage 13: extend

| extend AccountName = split(UserPrincipalName, "@")[0]

Stage 14: extend

| extend UPNSuffix = split(UserPrincipalName, "@")[1]

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
AppDisplayNameeq
  • Azure Portal
HomeTenantIdne
  • ResourceTenantId transforms: cased
ResourceTenantIdeq
  • AADTenantId transforms: cased
ResultTypeeq
  • 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
FirstSeensummarize
HomeTenantIdsummarize
IPAddresssummarize
LastSeensummarize
Locationsummarize
ResourceTenantIdsummarize
UserAgentsummarize
UserIdsummarize
UserPrincipalNamesummarize
AccountNameextend
UPNSuffixextend