Service principal or application credential addition by a rarely observed actor
Description
Hunting query that looks for credential additions or updates on service principals and applications performed by actors (users or apps) that have not been observed initiating the same operations in the previous 90 days. Covered operations are "Add service principal credentials" and "Update application - Certificates and secrets management", which correspond to adding passwordCredentials or keyCredentials to an existing registration. A rarely observed actor performing these operations can indicate persistence activity, such as a compromised account adding a backdoor credential to a high-privilege application. This query is hypothesis-driven and requires analyst validation. Benign matches include newly onboarded administrators, infrastructure-as-code pipelines running for the first time, and certificate rotation performed by a different operator than usual. References: - https://learn.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities - https://attack.mitre.org/techniques/T1098/001/
Query · kql
let starttime = todatetime('{{StartTimeISO}}');
let endtime = todatetime('{{EndTimeISO}}');
let baselineLookback = starttime - 90d;
let credOps = dynamic([
"Add service principal credentials",
"Update application - Certificates and secrets management"
]);
// Build 90-day baseline of actors who have previously performed credential operations
let KnownActors =
AuditLogs
| where TimeGenerated between (baselineLookback .. starttime)
| where OperationName in~ (credOps)
| extend ActorUpn = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName)
| extend ActorApp = tostring(parse_json(tostring(InitiatedBy.app)).displayName)
| extend Actor = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
| where isnotempty(Actor)
| summarize by Actor;
// Credential operations in the current window
AuditLogs
| where TimeGenerated between (starttime .. endtime)
| where OperationName in~ (credOps)
| extend ActorUpn = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName)
| extend ActorApp = tostring(parse_json(tostring(InitiatedBy.app)).displayName)
| extend ActorIp = iff(
isnotempty(tostring(parse_json(tostring(InitiatedBy.user)).ipAddress)),
tostring(parse_json(tostring(InitiatedBy.user)).ipAddress),
tostring(parse_json(tostring(InitiatedBy.app)).ipAddress))
| extend Actor = iff(isnotempty(ActorUpn), ActorUpn, ActorApp)
| where isnotempty(Actor)
// Keep only actors not seen in the 90-day baseline
| join kind=leftanti KnownActors on Actor
| extend TargetDisplayName = tostring(TargetResources[0].displayName)
| extend TargetId = tostring(TargetResources[0].id)
| extend TargetType = tostring(TargetResources[0].type)
| extend AccountName = iff(ActorUpn has "@", tostring(split(ActorUpn, "@")[0]), Actor)
| extend AccountUPNSuffix = iff(ActorUpn has "@", tostring(split(ActorUpn, "@")[1]), "")
| project
TimeGenerated,
OperationName,
Actor,
AccountName,
AccountUPNSuffix,
ActorApp,
ActorIp,
TargetDisplayName,
TargetId,
TargetType,
CorrelationId,
Result
| sort by TimeGenerated desc