diff --git a/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml b/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml new file mode 100644 index 00000000000..2b9aa5e46c5 --- /dev/null +++ b/Hunting Queries/SigninLogs/DormantPrivilegedIdentities.yaml @@ -0,0 +1,72 @@ +id: 0a70fd03-6f58-4df3-ab39-f9d123d538cd +name: Dormant privileged identities with no recent sign-ins +description: | + Finds enabled identity objects that hold directory roles or Entra PIM + roles but have not performed a successful sign-in in the last 90 days. + These accounts remain licensed and enabled yet unmanaged, a common + 'licensed but minimally configured' gap: an attacker who obtains or + reuses these credentials inherits the group's privilege without + triggering new role-assignment audit events. +description-detailed: | + Leverages the Microsoft 365 Defender IdentityInfo table, which exposes + assigned and PIM-eligible roles, BlastRadius and account enablement + state, correlated against SigninLogs to surface identities with zero + successful logons within the lookback. Review whether each account is + still required, rotate credentials for any that remain, and consider + removing unneeded role assignments or triggering just-in-time access. + References: + - https://learn.microsoft.com/entra/identity/role-based-access-control/groups-concept + - https://attack.mitre.org/techniques/T1078/004/ +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - connectorId: AzureActiveDirectory + dataTypes: + - SigninLogs +tactics: + - Persistence + - PrivilegeEscalation +relevantTechniques: + - T1078.004 +query: | + let lookback = 90d; + IdentityInfo + | summarize arg_max(TimeGenerated, *) by AccountObjectId + | where IsAccountEnabled == true + | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) + | join kind=leftanti ( + SigninLogs + | where TimeGenerated > ago(lookback) + | where ResultType == "0" + | project AccountObjectId = UserId + ) on AccountObjectId + | extend + AccountName = tostring(split(AccountUpn, "@")[0]), + AccountUPNSuffix = tostring(split(AccountUpn, "@")[1]) + | project + AccountObjectId, AccountUpn, AccountName, AccountUPNSuffix, AccountDisplayName, + BlastRadius, AssignedRoles, PrivilegedEntraPimRoles, + Department, JobTitle + | sort by BlastRadius desc +entityMappings: + - entityType: Account + fieldMappings: + - identifier: FullName + columnName: AccountUpn + - identifier: Name + columnName: AccountName + - identifier: UPNSuffix + columnName: AccountUPNSuffix + - identifier: AadUserId + columnName: AccountObjectId +version: 1.0.1 +metadata: + source: + kind: Community + author: + name: d4rk-pri0r + support: + tier: Community + categories: + domains: [ "Security - Threat Protection", "Identity" ] diff --git a/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml b/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml new file mode 100644 index 00000000000..25be01d8244 --- /dev/null +++ b/Hunting Queries/SigninLogs/PrivilegedAccountsUsingLegacyAuthentication.yaml @@ -0,0 +1,96 @@ +id: 06184800-6d66-46ce-aabd-7e17e9cf3fb0 +name: Privileged identities authenticating via legacy protocols +description: | + Identifies successful legacy-protocol sign-ins (Exchange ActiveSync, + IMAP4, POP3, SMTP Auth, MAPI over HTTP, and other legacy clients) by + accounts that hold directory or PIM roles. Legacy authentication + bypasses modern Conditional Access controls and MFA in most + configurations, so a tenant may be licensed for Entra ID P2 and + Defender yet leave its highest-privilege accounts reachable through + unmanaged clients. +description-detailed: | + Joins privileged accounts from IdentityInfo (AssignedRoles / + PrivilegedEntraPimRoles) against SigninLogs where ClientAppUsed matches + a legacy protocol and the sign-in succeeded. Review each hit against + Conditional Access 'legacy authentication' blocking policies and + block-legacy-auth settings in Exchange Online; disable the legacy + protocol for any account that does not require it. Protocol names here + follow the SigninLogs ClientAppUsed values used by existing repo + legacy-auth detection patterns. + References: + - https://learn.microsoft.com/entra/identity/conditional-access/block-legacy-authentication + - https://attack.mitre.org/techniques/T1078/004/ + - https://attack.mitre.org/techniques/T1110/003/ +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - connectorId: AzureActiveDirectory + dataTypes: + - SigninLogs +tactics: + - InitialAccess + - CredentialAccess +relevantTechniques: + - T1078.004 + - T1110.003 +query: | + let timeframe = 30d; + let LegacyProtocols = dynamic([ + "Exchange ActiveSync", + "IMAP4", + "MAPI over HTTP", + "POP3", + "SMTP Auth", + "Authenticated SMTP", + "Other clients" + ]); + let PrivilegedUsers = IdentityInfo + | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) + | summarize arg_max(TimeGenerated, AccountUpn) by AccountObjectId; + SigninLogs + | where TimeGenerated > ago(timeframe) + | where ResultType == "0" + | where ClientAppUsed in~ (LegacyProtocols) + | join kind=inner PrivilegedUsers on $left.UserId == $right.AccountObjectId + | extend + AccountName = tostring(split(UserPrincipalName, "@")[0]), + AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1]) + | summarize + SignInAttempts = count(), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated), + ClientApps = make_set(ClientAppUsed), + AuthRequirements = make_set(AuthenticationRequirement), + LastIP = tostring(arg_max(TimeGenerated, IPAddress)) + by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName + | project + UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn, + SignInAttempts, FirstSeen, LastSeen, ClientApps, AuthRequirements, + LastIP, AppDisplayName + | sort by SignInAttempts desc +entityMappings: + - entityType: Account + fieldMappings: + - identifier: FullName + columnName: UserPrincipalName + - identifier: Name + columnName: AccountName + - identifier: UPNSuffix + columnName: AccountUPNSuffix + - identifier: AadUserId + columnName: UserId + - entityType: IP + fieldMappings: + - identifier: Address + columnName: LastIP +version: 1.0.1 +metadata: + source: + kind: Community + author: + name: d4rk-pri0r + support: + tier: Community + categories: + domains: [ "Security - Threat Protection", "Identity" ] diff --git a/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml b/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml new file mode 100644 index 00000000000..c406bb3b790 --- /dev/null +++ b/Hunting Queries/SigninLogs/PrivilegedSigninsWithoutConditionalAccessSuccess.yaml @@ -0,0 +1,85 @@ +id: 87245d60-eefb-42dd-9748-cd1949c83a5e +name: Privileged identities whose sign-ins are not protected by Conditional Access +description: | + Identifies privileged identity objects (directory or PIM roles assigned) + whose successful sign-ins do not have a Conditional Access policy applied. + Entra ID P2 licenses the CA engine, but if no policy gates these accounts + they remain a 'licensed but minimally configured' gap: MFA and device + controls that the tenant already pays for are never enforced. +description-detailed: | + Joins privileged accounts from the Microsoft 365 Defender IdentityInfo + table (AssignedRoles / PrivilegedEntraPimRoles) against SigninLogs, + keeping only successful logons whose ConditionalAccessStatus is not + 'success' (including empty/null, notApplied, notEnabled, or failure). + Review whether a policy intended to cover privileged roles is + misconfigured, out of scope, or disabled (compare with the + ConditionalAccessPolicyDisabled family of hunting queries in + Hunting Queries/AuditLogs), and prioritize accounts that also + authenticate without MFA (AuthenticationRequirement = + singleFactorAuthentication). + References: + - https://learn.microsoft.com/entra/identity/conditional-access/overview + - https://attack.mitre.org/techniques/T1078/004/ +requiredDataConnectors: + - connectorId: MicrosoftThreatProtection + dataTypes: + - IdentityInfo + - connectorId: AzureActiveDirectory + dataTypes: + - SigninLogs +tactics: + - InitialAccess +relevantTechniques: + - T1078.004 +query: | + let timeframe = 30d; + let PrivilegedUsers = IdentityInfo + | where isnotempty(AssignedRoles) or isnotempty(PrivilegedEntraPimRoles) + | summarize arg_max(TimeGenerated, AccountUpn) by AccountObjectId; + SigninLogs + | where TimeGenerated > ago(timeframe) + | where ResultType == "0" + | extend ConditionalAccessStatus = coalesce(ConditionalAccessStatus, "notEvaluated") + | where ConditionalAccessStatus !~ "success" + | join kind=inner PrivilegedUsers on $left.UserId == $right.AccountObjectId + | extend + AccountName = tostring(split(UserPrincipalName, "@")[0]), + AccountUPNSuffix = tostring(split(UserPrincipalName, "@")[1]) + | summarize + SignInAttempts = count(), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated), + ConditionalAccessStatuses = make_set(ConditionalAccessStatus), + AuthRequirements = make_set(AuthenticationRequirement), + LastIP = tostring(arg_max(TimeGenerated, IPAddress)) + by UserPrincipalName, AccountName, AccountUPNSuffix, UserId, AccountUpn, AppDisplayName + | project + UserPrincipalName, AccountName, AccountUPNSuffix, AccountUpn, + SignInAttempts, FirstSeen, LastSeen, ConditionalAccessStatuses, + AuthRequirements, LastIP, AppDisplayName + | sort by SignInAttempts desc +entityMappings: + - entityType: Account + fieldMappings: + - identifier: FullName + columnName: UserPrincipalName + - identifier: Name + columnName: AccountName + - identifier: UPNSuffix + columnName: AccountUPNSuffix + - identifier: AadUserId + columnName: UserId + - entityType: IP + fieldMappings: + - identifier: Address + columnName: LastIP +version: 1.0.1 +metadata: + source: + kind: Community + author: + name: d4rk-pri0r + support: + tier: Community + categories: + domains: [ "Security - Threat Protection", "Identity" ]