Detection rules › Kusto

External guest invitation followed by Microsoft Entra ID PowerShell signin

Status
available
Severity
medium
Time window
1d
Group by
UserPrincipalName
Source
github.com/Azure/Azure-Sentinel

By default guests have capability to invite more external guest users, guests also can do suspicious Microsoft Entra ID enumeration. This detection look at guest users, who have been invited or have invited recently, who also are logging via various PowerShell CLI. Ref : 'https://danielchronlund.com/2021/11/18/scary-azure-ad-tenant-enumeration-using-regular-b2b-guest-accounts/

MITRE ATT&CK coverage

Event coverage

Rule body kusto

id: acc4c247-aaf7-494b-b5da-17f18863878a
name: External guest invitation followed by Microsoft Entra ID PowerShell signin
description: |
  'By default guests have capability to invite more external guest users, guests also can do suspicious Microsoft Entra ID enumeration. This detection look at guest users, who have been invited or have invited recently, who also are logging via various PowerShell CLI.
  Ref : 'https://danielchronlund.com/2021/11/18/scary-azure-ad-tenant-enumeration-using-regular-b2b-guest-accounts/'
severity: Medium
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
queryFrequency: 1h
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
status: Available
tactics:
  - InitialAccess
  - Persistence
  - Discovery
relevantTechniques:
  - T1078.004
  - T1136.003
  - T1087.004
query: |
  let queryfrequency = 1h;
  let queryperiod = 1d;
  AuditLogs
  | where TimeGenerated > ago(queryperiod)
  | where OperationName in ("Invite external user", "Bulk invite users - started (bulk)", "Invite external user with reset invitation status")
  | extend InitiatingAppName = tostring(InitiatedBy.app.displayName)
  | extend InitiatingAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
  | extend InitiatingUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
  | extend InitiatingAadUserId = tostring(InitiatedBy.user.id)
  | extend InitiatingIpAddress = tostring(iff(isnotempty(InitiatedBy.user.ipAddress), InitiatedBy.user.ipAddress, InitiatedBy.app.ipAddress))
  | extend InitiatedBy = iff(isnotempty(InitiatingUserPrincipalName), InitiatingUserPrincipalName, InitiatingAppName)
  // Uncomment the following line to filter events where the inviting user was a guest user
  //| where InitiatedBy has_any ("live.com#", "#EXT#")
  | mv-apply TargetResource = TargetResources on 
    (
        where TargetResource.type =~ "User"
        | extend InvitedUser = tostring(TargetResource.userPrincipalName)
    )
  | mv-expand UserToCompare = pack_array(InitiatedBy, InvitedUser) to typeof(string)
  | where UserToCompare has_any ("live.com#", "#EXT#")
  | extend
      parsedUser = replace_string(tolower(iff(UserToCompare startswith "live.com#", tostring(split(UserToCompare, "#")[1]), tostring(split(UserToCompare, "#EXT#")[0]))), "@", "_"),
      InvitationTime = TimeGenerated
  | join (
      (union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs)
      | where TimeGenerated > ago(queryfrequency)
      | where UserType != "Member"
      | where AppId has_any                       // This web may contain a list of these apps: https://msshells.net/
          ("1b730954-1685-4b74-9bfd-dac224a7b894",// Azure Active Directory PowerShell
           "04b07795-8ddb-461a-bbee-02f9e1bf7b46",// Microsoft Azure CLI
           "1950a258-227b-4e31-a9cf-717495945fc2",// Microsoft Azure PowerShell
           "a0c73c16-a7e3-4564-9a95-2bdf47383716",// Microsoft Exchange Online Remote PowerShell
           "fb78d390-0c51-40cd-8e17-fdbfab77341b",// Microsoft Exchange REST API Based Powershell
           "d1ddf0e4-d672-4dae-b554-9d5bdfd93547",// Microsoft Intune PowerShell
           "9bc3ab49-b65d-410a-85ad-de819febfddc",// Microsoft SharePoint Online Management Shell
           "12128f48-ec9e-42f0-b203-ea49fb6af367",// MS Teams Powershell Cmdlets
           "23d8f6bd-1eb0-4cc2-a08c-7bf525c67bcd",// Power BI PowerShell
           "31359c7f-bd7e-475c-86db-fdb8c937548e",// PnP Management Shell
           "90f610bf-206d-4950-b61d-37fa6fd1b224",// Aadrm Admin Powershell
           "14d82eec-204b-4c2f-b7e8-296a70dab67e",// Microsoft Graph PowerShell
           "9cee029c-6210-4654-90bb-17e6e9d36617" // Power Platform CLI - pac"
          )
      | summarize arg_min(TimeGenerated, *) by UserPrincipalName
      | extend
          parsedUser = replace_string(UserPrincipalName, "@", "_"),
          SigninTime = TimeGenerated
      )
      on parsedUser
  | project InvitationTime, InitiatedBy, InitiatingUserPrincipalName, InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIpAddress, OperationName, InvitedUser, SigninTime, SigninCategory = Category1, SigninUserPrincipalName = UserPrincipalName, AppDisplayName, ResourceDisplayName, UserAgent, InvitationAdditionalDetails = AdditionalDetails, InvitationTargetResources = TargetResources
  | extend InvitedUserName = tostring(split(InvitedUser,'@',0)[0]), InvitedUserUPNSuffix = tostring(split(InvitedUser,'@',1)[0]), 
           InitiatedByName = tostring(split(InitiatingUserPrincipalName,'@',0)[0]), InitiatedByUPNSuffix = tostring(split(InitiatingUserPrincipalName,'@',1)[0])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: InvitedUser
      - identifier: Name
        columnName: InvitedUserName
      - identifier: UPNSuffix
        columnName: InvitedUserUPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: InitiatingUserPrincipalName
      - identifier: Name
        columnName: InitiatedByName
      - identifier: UPNSuffix
        columnName: InitiatedByUPNSuffix
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: InitiatingAadUserId
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: InitiatingAppServicePrincipalId
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: InitiatingIpAddress
version: 1.0.3
kind: Scheduled

