Hunt for Trusted Cloud Apps Suspicious Behavior (Lessons from the ConnectWise Compromise) | SecurityHQ
Six behavioral threat hunts for Microsoft Entra ID and Microsoft Sentinel
Could an attacker use an approved backup, monitoring, or productivity integration to access Microsoft 365 without ever signing in as an employee? Yes. Application identities authenticate without interactive user sessions, and compromised credentials can reuse legitimate permissions. The hunting challenge is to distinguish ordinary automation from a meaningful deviation.
The ConnectWise/SkyKick incident illustrates the risk, but the detection opportunity is much broader. SHQ Adversary Labs presents six behavior-based hunting hypotheses that security teams can apply to established applications, regardless of vendor. The aim is to identify suspicious combinations of activity rather than generate alerts for every routine change.
| Signal | Why it matters |
|---|---|
| Identity or credential changes | May indicate the application was repurposed or persistence was established |
| Network and client changes | Anomalous ASN plus scripting behavior is stronger than a new IP alone |
| Resource and privilege changes | Unfamiliar workload access or role modification increases potential impact |
Six high-signal KQL hunting hypotheses
New user agent + New resource
An established service principal authenticates with a previously unseen client and requests a resource outside its observed history.
let Baseline = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(30d) .. ago(2d)) and tostring(ResultType) == "0" | where isnotempty(UserAgent) and isnotempty(ResourceDisplayName) | summarize KnownUA=make_set(tolower(UserAgent),500), KnownResources=make_set(tolower(ResourceDisplayName),500), BaselineCount=count() by AADTenantId, AppId, ServicePrincipalId | where BaselineCount >= 20; AADServicePrincipalSignInLogs | where TimeGenerated > ago(2d) and tostring(ResultType) == "0" | where isnotempty(UserAgent) and isnotempty(ResourceDisplayName) | extend UA=tolower(UserAgent), Resource=tolower(ResourceDisplayName) | join kind=inner Baseline on AADTenantId, AppId, ServicePrincipalId | where not(set_has_element(KnownUA,UA)) and not(set_has_element(KnownResources,Resource)) | project TimeGenerated, ServicePrincipalName, AppId, IPAddress, AutonomousSystemNumber, UserAgent, ResourceDisplayName, BaselineCount, KnownUA, KnownResources | order by TimeGenerated desc
Investigation note: Check vendor updates, approved feature activation and actual workload operations; exact user-agent strings may vary across normal releases.
New credential key + New ASN
An established identity authenticates successfully using an unseen credential key from an ASN absent from its history.
let Baseline = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(30d) .. ago(2d)) and tostring(ResultType) == "0" | where isnotempty(ServicePrincipalCredentialKeyId) and isnotempty(AutonomousSystemNumber) | summarize KnownKeys=make_set(ServicePrincipalCredentialKeyId,500), KnownASN=make_set(tostring(AutonomousSystemNumber),500), BaselineCount=count() by AADTenantId, AppId, ServicePrincipalId | where BaselineCount >= 20; AADServicePrincipalSignInLogs | where TimeGenerated > ago(2d) and tostring(ResultType) == "0" | where isnotempty(ServicePrincipalCredentialKeyId) and isnotempty(AutonomousSystemNumber) | extend ASN=tostring(AutonomousSystemNumber) | join kind=inner Baseline on AADTenantId, AppId, ServicePrincipalId | where not(set_has_element(KnownKeys,ServicePrincipalCredentialKeyId)) and not(set_has_element(KnownASN,ASN)) | project TimeGenerated, ServicePrincipalName, AppId, IPAddress, ASN, ServicePrincipalCredentialKeyId, ClientCredentialType, ResourceDisplayName, KnownKeys, KnownASN | order by TimeGenerated desc
Investigation note: Confirm credential rotation approvals and key creation time. A new key in sign-in logs is not by itself evidence of a malicious credential addition.
Dormant application + Scripting client
A historically active but recently dormant service principal resumes successful authentication with a script-like user agent.
let Historical = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(90d) .. ago(32d)) and tostring(ResultType) == "0" | summarize LastHistoricalSignIn=max(TimeGenerated), HistoricalCount=count() by AADTenantId, AppId, ServicePrincipalId; let Recent = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(32d) .. ago(2d)) and tostring(ResultType) == "0" | summarize by AADTenantId, AppId, ServicePrincipalId; AADServicePrincipalSignInLogs | where TimeGenerated > ago(2d) and tostring(ResultType) == "0" | where UserAgent contains "python" or UserAgent contains "curl/" or UserAgent contains "PowerShell" or UserAgent contains "Go-http-client" | join kind=inner Historical on AADTenantId, AppId, ServicePrincipalId | join kind=leftanti Recent on AADTenantId, AppId, ServicePrincipalId | project TimeGenerated, ServicePrincipalName, AppId, IPAddress, AutonomousSystemNumber, UserAgent, ResourceDisplayName, LastHistoricalSignIn, HistoricalCount | order by TimeGenerated desc
Investigation note: Check quarterly maintenance, scheduled recovery, and whether at least 90 days of historical records are actually available.
Multiple unfamiliar resources + Scripting client
An established app uses a scripting client to request tokens for two or more resources that were absent from the baseline.
let Baseline = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(30d) .. ago(2d)) and tostring(ResultType) == "0" and isnotempty(ResourceDisplayName) | summarize KnownResources=make_set(tolower(ResourceDisplayName),500), BaselineCount=count() by AADTenantId, AppId, ServicePrincipalId | where BaselineCount >= 20; AADServicePrincipalSignInLogs | where TimeGenerated > ago(2d) and tostring(ResultType) == "0" and isnotempty(ResourceDisplayName) | where UserAgent contains "python" or UserAgent contains "curl/" or UserAgent contains "PowerShell" or UserAgent contains "Go-http-client" | extend Resource=tolower(ResourceDisplayName) | join kind=inner Baseline on AADTenantId, AppId, ServicePrincipalId | where not(set_has_element(KnownResources,Resource)) | summarize NewResourceCount=dcount(Resource), NewResources=make_set(Resource,30), SourceIPs=make_set(IPAddress,20), ASNs=make_set(AutonomousSystemNumber,20), UserAgents=make_set(UserAgent,20), FirstSeen=min(TimeGenerated), LastSeen=max(TimeGenerated) by AADTenantId, AppId, ServicePrincipalId, ServicePrincipalName, bin(TimeGenerated,1h) | where NewResourceCount >= 2 | order by NewResourceCount desc, LastSeen desc
Investigation note: A token request does not demonstrate data exfiltration. Correlate with resource-specific Microsoft 365 audit events.
Credential modification + Suspicious authentication
A credential change targeting a service-principal object is followed by scripted authentication from a previously unseen ASN.
let Baseline = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(30d) .. ago(2d)) and tostring(ResultType) == "0" and isnotempty(AutonomousSystemNumber) | summarize KnownASN=make_set(tostring(AutonomousSystemNumber),500) by AADTenantId, AppId, ServicePrincipalId; let Changes = AuditLogs | where TimeGenerated > ago(2d) and Category =~ "ApplicationManagement" and Result =~ "success" | where OperationName has "credential" or OperationName has "Certificates and secrets" or OperationName has "Update application" or OperationName has "Update service principal" | mv-expand Target=TargetResources | extend ChangedObjectId=tostring(Target.id), ChangedApp=tostring(Target.displayName), Properties=tostring(Target.modifiedProperties) | where OperationName has "credential" or OperationName has "Certificates and secrets" or Properties has "passwordCredentials" or Properties has "keyCredentials" | project AuditTime=TimeGenerated, ChangedObjectId, ChangedApp, OperationName, InitiatedBy, Properties; AADServicePrincipalSignInLogs | where TimeGenerated > ago(2d) and tostring(ResultType) == "0" and isnotempty(AutonomousSystemNumber) | where UserAgent contains "python" or UserAgent contains "curl/" or UserAgent contains "PowerShell" or UserAgent contains "Go-http-client" | extend ASN=tostring(AutonomousSystemNumber) | join kind=inner Baseline on AADTenantId, AppId, ServicePrincipalId | where not(set_has_element(KnownASN,ASN)) | join kind=inner Changes on $left.ServicePrincipalId == $right.ChangedObjectId | where TimeGenerated between (AuditTime .. AuditTime + 24h) | project TimeGenerated, AuditTime, ServicePrincipalName, AppId, IPAddress, ASN, UserAgent, ResourceDisplayName, OperationName, InitiatedBy, ChangedApp, Properties | order by TimeGenerated desc
Investigation note: Important scope: run per tenant. App registration credential changes often target the application object, not the service principal; full coverage requires Microsoft Graph object-ID mapping.
Privileged role change + New ASN
let Baseline = AADServicePrincipalSignInLogs
| where TimeGenerated between (ago(30d) .. ago(2d)) and tostring(ResultType) == "0" and isnotempty(AutonomousSystemNumber)
| summarize KnownASN=make_set(tostring(AutonomousSystemNumber),500) by AADTenantId, AppId, ServicePrincipalId;
let RoleChanges = AuditLogs
| where TimeGenerated > ago(2d) and Category =~ "RoleManagement" and Result =~ "success"
| where OperationName has "Add" and OperationName has "role"
| extend ActorAppId=tostring(InitiatedBy.app.appId), TargetText=tostring(TargetResources)
| where isnotempty(ActorAppId) and TargetText has_any ("Global Administrator","Privileged Role Administrator","Application Administrator","Cloud Application Administrator","Exchange Administrator")
| project RoleChangeTime=TimeGenerated, ActorAppId, OperationName, TargetText, Result;
AADServicePrincipalSignInLogs
| where TimeGenerated > ago(2d) and tostring(ResultType) == "0" and isnotempty(AutonomousSystemNumber)
| extend ASN=tostring(AutonomousSystemNumber)
| join kind=inner Baseline on AADTenantId, AppId, ServicePrincipalId
| where not(set_has_element(KnownASN,ASN))
| join kind=inner RoleChanges on $left.AppId == $right.ActorAppId
| where TimeGenerated between (RoleChangeTime - 1h .. RoleChangeTime + 24h)
| project TimeGenerated, RoleChangeTime, ServicePrincipalName, AppId, IPAddress, ASN, KnownASN, UserAgent, ResourceDisplayName, OperationName, TargetText, Result
| order by TimeGenerated desc
Investigation note: Important scope: run within a single customer tenant, or add a verified tenant key to both tables. Audit initiator and role recipient are distinct; inspect the full target record before claiming self-elevation.
Conclusion
A new IP address is ordinary for many cloud integrations. A new ASN combined with an unfamiliar client, a new credential, unexpected resource access or a privileged change tells a much more actionable story. Through these SHQ Adversary Labs hunting hypotheses, security teams can chain behaviors and ground investigations in application history, reducing noise while expanding visibility into trusted application identities.
Hunting research and documentation: SHQ Adversary Labs. The included KQL is provided for analyst validation and customer-specific tuning; it does not represent confirmed findings in any particular tenant.
References
Microsoft Learn – AADServicePrincipalSignInLogs table schema: https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/aadserviceprincipalsigninlogs
ConnectWise Trust Center: https://www.connectwise.com/company/trust/advisories
ConnectWise Suspends Cloud Backup: https://thehackernews.com/2026/10/connectwise-suspends-cloud-backup-after.html