Detection Library
Ready-to-use KQL hunting queries, parser functions and Microsoft Sentinel analytic-rule templates developed for the playbook. Copy a query or deploy a rule template straight to your workspace.
Entra Connect Sync Account
Disabled soft- or hard match of Azure AD Connect sync
Change of AAD sync configuration to overwrite and take-over (Azure AD) cloud-only accounts from AADC server (on-premises)
AuditLogs
| where OperationName has "Set DirSync feature"
| where Category has "DirectoryManagement"
| where parse_json(tostring(TargetResources[0].modifiedProperties))[0].displayName == "DirSyncFeatures"
| extend NewValue = parse_json(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].newValue))[0]
| extend OldValue = parse_json(tostring(parse_json(tostring(TargetResources[0].modifiedProperties))[0].oldValue))[0]
| extend UserPrincipalName = parse_json(tostring(InitiatedBy.user)).userPrincipalName
| extend IPAddress = parse_json(tostring(InitiatedBy.user)).ipAddress
| extend timestamp = TimeGenerated, IPCustomEntity = IPAddress, AccountCustomEntity = UserPrincipalName
Successful sign-ins from valid AAD connector account outside of whitelisted IP address from WatchList
'Detection of sign-ins outside of a named public IP addresses. We're AccountObject ID from the "Service Accounts" watchlist to detect any sign-ins outside of the named IP address which is defined in the "High Value Asses" watchlist. Furthermore, we're covering all sign-ins to the AAD Connect Endpoints (Azure AD Sync and AAD Connect V2) to detect sign-ins that doesn't match with the WatchList.'
let AADCServer = (_GetWatchlist('HighValueAssets')
| where ['Tags'] == "Azure AD Connect" | project ['IP Address']);
let AADConnectorAcc = (_GetWatchlist('ServiceAccounts')
| where ['Tags'] == "Azure AD Connect" | project AccountObjectId = ['Service AAD Object Id']);
union isfuzzy=true AADNonInteractiveUserSignInLogs, SigninLogs
// AADC APIs: AADSync = "cb1056e2-e479-49de-ae31-7812af012ed8", AAD Connect v2 = 6eb59a73-39b2-4c23-a70f-e2e3ce8965b1
| where (UserId in (AADConnectorAcc) or AppId == "cb1056e2-e479-49de-ae31-7812af012ed8" or AppId == "6eb59a73-39b2-4c23-a70f-e2e3ce8965b1") and IPAddress !in (AADCServer)
| where ResultType == "0"
| project TimeGenerated,Category,UserPrincipalName, AppDisplayName, AppId, IPAddress, RiskState
| extend timestamp = TimeGenerated, IPCustomEntity = IPAddress, AccountCustomEntity = UserPrincipalName
Activities from AAD connector account with enrichment of IdentityInfo
'This hunting query can be used customized as detection or for further investigation of changes which was made by the whitelisted AAD connector account. It allows to find take over or synchronization to user objects with sensitive group membership or assigned AAD roles. This query is also useful to find anomaly of object changes.''
let AADConnectorAcc = (_GetWatchlist('ServiceAccounts')
| where ['Tags'] == "Azure AD Connect" | project AccountObjectId = ['Service AAD Object Id']);
let AADCServer = (_GetWatchlist('HighValueAssets')
| where ['Tags'] == "Azure AD Connect" | project ['IP Address']);
AuditLogs
| extend ActorAccountObjectId = tostring(parse_json(tostring(InitiatedBy.user)).id)
| extend ActorAccountIPAddress = tostring(parse_json(tostring(InitiatedBy.user)).ipAddress)
| extend TargetAccountObjectId = tostring(parse_json(tostring(TargetResources[0])).id)
| where ActorAccountObjectId in (AADConnectorAcc)
| join kind=leftouter (IdentityInfo | project AccountObjectId, AccountDisplayName, GroupMembership, AssignedRoles) on $left.TargetAccountObjectId == $right.AccountObjectId
| project TimeGenerated, ActorAccountObjectId, TargetAccountObjectId, OperationName, TargetUPN = tostring(parse_json(tostring(TargetResources[0])).userPrincipalName), GroupMembership, AssignedRoles, ActorAccountIPAddress
| extend timestamp = TimeGenerated, IPCustomEntity = ActorAccountIPAddress, AccountCustomEntity = TargetUPN
AADConnectorAccount ActivitiesOnAzureADRoleMembers
let DirSyncAssignedMembers = (IdentityInfo
| where AssignedRoles contains "Directory Synchronization Accounts"
| summarize by AccountObjectId);
let DirSyncNamedUsers = (SigninLogs
| where UserPrincipalName startswith "sync_" and UserPrincipalName endswith "onmicrosoft.com"
| summarize by AccountObjectId = UserId);
let PIMOperations = dynamic(["Add member to role", "Add eligible member to role in PIM completed (timebound)", "Add eligible member to role in PIM requested (permanent)"]);
AuditLogs
| extend ActorAccountObjectId = tostring(parse_json(tostring(InitiatedBy.user)).id)
| extend ActorAccountIPAddress = tostring(parse_json(tostring(InitiatedBy.user)).ipAddress)
| extend TargetAccountObjectId = tostring(parse_json(tostring(TargetResources[0])).id)
| where ActorAccountObjectId in (DirSyncAssignedMembers) or ActorAccountObjectId in (DirSyncNamedUsers)
// Correlate with role-assignable users from IdentityInfo
| join kind=inner ( IdentityInfo
| extend AADRoles = parsejson(AssignedRoles)
| where AADRoles != "[]"
| distinct AccountObjectId) on $left.TargetAccountObjectId == $right.AccountObjectId
// Correlate with users from recent PIM activities
| join kind=leftouter ( AuditLogs
| where OperationName in (PIMOperations)
| mv-expand TargetResources
| extend AccountObjectId = tostring(parse_json(tostring(TargetResources)).id)
| where isnotempty(AccountObjectId)
| distinct AccountObjectId) on $left.TargetAccountObjectId == $right.AccountObjectId
| where ActorAccountObjectId != "9484ee3a-5169-4dcb-bfb2-41a446b3c337"
// Correlate with role-assignable users from IdentityInfo
| join kind=leftouter ( IdentityInfo
| extend AADRoles = parsejson(AssignedRoles)
| where AADRoles != "[]"
| distinct AccountObjectId) on $left.TargetAccountObjectId == $right.AccountObjectId
| project TimeGenerated, ActorAccountObjectId, OperationName, TargetUPN = tostring(parse_json(tostring(TargetResources[0])).userPrincipalName)Added temporary access pass or changed password of Azure AD connector account
'Added TAP security information or password change event on On-Premises Sync Account. This account will be identified by IdentityInfo table (assignment to "Directory Synchronization Accounts" role) and name pattern'
let AADConnectorAcc = (_GetWatchlist('ServiceAccounts')
| where ['Tags'] == "Azure AD Connect" | project AccountObjectId = ['Service AAD Object Id']);
AuditLogs
| extend TargetUpn = tolower(tostring(TargetResources[0].userPrincipalName))
| extend TargetId = tostring(TargetResources[0].id)
| where TargetId in (AADConnectorAcc)
| where (LoggedByService == "Authentication Methods" and ResultDescription == "Admin registered temporary access pass method for user") or OperationName == "Reset user password"
| extend InitiatingUserOrApp = iff(isnotempty(InitiatedBy.user.userPrincipalName),tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
| extend InitiatingIpAddress = iff(isnotempty(InitiatedBy.user.ipAddress), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
| extend AccountCustomEntity = InitiatingUserOrApp
Detection of Azure AD connector accounts outside of WatchLists
'List of objects with Directory role membership to "Directory Synchronization" or naming similar to AAD connector account which aren't stored in the WatchList was found. Indicator of creating AAD connector account as backdoor.''
let DirSyncRoleAssignedMembers = (IdentityInfo
| where AssignedRoles contains "Directory Synchronization Accounts"
| summarize by AccountObjectId, AccountUPN = tolower(AccountUPN));
let DirSyncNamedUsers = (union isfuzzy=true AADNonInteractiveUserSignInLogs, SigninLogs
// AADC APIs: AADSync = "cb1056e2-e479-49de-ae31-7812af012ed8", AAD Connect v2 = 6eb59a73-39b2-4c23-a70f-e2e3ce8965b1
| where (UserPrincipalName startswith "sync_" and UserPrincipalName endswith "onmicrosoft.com") or AppId == "cb1056e2-e479-49de-ae31-7812af012ed8" or AppId == "6eb59a73-39b2-4c23-a70f-e2e3ce8965b1"
| summarize by AccountObjectId = UserId, AccountCustomEntity = tolower(UserPrincipalName));
let WatchList = _GetWatchlist('ServiceAccounts') | where ['Tags'] == "Azure AD Connect" | project AccountObjectId = ['Service AAD Object Id'];
union isfuzzy=true DirSyncRoleAssignedMembers,DirSyncNamedUsers
| distinct tostring(AccountObjectId), AccountCustomEntity
| where AccountObjectId !in (WatchList)
MDA Hunt Multi Stage Incident
//Reference documentation for "The hunt in a multi-stage incident"
//https://techcommunity.microsoft.com/t5/microsoft-365-defender-blog/microsoft-cloud-app-security-the-hunt-in-a-multi-stage-incident/ba-p/2193484
//This queries are built to M365 Defender Advanced Hunting
//Basic user info
IdentityInfo | where AccountUpn =~ '<insert user identity/upn here>'
//Sign-in activity by the user
let timeToSearch = startofday(datetime('2022-05-01'));
EntraIdSignInEvents
| where AccountObjectId == '<user object id>' and Timestamp >= timeToSearch
| distinct, Application, ResourceDisplayName, Country, City, IPAddress, DeviceName, DeviceTrustType, OSPlatform, IsManaged, IsCompliant, AuthenticationRequirement, RiskState, UserAgent, ClientAppUsed
//Summarize all the performed actions from the suspicious IP/location for that account.
let accountId = '<user object id>';
let locations = pack_array('RO', 'HK');
let timeToSearch = startofday(datetime('2022-05-01'));
CloudAppEvents
| where AccountObjectId == accountId and CountryCode in (locations) and Timestamp >= timeToSearch
| summarize by ActionType, CountryCode, AccountObjectId
| sort by ActionType asc
//Review the accessed emails
let accountId = '<user object id>';
let locations = pack_array('RO', 'HK');
let timeToSearch = startofday(datetime('2022-05-01'));
CloudAppEvents
| where ActionType == 'MailItemsAccessed' and CountryCode in (locations) and AccountObjectId == accountId and Timestamp >= timeToSearch
| mv-expand todynamic(RawEventData.Folders)
| extend Path = todynamic(RawEventData_Folders.Path), SessionId = tostring(RawEventData.SessionId)
| mv-expand todynamic(RawEventData_Folders.FolderItems)
| project SessionId, Timestamp, AccountObjectId, DeviceType, CountryCode, City, IPAddress, UserAgent, Path, Message = tostring(RawEventData_Folders_FolderItems.InternetMessageId)
| join kind=leftouter (
EmailEvents
| where RecipientObjectId == accountId
| project Subject, RecipientEmailAddress , SenderMailFromAddress , DeliveryLocation , ThreatTypes, AttachmentCount , UrlCount , InternetMessageId
) on $left.Message == $right.InternetMessageId
| sort by Timestamp desc
//Review the accessed folders and files
let accountId = '<user object id>';
let locations = pack_array('RO', 'BY');
let timeToSearch = startofday(datetime('2022-05-01'));
CloudAppEvents
| where ActionType == 'FilePreviewed' and CountryCode in (locations) and AccountObjectId == accountId and Timestamp >= timeToSearch
| project Timestamp, CountryCode , IPAddress , ISP, UserAgent , Application, ActivityObjects, AccountObjectId
| mv-expand ActivityObjects
| where ActivityObjects['Type'] in ('File', 'Folder')
| evaluate bag_unpack(ActivityObjects)
Adversary-in-the-Middle (AiTM)
HuntUserActivities
//Hunting suspicious activities based on MDA CloudAppEvents table data when user entity is known
// The queries are from Microsoft Techcommunity blog a few years ago
//Basic user info
IdentityInfo | where AccountUpn =~ '<userPrincipalName>'
//Sign-in activity by the user
let timeToSearch = startofday(datetime('2024-08-01'));
EntraIdSignInEvents
| where AccountObjectId == '<Insert user AccountId>' and Timestamp>=timeToSearch
| distinct Application, ResourceDisplayName, Country, City, IPAddress, DeviceName, DeviceTrustType, OSPlatform, IsManaged, IsCompliant, AuthenticationRequirement, RiskState, UserAgent, ClientAppUsed
//Summarize all the performed actions from the suspicious IP/location for that account
//Use accountId and filters to drill down specific locations
let accountId = '<Insert user AccountId>';
let locations = pack_array('SG', 'KR', 'JP');
let timeToSearch = startofday(datetime('2024-08-01'));
CloudAppEvents
| where AccountObjectId == accountId and CountryCode in (locations) and Timestamp >= timeToSearch
| summarize by ActionType, CountryCode, AccountObjectId
| sort by ActionType asc
//Review the accessed emails
let accountId = '<Insert user AccountId>';
let locations = pack_array('SG', 'KR', 'JP');
let timeToSearch = startofday(datetime('2024-08-01'));
CloudAppEvents
| where ActionType == 'MailItemsAccessed' and CountryCode in (locations) and AccountObjectId == accountId and Timestamp >= timeToSearch
| mv-expand todynamic(RawEventData.Folders)
| extend Path = todynamic(RawEventData_Folders.Path), SessionId = tostring(RawEventData.SessionId)
| mv-expand todynamic(RawEventData_Folders.FolderItems)
| project SessionId, Timestamp, AccountObjectId, DeviceType, CountryCode, City, IPAddress, UserAgent, Path, Message = tostring(RawEventData_Folders_FolderItems.InternetMessageId)
| join kind=leftouter (
EmailEvents
| where RecipientObjectId == accountId
| project Subject, RecipientEmailAddress , SenderMailFromAddress , DeliveryLocation , ThreatTypes, AttachmentCount , UrlCount , InternetMessageId
) on $left.Message == $right.InternetMessageId
| sort by Timestamp desc
//Review the accessed emails
let accountId = '<Insert user AccountId>';
let locations = pack_array('SG', 'KR', 'JP');
let timeToSearch = startofday(datetime('2024-04-01'));
CloudAppEvents
| where ActionType == 'FilePreviewed' or ActionType == 'FolderModified' or ActionType == 'New-InboxRule' and CountryCode in (locations) and AccountObjectId == accountId and Timestamp >= timeToSearch
| project Timestamp, CountryCode , IPAddress , ISP, UserAgent , Application, ActivityObjects, AccountObjectId, ActionType
| mv-expand ActivityObjects
| where ActivityObjects['Type'] in ('File', 'Folder')
| evaluate bag_unpack(ActivityObjects)
//Review New-InboxRules
let accountId = '<Insert user AccountId>';
let locations = pack_array('SG', 'KR', 'JP');
let timeToSearch = startofday(datetime('2024-05-01'));
CloudAppEvents
| where ActionType == 'New-InboxRule' and CountryCode in (locations) and AccountObjectId == accountId and Timestamp >= timeToSearch
| project Timestamp, CountryCode , IPAddress , ISP, UserAgent , Application, ActivityObjects, AccountObjectId, ActionTypeInvestigateBehaviors
//Investigate Defender for Cloud Apps Behaviors (BehaviorEntities & BehaviorInfo) for a specific user
BehaviorInfo
| where Timestamp >ago(30d)
| where ServiceSource == "Microsoft Cloud App Security"
//| where BehaviorId == "<Inser BehaviorId>"
//| where AccountUpn == "<Insert userPrincipalName>"
| join BehaviorEntities on BehaviorId
| project Timestamp, BehaviorId, ActionType, Description, Categories, AttackTechniques, ServiceSource, AccountUpn, AccountObjectId, EntityType, EntityRole, RemoteIP, AccountName, AccountDomain, Application
IpcAlertToSessionAndRequestId
// Query to get IPC alerts with SessionId and Request Id
SecurityAlert
| where TimeGenerated >ago(365d)
| mv-expand parse_json(Entities)
| where Entities.Type == 'cloud-logon-session' or Entities.Type == 'cloud-logon-request'
| project OriginalRequestId = parse_json(ExtendedProperties).["Request Id"], Entities.SessionId, AlertName, StatusSearchCookies
//Search for cookies that were first seen after OfficeHome application authentication (as seen when the user authenticated to the AiTM phishing site) and then seen being used in other applications in other countries
//The query is creatd by Microsoft DART team and the inital version is found in 'https://www.microsoft.com/en-us/security/blog/2022/07/12/from-cookie-theft-to-bec-attackers-use-aitm-phishing-sites-as-entry-point-to-further-financial-fraud/?msockid=28c8c6feb17d6e740accd40eb04a6f51'
let OfficeHomeSessionIds =
EntraIdSignInEvents
| where Timestamp > ago(7d)
| where ErrorCode == 0
| where ApplicationId == "4765445b-32c6-49b0-83e6-1d93765276ca" //OfficeHome application
| where ClientAppUsed == "Browser"
| where LogonType has "interactiveUser"
| summarize arg_min(Timestamp, Country) by SessionId;
EntraIdSignInEvents
| where Timestamp > ago(7d)
| where ApplicationId != "4765445b-32c6-49b0-83e6-1d93765276ca"
| where ClientAppUsed == "Browser"
| project OtherTimestamp = Timestamp, Application, ApplicationId, AccountObjectId, AccountDisplayName, OtherCountry = Country, SessionId
| join OfficeHomeSessionIds on SessionId
| where OtherTimestamp > Timestamp and OtherCountry != Country
Summarize for each user the countries that authentication to the OfficeHome
//Summarize for each user the countries that authenticated to the OfficeHome application and find uncommon or untrusted ones
//The query is creatd by Microsoft DART team and the inital version is found in 'https://www.microsoft.com/en-us/security/blog/2022/07/12/from-cookie-theft-to-bec-attackers-use-aitm-phishing-sites-as-entry-point-to-further-financial-fraud/?msockid=28c8c6feb17d6e740accd40eb04a6f51'
EntraIdSignInEvents
| where Timestamp >ago(7d)
| where ApplicationId == "4765445b-32c6-49b0-83e6-1d93765276ca" //OfficeHome application
| where ClientAppUsed == "Browser"
| where LogonType has "interactiveUser"
| summarize Countries = make_set(Country) by AccountObjectId, AccountDisplayName
AiTM – Parser Functions
Token_EntityToAlertSession
let Token_EntityToAlertSessions = (Entity:string) {
let SessionRelatedAlerts = (SecurityAlert
| where Entities has (Entity)
| mv-expand parse_json(Entities)
| where Entities.Type == "cloud-logon-session" or Entities.Type == "cloud-logon-request"
| summarize arg_max(TimeGenerated, *) by SystemAlertId
// Optional: Filtered for resolved events only
//| where Status != "Resolved"
| project OriginalRequestId = tostring(parse_json(ExtendedProperties).["Request Id"]), SessionId = tostring(Entities.SessionId), AlertName, Status, SystemAlertId, tostring(Tactics), tostring(Techniques), tostring(Entities), RequestId = tostring(Entities.RequestId)
);
let AssociatedSessionIds = SessionRelatedAlerts
| join kind=inner ( EntraIdSignInEvents
| where isnotempty (SessionId)
| extend SignInTime = TimeGenerated, Timestamp, AppId = ApplicationId, ResourceId, OriginalRequestId = tostring(RequestId), CorrelationId = ReportId, SessionId, IPAddress, Application, ResourceDisplayName
) on SessionId;
let AssociatedRequestIds = SessionRelatedAlerts
| join kind=inner ( EntraIdSignInEvents
| where isnotempty (RequestId)
| extend SignInTime = TimeGenerated, Timestamp, AppId = ApplicationId, ResourceId, RequestId, CorrelationId = ReportId, SessionId, IPAddress, Application, ResourceDisplayName
) on RequestId;
union AssociatedRequestIds, AssociatedSessionIds
| extend SessionId = iff(isempty(SessionId), SessionId1, SessionId)
| extend OriginalRequestId = iff(isempty(RequestId), RequestId1, RequestId)
| extend SignIns = bag_pack_columns(SignInTime, Application, AppId, ResourceId, ResourceDisplayName, IPAddress, ReportId, CorrelationId, OriginalRequestId)
| extend SessionAlert = bag_pack_columns(TimeGenerated, SystemAlertId, AlertName, Status, Tactics, Techniques, Entities)
| summarize SignInActivityStart=min(SignInTime), SignInActivityEnd=max(SignInTime), SignIns = make_set(SignIns), SessionAlerts = make_set(SessionAlert), OriginalRequestIds = make_set(OriginalRequestId) by AccountObjectId, AccountDisplayName, SessionId
};
Token_EntityToAlertSessions(Entity)Token_EntityToAlertSignInRequest
let Token_EntityToAlertSignInRequest = (Entity:string="") {
let Lookback = 90d;
let RequestRelatedAlerts = (
SecurityAlert
| where Entities has (Entity)
| where TimeGenerated >ago(Lookback)
| mv-expand parse_json(Entities)
| where Entities.Type == "cloud-logon-request"
| summarize arg_max(TimeGenerated, *) by SystemAlertId
// Optional: Filter for resolved events only
//| where Status != "Resolved"
| project SignInAlertTime = TimeGenerated, OriginalRequestId = tostring(parse_json(ExtendedProperties).["Request Id"]), AlertName, Status, SystemAlertId, tostring(Tactics), tostring(Techniques), tostring(Entities), RequestId = tostring(Entities.RequestId)
);
let AssociatedSignIns = RequestRelatedAlerts
| join kind=inner (
union SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated >ago(Lookback)
| extend SignInTime = TimeGenerated, AppId, ResourceId, RequestId = OriginalRequestId, CorrelationId, IPAddress, Application = AppDisplayName, ResourceDisplayName, AccountObjectId = UserId, AccountDisplayName = UserDisplayName, UniqueTokenIdentifier
) on RequestId;
AssociatedSignIns
| extend SignIns = bag_pack_columns(SignInTime, Application, AppId, ResourceId, ResourceDisplayName, IPAddress, CorrelationId, UniqueTokenIdentifier)
| extend SignInAlert = bag_pack_columns(SignInAlertTime, SystemAlertId, AlertName, Status, Tactics, Techniques, Entities)
| summarize SignInActivityStart=min(SignInTime), SignInActivityEnd=max(SignInTime), SignIns = make_set(SignIns), SignInAlerts = make_set(SignInAlert), OriginalRequestIds = make_set(OriginalRequestId) by AccountObjectId, RequestId
};
Token_EntityToAlertSignInRequest(Entity)Token_GsaPrivilegedInterfaceActivity
let Token_GsaPrivilegedInterfaceActivity = (Entity:string="", CaPolicyBlockedOutsideGsa:string="", FilterByUniqueTokenIdentifier:string="", FilteredByActivityIpAddress:string="") {
let PrivilegedInterfaces = datatable (ResourceDisplayName:string, Url:string) [
"Windows Azure Service Management API", "management.azure.com",
"Microsoft Graph", "graph.microsoft.com"
];
let PrivilegedInterfacesAllUrls = dynamic(['graph.microsoft.com','management.azure.com']);
let PrivilegedArmOperations = dynamic([
'Microsoft.Authorization/roleAssignments/write',
'Microsoft.Authorization/roleAssignmentScheduleRequests/write',
'Microsoft.Authorization/roleEligibilityScheduleRequests/write',
'Microsoft.Authorization/roleManagementPolicies/write',
'Microsoft.Storage/storageAccounts/listKeys/action'
]);
let PrivilegedGraphOperationsUri = dynamic([
'/v1.0/applications/<UUID>/microsoft.graph.addPassword'
]);
let PrivilegedGraphOperations = dynamic([
'PATCH',
'POST',
'DELETE'
]);
let SignInWithConnections = union SigninLogs, AADNonInteractiveUserSignInLogs
// Currently no filtering for sign-in property "Through Global Secure Access", property isn't available in Sign-in logs
| where UserPrincipalName == (Entity) or UserId == (Entity)
// Extending Auth processing details for CAE
| extend AuthProcessDetails = replace_string(AuthenticationProcessingDetails, " ", "")
| extend AuthProcessDetails = replace_string(AuthProcessDetails, "\r\n", "")
| parse-where AuthProcessDetails with * "IsCAEToken\",\"value\":\"" IsTokenCAE"\"" *
// General filtering of sign-in events
| where UniqueTokenIdentifier contains (FilterByUniqueTokenIdentifier)
| where ResourceDisplayName in~ (PrivilegedInterfaces)
// Enrichment of device and user details
| extend DeviceDetail = iff(isempty( DeviceDetail_dynamic ), todynamic(DeviceDetail_string), DeviceDetail_dynamic)
| extend DeviceName = tostring(toupper(DeviceDetail.displayName))
| extend DeviceId = iff(isnotempty(parse_json(DeviceDetail).deviceId), tostring(parse_json(DeviceDetail).deviceId), "Unknown")
| extend DeviceOS = tostring(parse_json(DeviceDetail).operatingSystem)
| extend DeviceTrust = tostring(parse_json(DeviceDetail).trustType)
| extend DeviceCompliance = tostring(parse_json(DeviceDetail).isCompliant)
| extend AuthenticationMethod = tostring(parse_json(AuthenticationDetails)[0].authenticationMethod)
| extend AuthenticationDetail = tostring(parse_json(AuthenticationDetails)[0].authenticationStepResultDetail)
| extend DeviceInsights = bag_pack_columns(DeviceName, DeviceTrust, DeviceCompliance)
| extend AuthInsights = bag_pack_columns(AuthenticationMethod, AuthenticationDetail)
| extend SignInIpAddress = IPAddress
// Get identifier if token is CAE-capable
| extend JsonAuthCaeDetails = parse_json(AuthenticationProcessingDetails)
// Enrichment of CA policy status
| extend ConditionalAccessPolicies = iff(isempty( ConditionalAccessPolicies_dynamic ), todynamic(ConditionalAccessPolicies_string), ConditionalAccessPolicies_dynamic)
| mv-apply ConditionalAccessPolicies on (
where ConditionalAccessPolicies.displayName startswith (CaPolicyBlockedOutsideGsa)
)
| extend GsaCaStatus = ConditionalAccessPolicies.result
| join kind=inner ( PrivilegedInterfaces ) on ResourceDisplayName
// Correlation to GSA can't be established by SessionId (currently missing), connections with available identifier will be used in the TimeRange window will be used
| join kind=leftouter (
NetworkAccessTraffic
| where DestinationFqdn in~ (PrivilegedInterfacesAllUrls)
| summarize ConnectIds = make_set(ConnectionId) by UserId, DeviceId, Url = DestinationFqdn, GsaSourceIp = SourceIp, IPAddress = SourceIp
) on UserId, DeviceId, Url, IPAddress
| project SignInTime = CreatedDateTime, ResultType, ResultDescription, TimeGenerated, CorrelationId, OriginalRequestId, UniqueTokenIdentifier, AppId, AppDisplayName, ResourceId = ResourceIdentity, ResourceDisplayName, Category, SignInIpAddress = IPAddress, DeviceInsights, AuthInsights, AuthenticationProcessingDetails, RiskLevelDuringSignIn, SignInIdentifierType, tostring(ConnectIds), GsaCaStatus, GsaSourceIp, AuthProcessDetails, IsTokenCAE, UserPrincipalName
| sort by SignInTime desc;
let GraphActivity = SignInWithConnections
| join kind=inner ( MicrosoftGraphActivityLogs
| where ClientAuthMethod == "0"
| extend ParsedUri = parse_url(RequestUri)
| extend NormalizedRequestUri = tostring(ParsedUri.Path)
| extend NormalizedRequestUri = replace_string(NormalizedRequestUri, '//', '/')
| extend NormalizedRequestUri = replace_regex(NormalizedRequestUri, @'[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}', @'<UUID>'), ParsedUri
| extend IsSensitive = iff((NormalizedRequestUri in~ (PrivilegedGraphOperationsUri) and RequestMethod in~ (PrivilegedGraphOperations)) == true, true, false)
| extend GraphOperations = bag_pack_columns(ActivityTime = TimeGenerated, RequestId, OperationId, ClientRequestId, UserAgent, RequestUri, ResponseSizeBytes, UserAgent, IsSensitive)
| summarize Operations = make_set(GraphOperations) by ActivityIpAddress = IPAddress, tostring(TokenIssuedAt), UniqueTokenIdentifier = SignInActivityId
) on UniqueTokenIdentifier
| project-away UniqueTokenIdentifier1
| extend OutsideOfGsa = iff(SignInIpAddress != ActivityIpAddress or isempty(ConnectIds), true, false);
let AzureActivity = SignInWithConnections
| join kind=inner ( CloudAppEvents
| extend UniqueTokenIdentifier = tostring(RawEventData.uniqueTokenId)
| extend TokenIssuedAt = tostring(parse_json(tostring(RawEventData.claims)).iat)
| extend ClientIpAddress = tostring(parse_json(tostring(RawEventData.httpRequest)).clientIpAddress)
| extend CorrelationId = RawEventData.ActivityId
| extend OperationNameValue = parse_json(tostring(RawEventData.properties)).message
| extend IsSensitive = iff((OperationNameValue in (PrivilegedArmOperations)) == true, true, false)
| extend ArmOperations = bag_pack_columns(ActivityTime = TimeGenerated, CorrelationId, OperationNameValue, ResourceId = ObjectId, IsSensitive)
| summarize Operations = make_set(ArmOperations) by ActivityIpAddress = ClientIpAddress, TokenIssuedAt, UniqueTokenIdentifier
) on UniqueTokenIdentifier
| project-away UniqueTokenIdentifier1
| extend OutsideOfGsa = iff(SignInIpAddress != ActivityIpAddress or isempty(ConnectIds), true, false);
let BlockedSigIns = SignInWithConnections
| where ResultType != "0" and GsaCaStatus == "failure"
| extend OutsideOfGsa = true;
union AzureActivity, GraphActivity, BlockedSigIns
| sort by SignInTime desc
| where ActivityIpAddress contains (FilteredByActivityIpAddress)
| project-reorder SignInTime, UserPrincipalName, SignInIpAddress, ActivityIpAddress, OutsideOfGsa, GsaCaStatus, IsTokenCAE
// Filter for sensitive Actions outside of GSA
//| where OutsideOfGsa == true
//| mv-expand parse_json(Operations) | where Operations.IsSensitive == "true" | project-reorder Operations
};
Token_GsaPrivilegedInterfaceActivity()Token_SessionIdToXdrActivities
let Token_SessionIdToXdrActivities = (T:(SessionId:string)) {
let SensitiveEvents = dynamic([
'New-InboxRule',
'Set-InboxRule',
'HardDelete'
'AnonymousLinkCreated'
]);
let XdrSessionIdActivities = CloudAppEvents
| where tostring(RawEventData.SessionId) in~ (SessionId)
| extend SessionId = tostring(RawEventData.SessionId)
| extend SessionId = iff(isnotempty(SessionId), SessionId, tostring(tostring(parse_json(tostring(RawEventData.AppAccessContext)).AADSessionId)))
| extend IsSensitive = iff(ActionType in~ (SensitiveEvents), true, false)
| extend UniqueTokenIdentifier = parse_json(tostring(RawEventData.AppAccessContext)).UniqueTokenId
| extend Activity = bag_pack_columns(ObjectType, ActionType, ActivityObjects, ActivityType, ReportId, IsSensitive, UniqueTokenIdentifier)
| extend IsCritical = iff(Activity.IsSensitive contains "true", true, false)
| extend IpTags = tostring(IPTags)
| extend IpInsights = bag_pack_columns(IPAddress, ISP, IpCategory = IPCategory, IpTags, IsAnonymousProxy)
| summarize Activity = make_set(Activity) by SessionId, Application, tostring(IpInsights), IsCritical;
XdrSessionIdActivities
};
Token_SessionIdToXdrActivities(SessionId)Token_UtiToXdrActivities
let Token_UtiToXdrActivities = (T:(UniqueTokenId:string)) {
let SensitiveAzEvents = dynamic([
'Microsoft.Authorization/roleAssignments/write',
'Microsoft.Authorization/roleAssignmentScheduleRequests/write',
'Microsoft.Authorization/roleEligibilityScheduleRequests/write',
'Microsoft.Authorization/roleManagementPolicies/write',
'Microsoft.Storage/storageAccounts/listKeys/action'
]);
let SensitiveSaasEvents = dynamic([
'New-InboxRule',
'Set-InboxRule',
'HardDelete'
'AnonymousLinkCreated'
]);
let AzTokenActivities = CloudAppEvents
| where Application == "Microsoft Azure"
| extend UniqueTokenIdentifier = tostring(parse_json(RawEventData).uniqueTokenId)
| where UniqueTokenIdentifier in~ (UniqueTokenId)
| where isnotempty(UniqueTokenIdentifier)
| extend Operation = tostring(parse_json(tostring(RawEventData.properties)).message)
| extend IsSensitive = iff(Operation in~ (SensitiveAzEvents), true, false)
| extend Activity = bag_pack_columns(TimeGenerated, ObjectType, ActionType, ActivityObjects, ActivityType, ReportId, IsSensitive)
| extend IsCritical = iff(Activity.IsSensitive contains "true", true, false)
| extend IpTags = tostring(IPTags)
| extend IpInsights = bag_pack_columns(IPAddress, ISP, IpCategory = IPCategory, IpTags, IsAnonymousProxy)
| summarize Activity = make_set(Activity) by AccountObjectId, UniqueTokenIdentifier, Application, tostring(IpInsights), IsSensitive;
let SaasTokenActivities = CloudAppEvents
| where Application != "Microsoft Azure"
| extend UniqueTokenIdentifier = tostring(parse_json(tostring(RawEventData.AppAccessContext)).UniqueTokenId)
| where UniqueTokenIdentifier in~ (UniqueTokenId)
| where isnotempty(UniqueTokenIdentifier)
| extend Operation = tostring(parse_json(tostring(RawEventData.properties)).message)
| extend IsSensitive = iff(ActionType in~ (SensitiveSaasEvents), true, false)
| extend Activity = bag_pack_columns(TimeGenerated, ObjectType, ActionType, ActivityObjects, ActivityType, ReportId, IsSensitive)
| extend IsCritical = iff(Activity.IsSensitive contains "true", true, false)
| extend IpTags = tostring(IPTags)
| extend IpInsights = bag_pack_columns(IPAddress, ISP, IpCategory = IPCategory, IpTags, IsAnonymousProxy)
| summarize Activity = make_set(Activity) by AccountObjectId, UniqueTokenIdentifier, Application, tostring(IpInsights), IsSensitive;
union AzTokenActivities, SaasTokenActivities
};
Token_UtiToXdrActivities(UniqueTokenId)Entra Connect (Exposure Management)
XSPM HighExposureDevices
let ExposureItems = (ExposureGraphEdges
| where EdgeLabel == "affecting"
| mv-expand TargetNodeCategories
| where TargetNodeCategories == "device"
| join kind=inner ExposureGraphNodes on $left.TargetNodeId == $right.NodeId
| mv-expand EntityIds
| extend EntityType = tostring(EntityIds.type)
| where EntityType == "DeviceInventoryId"
| extend EntityID = tostring(EntityIds.id)
| summarize Item = make_set(SourceNodeName) by EntityID
| extend Case = array_length(Item));
DeviceInfo
| where ExposureLevel in ("Medium", "High")
| summarize arg_max(Timestamp, *) by DeviceId, DeviceName
| join kind=inner ExposureItems on $left.DeviceId == $right.EntityID
| project Timestamp, DeviceId, DeviceName, OSPlatform, ExposureLevel, Case, Item
| order by Case desc XSPM ListDevicesbyCriticalityLevel
// Step 1: Identify servers by criticality levels (Updated logic with expanded coverage)
let CriticalityServers = ExposureGraphNodes
| mv-expand CriticalityData = parse_json(NodeProperties)["rawData"]["criticalityLevel"]["ruleNames"]
| extend CriticalityLevel = tostring(parse_json(NodeProperties)["rawData"]["criticalityLevel"]["criticalityLevel"])
| extend RuleName = tostring(CriticalityData)
| project-reorder NodeId, NodeName, CriticalityLevel, RuleName;
// Step 2: Identify accounts or groups with "authenticate as" permissions
let AuthenticatedEntities = ExposureGraphEdges
| where EdgeLabel == "can authenticate to" // Adjusted EdgeLabel to match "authenticate as" semantics
| where SourceNodeLabel in ("user", "group") // Filtering by SourceNodeLabel for accounts/groups
| project SourceNodeId, SourceNodeName, TargetNodeId // Capturing relevant fields
| join kind=inner ExposureGraphNodes on $left.SourceNodeId == $right.NodeId
| project TargetNodeId, SourceNodeName, SourceNodeId // Linking authenticated accounts/groups to targets
| summarize AccountsOrGroups = make_set(SourceNodeName) by TargetNodeId; // Group accounts/groups by target device
// Step 3: Correlate servers with authenticated accounts or groups
CriticalityServers
| join kind=inner (
AuthenticatedEntities
| project TargetNodeId, AccountsOrGroups
) on $left.NodeId == $right.TargetNodeId // Correcting join keys for correlation
| where CriticalityLevel == '0' or CriticalityLevel == '1'
| project Timestamp = now(), ServerId = NodeId, ServerName = NodeName, CriticalityLevel, RuleName, AccountsOrGroups
| order by ServerName ascEntra Connect App-based Authentication
Added Credentials
let Lookback = 1d;
let EntraConnectAppIdentities = OAuthAppInfo
| where TimeGenerated >ago(7d)
| where parse_json(Permissions) has 'ADSynchronization.ReadWrite.All'
| summarize by AppName;
AuditLogs
| where TimeGenerated >ago(Lookback)
| where OperationName has_any ("Add service principal", "Certificates and secrets management", "Update application")
| where Result =~ "success"
| mv-apply TargetResource = TargetResources on
(
where TargetResource.type =~ "Application" or TargetResource.type =~ "ServicePrincipal"
| extend
TargetName = tostring(TargetResource.displayName),
TargetObjectType = tostring(TargetResource.type),
ResourceId = tostring(TargetResource.id),
AddedKeyEvent = TargetResource.modifiedProperties
)
| where TargetName in~ (EntraConnectAppIdentities)
| extend InitiatingBy = iff(isnotempty(InitiatedBy.user.id), tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName))
| extend InitiatingUserOrAppId = iff(isnotempty(InitiatedBy.user.id), tostring(InitiatedBy.user.id), tostring(InitiatedBy.app.servicePrincipalId))
| extend InitiatingIpAddress = iff(isnotempty(InitiatedBy.user.ipAddress), tostring(InitiatedBy.user.ipAddress), tostring(InitiatedBy.app.ipAddress))
| mv-apply Property = AddedKeyEvent on
(
where Property.displayName =~ "KeyDescription" or Property.displayName =~ "FederatedIdentityCredentials"
| extend
OldValue = parse_json(tostring(Property.oldValue)),
NewValue = parse_json(tostring(Property.newValue))
)
| extend diff = set_difference(NewValue, OldValue)
| parse diff with * "KeyIdentifier=" keyIdentifier: string ",KeyType=" keyType: string ",KeyUsage=" keyUsage: string ",DisplayName=" keyDisplayName: string "]" *
| project ActivityDateTime, ActivityDisplayName, CorrelationId, Result, TargetName, TargetObjectType, InitiatingBy, InitiatingIpAddress, AddedKeyEvent, AddedKeyId = keyIdentifier, OldValue, NewValue
Identify EntraConnectApplicationIdentities
```jsx
let PwdWriteBackAppRoles = dynamic(['PasswordWriteback.RefreshClient.All','PasswordWriteback.RegisterClientVersion.All']);
let SyncApiApproles = dynamic(['ADSynchronization.ReadWrite.All']);
let EntraConnectAppIdentities = OAuthAppInfo
| summarize arg_max(Timestamp, *) by OAuthAppId
| mv-expand Permission = parse_json(Permissions)
| where (parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft Entra AD Synchronization Service' and parse_json(Permission)["PermissionValue"] has_any (SyncApiApproles)) or
(parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft password reset service' and parse_json(Permission)["PermissionValue"] has_any (PwdWriteBackAppRoles))
| summarize Permissions = make_set(Permission) by ServicePrincipalId, OAuthAppId, AppName, AppOwnerTenantId, AppStatus, AddedOnTime, LastModifiedTime
| join kind=leftouter (
ExposureGraphNodes
| where NodeLabel == "serviceprincipal"
| extend OAuthAppId = tostring(parse_json(NodeProperties)["rawData"]["appId"])
| project OAuthAppId, ServicePrincipalNodeId = NodeId
) on OAuthAppId
| join kind=leftouter (
ExposureGraphNodes
| where NodeLabel == @"Microsoft Entra OAuth App"
| mv-expand parse_json(EntityIds)
| where parse_json(EntityIds).type == "AadApplicationId"
| extend OAuthAppId = tostring(parse_json(EntityIds)["id"])
| project OAuthAppId, AppRegistrationNodeId = NodeId
) on OAuthAppId
| project-away OAuthAppId1, OAuthAppId2;
EntraConnectAppIdentities
```Identify EntraConnectServers
let XspmConnectRuleName = 'EntraConnectServer';
let MinVersionForAbaSupport = "2.5.3.0";
let EntraConnectComponents = dynamic([
'microsoft_azure_ad_connect',
'microsoft_entra_connect_sync',
'microsoft_azure_ad_connect_synchronization_services',
'microsoft_entra_connect_synchronization_services'
]);
let EntraConnectIdentifiedByTvm = DeviceTvmSoftwareInventory
| where SoftwareName in~ (EntraConnectComponents)
| extend VersionParts = split(SoftwareVersion, ".")
| extend MinVersionParts = split(MinVersionForAbaSupport, ".")
| extend AbaSupported =
toint(VersionParts[0]) > toint(MinVersionParts[0]) or
(toint(VersionParts[0]) == toint(MinVersionParts[0]) and toint(VersionParts[1]) > toint(MinVersionParts[1])) or
(toint(VersionParts[0]) == toint(MinVersionParts[0]) and toint(VersionParts[1]) == toint(MinVersionParts[1]) and toint(VersionParts[2]) > toint(MinVersionParts[2])) or
(toint(VersionParts[0]) == toint(MinVersionParts[0]) and toint(VersionParts[1]) == toint(MinVersionParts[1]) and toint(VersionParts[2]) == toint(MinVersionParts[2]) and toint(VersionParts[3]) >= toint(MinVersionParts[3]))
| summarize EntraConnectComponents = make_list(EntraConnectComponents) by DeviceName, DeviceId, SoftwareVersion, AbaSupported;
let EntraConnectIdentifiedByXspm = ExposureGraphNodes
| where parse_json(NodeProperties)["rawData"]["criticalityConfidenceHigh"] has (XspmConnectRuleName)
or parse_json(NodeProperties)["rawData"]["criticalityConfidenceLow"] has (XspmConnectRuleName)
| extend CriticalityConfidenceScore = iff(parse_json(NodeProperties)["rawData"]["criticalityConfidenceHigh"] has (XspmConnectRuleName), "High", "Low")
| mv-apply EntityIds = parse_json(EntityIds) on (
where EntityIds.type =~ "DeviceInventoryId"
| extend DeviceId = tostring(EntityIds.id)
)
| extend DeviceName = tostring(parse_json(NodeProperties)["rawData"]["deviceName"])
| extend TpmActivated = tostring(parse_json(NodeProperties)["rawData"]["tpmData"]["activated"])
| project DeviceName, DeviceId, NodeId, CriticalityConfidenceScore, TpmActivated;
let EntraConnectServer = EntraConnectIdentifiedByTvm
| join kind = leftouter ( EntraConnectIdentifiedByXspm ) on DeviceId
| extend VerifiedEntraConnect = iff(CriticalityConfidenceScore == "High", True, False);
EntraConnectServerSignIn DifferentAbaCertificatesAtSameTime
let window = 1h;
let PwdWriteBackAppRoles = dynamic(['PasswordWriteback.RefreshClient.All','PasswordWriteback.RegisterClientVersion.All']);
let SyncApiApproles = dynamic(['ADSynchronization.ReadWrite.All']);
let EntraConnectAppIdentities =
OAuthAppInfo
| where TimeGenerated >ago(7d)
| mv-expand Perm = parse_json(Permissions)
| where (Perm.TargetAppDisplayName == 'Microsoft Entra AD Synchronization Service' and Perm.PermissionValue has_any (SyncApiApproles))
or (Perm.TargetAppDisplayName == 'Microsoft password reset service' and Perm.PermissionValue has_any (PwdWriteBackAppRoles))
| summarize by ServicePrincipalId, OAuthAppId, AppName, AppOwnerTenantId, AppStatus, AddedOnTime, LastModifiedTime;
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(1d)
| where ResultType == "0"
| where isnotempty(ServicePrincipalCredentialKeyId)
| where ServicePrincipalId in (EntraConnectAppIdentities | project ServicePrincipalId)
| extend Window = bin(TimeGenerated, window)
| sort by ServicePrincipalId asc, Window asc, TimeGenerated asc
| serialize
// prevKey only within same SP and window
| extend PrevKey = iff(prev(ServicePrincipalId) == ServicePrincipalId and prev(Window) == Window, prev(ServicePrincipalCredentialKeyId), ServicePrincipalCredentialKeyId)
| extend Switched = iif(ServicePrincipalCredentialKeyId != PrevKey, 1, 0)
| summarize
DistinctKeys = dcount(ServicePrincipalCredentialKeyId),
Switches = sum(Switched),
Events = count(),
ServicePrincipalCredentialKeys = make_set(tostring(ServicePrincipalCredentialKeyId)),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by ServicePrincipalId, Window
// “Alternating” heuristic: exactly two keys present and at least 1 switches (e.g., A→B→A)
| where DistinctKeys >= 2 and Switches >= 1
| order by Switches desc, Events desc
SignIn EntraConnectAbaSuspiciousCredentialType
let PwdWriteBackAppRoles = dynamic(['PasswordWriteback.RefreshClient.All','PasswordWriteback.RegisterClientVersion.All']);
let SyncApiApproles = dynamic(['ADSynchronization.ReadWrite.All']);
let EntraConnectAppIdentities = OAuthAppInfo
| mv-expand Permission = parse_json(Permissions)
| where (parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft Entra AD Synchronization Service' and parse_json(Permission)["PermissionValue"] has_any (SyncApiApproles)) or
(parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft password reset service' and parse_json(Permission)["PermissionValue"] has_any (PwdWriteBackAppRoles))
| summarize Permissions = make_set(Permission) by ServicePrincipalId, OAuthAppId, AppName, AppOwnerTenantId, AppStatus, AddedOnTime, LastModifiedTime;
AADServicePrincipalSignInLogs
| where ResultType == "0"
| where ServicePrincipalId in (EntraConnectAppIdentities)
// Enrichment to EntraIdSpnSignInEvents to get ReportId (required field for detection) and RequestId (for hunting in activity logs)
| join kind = inner ( EntraIdSpnSignInEvents ) on CorrelationId
| project Timestamp = CreatedDateTime, IPAddress, ClientCredentialType, FederatedCredentialId, ServicePrincipalCredentialKeyId, ServicePrincipalCredentialThumbprint, UserAgent, ResourceDisplayName, ResultType, SessionId, UniqueTokenIdentifier, Location, ReportId, RequestId
| extend MaliciousCredential = case(
isnotempty(FederatedCredentialId)
, "App Authentication is using Federated Credentials instead of a certificate",
ClientCredentialType != "clientAssertion"
, "App Authentication is using credentials instead of a certificate",
""
)
| where isnotempty(MaliciousCredential)SignIn NewCertificateOutsideOfAbaRotation
let Lookback = 1h;
let PwdWriteBackAppRoles = dynamic(['PasswordWriteback.RefreshClient.All','PasswordWriteback.RegisterClientVersion.All']);
let SyncApiApproles = dynamic(['ADSynchronization.ReadWrite.All']);
let EntraConnectAppIdentities = OAuthAppInfo
| where TimeGenerated >ago(14d)
| summarize arg_max(Timestamp, *) by OAuthAppId
| mv-expand Permission = parse_json(Permissions)
| where (parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft Entra AD Synchronization Service' and parse_json(Permission)["PermissionValue"] has_any (SyncApiApproles)) or
(parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft password reset service' and parse_json(Permission)["PermissionValue"] has_any (PwdWriteBackAppRoles))
| summarize Permissions = make_set(Permission) by ServicePrincipalId, OAuthAppId, AppName, AppOwnerTenantId, AppStatus, AddedOnTime, LastModifiedTime;
AADServicePrincipalSignInLogs
| where TimeGenerated >ago(Lookback)
| where ServicePrincipalId in (EntraConnectAppIdentities)
| where ResultType == "0"
// Recent sign-ins from this Thumbprint
| join kind=anti (
AADServicePrincipalSignInLogs
| where TimeGenerated <ago(Lookback)
| where ResultType == "0"
| where ServicePrincipalId in (EntraConnectAppIdentities)
) on ServicePrincipalId, ServicePrincipalCredentialThumbprint
| summarize SignInStartTimeUtc = min(TimeGenerated), SignInEndTimeUtc = max(TimeGenerated), NumberOfSignIns = count() by ServicePrincipalId, ServicePrincipalName, ServicePrincipalCredentialThumbprint
| join kind=leftouter (
SecurityEvent
| where EventSourceName == @"Directory Synchronization" and EventID == "1013"
| where TimeGenerated >ago(30d)
| extend EventData = parse_xml(EventData)
| extend EventData = tostring(EventData.EventData.Data)
| extend CertificateThumbprint = extract(@"CertificateThumbprint=([0-9A-Fa-f]{40})", 1, EventData)
| extend CertificateSHA256Hash = extract(@"CertificateSHA256Hash=([0-9A-Fa-f]{64})", 1, EventData)
| extend HasTpmProvider = EventData contains "with TPM crypto provider"
| where isnotempty(CertificateThumbprint) and isnotempty(CertificateSHA256Hash)
| project TimeGenerated, CertificateThumbprint, CertificateSHA256Hash, HasTpmProvider
| join kind=leftouter (
SecurityEvent
| where TimeGenerated >ago(Lookback)
| where EventSourceName == "Entra Connect Admin Actions"
| extend CreatedOnEntraConnect = true
| extend Data=parse_xml(EventData)
| extend EventData=Data.DataItem.EventData.Data
| extend EventStatus = parse_json(tostring(parse_json(tostring(Data.EventData)).Data)).Status
| extend EventAction = parse_json(tostring(parse_json(tostring(Data.EventData)).Data)).Name
| where EventAction == "RotateApplicationCertificate"
| extend EventTimestamp = parse_json(tostring(parse_json(tostring(Data.EventData)).Data)).Timestamp
| extend EventData = parse_json(tostring(parse_json(tostring(Data.EventData)).Data))
| extend EventUser = tostring(parse_json(EventData.User))
| extend EventDetails = tostring(parse_json(EventData.Details))
| extend
AppId = extract(@"ApplicationId: ([^,]+),", 1, EventDetails),
ServicePrincipalCredentialThumbprint = extract(@"CertificateThumbprint: ([^,]+),", 1, EventDetails),
CertificateSHA256Hash = extract(@"CertificateSHA256Hash: ([^\s]+)", 1, EventDetails)
| extend CertRotateEvent = bag_pack_columns(EventTimestamp, EventData, EventAction, EventStatus, EventUser, EventDetails)
| summarize CertRotateEvents = make_set(CertRotateEvent) by AppId, ServicePrincipalCredentialThumbprint, CertificateSHA256Hash, CreatedOnEntraConnect
) on $left.CertificateThumbprint == $right.ServicePrincipalCredentialThumbprint and $left.CertificateSHA256Hash == $right.CertificateSHA256Hash
| extend AdminRotation = iff(isnotempty(CertRotateEvents), true, false)
) on ServicePrincipalCredentialThumbprint
| extend MaliciousCredential = case(
HasTpmProvider == false
, "Used credentials has been created outside of TPM on Entra Connect server",
AdminRotation == true
, "Credential has been rotated on Entra Connect server",
isempty(CreatedOnEntraConnect)
, "Credential not been created or rotated by Entra Connect sync service"
, ""
)
| project-reorder MaliciousCredential
TokenAcquisition OutsideOfEntraConnectServer
let PwdWriteBackAppRoles = dynamic(['PasswordWriteback.RefreshClient.All','PasswordWriteback.RegisterClientVersion.All']);
let SyncApiApproles = dynamic(['ADSynchronization.ReadWrite.All']);
let EntraConnectAppIdentities = OAuthAppInfo
| where TimeGenerated >ago(14d)
| summarize arg_max(Timestamp, *) by OAuthAppId
| mv-expand Permission = parse_json(Permissions)
| where (parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft Entra AD Synchronization Service' and parse_json(Permission)["PermissionValue"] has_any (SyncApiApproles)) or
(parse_json(Permission)["TargetAppDisplayName"] == 'Microsoft password reset service' and parse_json(Permission)["PermissionValue"] has_any (PwdWriteBackAppRoles))
| summarize Permissions = make_set(Permission) by ServicePrincipalId, OAuthAppId, AppName, AppOwnerTenantId, AppStatus, AddedOnTime, LastModifiedTime;
AADServicePrincipalSignInLogs
| where TimeGenerated >ago(1h)
| where ServicePrincipalId in (EntraConnectAppIdentities)
| join kind = anti (
SecurityEvent
| where TimeGenerated >ago(1h)
| where EventSourceName == @"Directory Synchronization"
| extend EventData = tostring(parse_xml(EventData).EventData.Data)
| extend CorrelationId = extract(@"(?i)CorrelationId\(([0-9a-fA-F-]{36})\)", 1, EventData)
| where isnotempty(CorrelationId)
) on CorrelationId
| extend Timestamp = CreatedDateTime
| project-reorder Timestamp, ClientCredentialType, ServicePrincipalCredentialThumbprint, IPAddress