Stages and Predicates

Parameters

let queryfrequency = 1h;
let queryperiod = 1d;

Stage 1: source

AuditLogs

Stage 2: where

| where TimeGenerated > ago(queryperiod)

Stage 3: where

| where OperationName in ("Invite external user", "Bulk invite users - started (bulk)", "Invite external user with reset invitation status")

Stage 4: extend (6 consecutive steps)

| extend InitiatingAppName = tostring(InitiatedBy.app.displayName)
| extend InitiatingAppServicePrincipalId = tostring(InitiatedBy.app.servicePrincipalId)
| extend InitiatingUserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| extend InitiatingAadUserId = tostring(InitiatedBy.user.id)
| extend InitiatingIpAddress = tostring(iff(isnotempty(InitiatedBy.user.ipAddress), InitiatedBy.user.ipAddress, InitiatedBy.app.ipAddress))
| extend InitiatedBy = iff(isnotempty(InitiatingUserPrincipalName), InitiatingUserPrincipalName, InitiatingAppName)

Stage 5: kusto:mv-apply

| mv-apply TargetResource = TargetResources on 
  (
      where TargetResource.type =~ "User"
      | extend InvitedUser = tostring(TargetResource.userPrincipalName)
  )

Stage 6: mv-expand

| mv-expand UserToCompare = pack_array(InitiatedBy, InvitedUser) to typeof(string)

Stage 7: where

| where UserToCompare has_any ("live.com#", "#EXT#")

Stage 8: extend

| extend
    parsedUser = replace_string(tolower(iff(UserToCompare startswith "live.com#", tostring(split(UserToCompare, "#")[1]), tostring(split(UserToCompare, "#EXT#")[0]))), "@", "_"),
    InvitationTime = TimeGenerated

Stage 9: join

| join (
    (union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs)
    | where TimeGenerated > ago(queryfrequency)
    | where UserType != "Member"
    | where AppId has_any
        ("1b730954-1685-4b74-9bfd-dac224a7b894",
         "04b07795-8ddb-461a-bbee-02f9e1bf7b46",
         "1950a258-227b-4e31-a9cf-717495945fc2",
         "a0c73c16-a7e3-4564-9a95-2bdf47383716",
         "fb78d390-0c51-40cd-8e17-fdbfab77341b",
         "d1ddf0e4-d672-4dae-b554-9d5bdfd93547",
         "9bc3ab49-b65d-410a-85ad-de819febfddc",
         "12128f48-ec9e-42f0-b203-ea49fb6af367",
         "23d8f6bd-1eb0-4cc2-a08c-7bf525c67bcd",
         "31359c7f-bd7e-475c-86db-fdb8c937548e",
         "90f610bf-206d-4950-b61d-37fa6fd1b224",
         "14d82eec-204b-4c2f-b7e8-296a70dab67e",
         "9cee029c-6210-4654-90bb-17e6e9d36617"
        )
    | summarize arg_min(TimeGenerated, *) by UserPrincipalName
    | extend
        parsedUser = replace_string(UserPrincipalName, "@", "_"),
        SigninTime = TimeGenerated
    )
    on parsedUser

Stage 10: project

| project InvitationTime, InitiatedBy, InitiatingUserPrincipalName, InitiatingAadUserId, InitiatingAppName, InitiatingAppServicePrincipalId, InitiatingIpAddress, OperationName, InvitedUser, SigninTime, SigninCategory = Category1, SigninUserPrincipalName = UserPrincipalName, AppDisplayName, ResourceDisplayName, UserAgent, InvitationAdditionalDetails = AdditionalDetails, InvitationTargetResources = TargetResources

Stage 11: extend

| extend InvitedUserName = tostring(split(InvitedUser,'@',0)[0]), InvitedUserUPNSuffix = tostring(split(InvitedUser,'@',1)[0]), 
         InitiatedByName = tostring(split(InitiatingUserPrincipalName,'@',0)[0]), InitiatedByUPNSuffix = tostring(split(InitiatingUserPrincipalName,'@',1)[0])

Stage 12: summarize

summarize by UserPrincipalName

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
AppIdmatch
  • 04b07795-8ddb-461a-bbee-02f9e1bf7b46
  • 12128f48-ec9e-42f0-b203-ea49fb6af367
  • 14d82eec-204b-4c2f-b7e8-296a70dab67e
  • 1950a258-227b-4e31-a9cf-717495945fc2
  • 1b730954-1685-4b74-9bfd-dac224a7b894
  • 23d8f6bd-1eb0-4cc2-a08c-7bf525c67bcd
  • 31359c7f-bd7e-475c-86db-fdb8c937548e
  • 90f610bf-206d-4950-b61d-37fa6fd1b224
  • 9bc3ab49-b65d-410a-85ad-de819febfddc
  • 9cee029c-6210-4654-90bb-17e6e9d36617
  • a0c73c16-a7e3-4564-9a95-2bdf47383716
  • d1ddf0e4-d672-4dae-b554-9d5bdfd93547
  • fb78d390-0c51-40cd-8e17-fdbfab77341b
OperationNamein
  • Bulk invite users - started (bulk) transforms: cased
  • Invite external user transforms: cased
  • Invite external user with reset invitation status transforms: cased
UserToComparematch
  • #EXT#
  • live.com#
UserTypene
  • Member transforms: cased
typeeq
  • User

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
UserPrincipalNamesummarize