Detection rules › Kusto

Detect credential add to Connect Sync Application

Author
Robbe Van den Daele
Source
github.com/HybridBrothers/Hunting-Queries-Detection-Rules

This detection specifically flags credentials being added to the Connect Sync Application in Entra ID, a technique known to have persistance from On-premise AD DS to Entra ID. It tries to look at both certificate, client secret, and federated credentials being added, and tries to remove legitimate renewal processes. Since the legitimate renewal process first adds a new certificate only te remove the old one short after, we by default allow for a maximum of 1 minute between the certificate create and delete events.

MITRE ATT&CK coverage

References

Event coverage

Rule body yaml

// Flag adding credential that does not look like a renewal
let base = materialize (
    AuditLogs
    // Search events happening on the Sync Account
    | extend AppName = tostring(TargetResources[0].displayName)
    | where AppName startswith "ConnectSyncProvisioning_"
    // Only get cretificate or secret changes
    | where OperationName has_any ("Add service principal", "Certificates and secrets management", "Update application")
    // Expand the target resources and modified properties, and only use events ralted to KeyDescription
    | mv-expand TargetResources
    | extend ModifiedProperties = TargetResources.modifiedProperties
    | mv-expand ModifiedProperties
    | where ModifiedProperties.displayName == "KeyDescription"
    // Save the old and new values of the secrets on the application
    | extend OldValue = tostring(ModifiedProperties.oldValue), 
        NewValue = tostring(ModifiedProperties.newValue)
    // Save the old and new credential names in an array
    | extend OldCredentialNames = extract_all(@"DisplayName=([^\]]+)", dynamic([1]), OldValue),
        NewCredentialNames = extract_all(@"DisplayName=([^\]]+)", dynamic([1]), NewValue)
);
let newCredsAdd = (
    base
    // Flag when there are more new credentials than old ones
    | where array_length(OldCredentialNames) < array_length(NewCredentialNames)
    | project-rename NewCredAddTimeGenerated = TimeGenerated
);
let credRemove = (
    base
    // Get events where credentials are being removed
    | where array_length(OldCredentialNames) > array_length(NewCredentialNames)
    | project-rename CredRemoveTimeGenerated = TimeGenerated
);
// Only flag when credentials are added without another being removed for the same application within a small time window
// This excludes a normal renewal process
newCredsAdd
| join kind=leftouter credRemove on AppName
| where CredRemoveTimeGenerated - NewCredAddTimeGenerated > 1m
| project NewCredAddTimeGenerated, CredRemoveTimeGenerated, OperationName, AdditionalDetails, InitiatedBy, AppName, ModifiedProperties, OldCredentialNames, NewCredentialNames

Stages and Predicates

Let binding: credRemove

let credRemove = (
    base
    | where array_length(OldCredentialNames) > array_length(NewCredentialNames)
    | project-rename CredRemoveTimeGenerated = TimeGenerated
);

Derived from base.

The stages below define let newCredsAdd (the rule's main pipeline source).

Stage 1: source

AuditLogs

Stage 2: extend

| extend AppName = tostring(TargetResources[0].displayName)

Stage 3: where

| where AppName startswith "ConnectSyncProvisioning_"

Stage 4: where

| where OperationName has_any ("Add service principal", "Certificates and secrets management", "Update application")

Stage 5: mv-expand

| mv-expand TargetResources

Stage 6: extend

| extend ModifiedProperties = TargetResources.modifiedProperties

Stage 7: mv-expand

| mv-expand ModifiedProperties

Stage 8: where

| where ModifiedProperties.displayName == "KeyDescription"

Stage 9: extend

| extend OldValue = tostring(ModifiedProperties.oldValue), 
        NewValue = tostring(ModifiedProperties.newValue)

Stage 10: extend

| extend OldCredentialNames = extract_all(@"DisplayName=([^\]]+)", dynamic([1]), OldValue),
        NewCredentialNames = extract_all(@"DisplayName=([^\]]+)", dynamic([1]), NewValue)

Stage 11: where

| where array_length(OldCredentialNames) < array_length(NewCredentialNames)

Stage 12: project-rename

| project-rename NewCredAddTimeGenerated = TimeGenerated

The stages below run on newCredsAdd (the outer pipeline).

Stage 13: join

newCredsAdd
| join kind=leftouter credRemove on AppName

Stage 14: where where CredRemoveTimeGenerated - NewCredAddTimeGenerated > 1m

| where CredRemoveTimeGenerated - NewCredAddTimeGenerated > 1m

Stage 15: project

| project NewCredAddTimeGenerated, CredRemoveTimeGenerated, OperationName, AdditionalDetails, InitiatedBy, AppName, ModifiedProperties, OldCredentialNames, NewCredentialNames

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
AppNamestarts_with
  • ConnectSyncProvisioning_
OldCredentialNamescross_field_compare
  • NewCredentialNames transforms: op:lt, lhs:array_length, rhs:array_length
OperationNamematch
  • Add service principal
  • Certificates and secrets management
  • Update application
displayNameeq
  • KeyDescription transforms: cased

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
AdditionalDetailsproject
AppNameproject
CredRemoveTimeGeneratedproject
InitiatedByproject
ModifiedPropertiesproject
NewCredAddTimeGeneratedproject
NewCredentialNamesproject
OldCredentialNamesproject
OperationNameproject