Skip to content

Azure Control Plane and Storage

These hunts are written for Microsoft Sentinel / Log Analytics. Run them in the workspace that receives the named Azure resource logs, then tune the windows and thresholds against normal administrator and application behavior.

Rare Azure remote-execution path

Purpose: Find a VM Run Command, Custom Script Extension, or Cloud Shell storage-key path that has not appeared with the same caller, source IP, resource, and method during the preceding 14 days.

Run first

Rare Azure remote-execution path
let baselineWindow = 14d;
let detectionWindow = 1d;
let RemoteExecution = materialize(
    AzureActivity
    | where TimeGenerated >= ago(baselineWindow)
    | extend RemotePath = case(
        OperationNameValue =~ "Microsoft.Compute/virtualMachines/runCommand/action", "VM Run Command",
        OperationName =~ "Create or Update Virtual Machine Extension", "Custom Script Extension",
        OperationNameValue =~ "Microsoft.Storage/storageAccounts/listKeys/action" and ResourceGroup contains "cloud-shell", "Cloud Shell storage key",
        "")
    | where isnotempty(RemotePath)
    | extend PropertiesJson = parse_json(Properties)
    | extend ResponseBody = parse_json(tostring(PropertiesJson.responseBody))
    | extend Settings = parse_json(tostring(ResponseBody.properties.settings))
    | extend FileUris = tostring(Settings.fileUris), CommandToExecute = tostring(Settings.commandToExecute)
    | project TimeGenerated, RemotePath, OperationName, OperationNameValue, ActivityStatusValue,
              Caller, CallerIpAddress, CorrelationId, OperationId, ResourceGroup,
              Resource = _ResourceId, FileUris, CommandToExecute, Properties
);
let FirstSeen = RemoteExecution
| summarize FirstSeen = min(TimeGenerated), Seen = count()
    by RemotePath, Caller, CallerIpAddress, Resource;
RemoteExecution
| where TimeGenerated >= ago(detectionWindow)
| join kind=inner FirstSeen on RemotePath, Caller, CallerIpAddress, Resource
| where FirstSeen >= ago(detectionWindow)
| project TimeGenerated, FirstSeen, Seen, RemotePath, ActivityStatusValue, Caller,
          CallerIpAddress, Resource, ResourceGroup, CorrelationId, OperationId,
          FileUris, CommandToExecute
| order by TimeGenerated desc

Pay attention to

  • Exact paths: Microsoft.Compute/virtualMachines/runCommand/action, Create or Update Virtual Machine Extension, and Microsoft.Storage/storageAccounts/listKeys/action in a resource group containing cloud-shell.
  • A new Caller + CallerIpAddress + resource combination, a successful status, or several accepted/failed attempts followed by success.
  • Literal values in FileUris and CommandToExecute. Protected extension settings are intentionally masked; use guest telemetry when those fields are empty or encrypted.
  • Reused CorrelationId, target VM/resource ID, and nearby role, key, network, or policy changes.

Then pivot

Pivot around rare Azure remote execution
let hitWindow = 1d;
let Hits = materialize(
    AzureActivity
    | where TimeGenerated >= ago(hitWindow)
    | extend RemotePath = case(
        OperationNameValue =~ "Microsoft.Compute/virtualMachines/runCommand/action", "VM Run Command",
        OperationName =~ "Create or Update Virtual Machine Extension", "Custom Script Extension",
        OperationNameValue =~ "Microsoft.Storage/storageAccounts/listKeys/action" and ResourceGroup contains "cloud-shell", "Cloud Shell storage key",
        "")
    | where isnotempty(RemotePath)
    | project HitTime = TimeGenerated, HitPath = RemotePath, HitCaller = Caller,
              HitIP = CallerIpAddress, HitCorrelationId = CorrelationId,
              HitResource = _ResourceId
);
AzureActivity
| where TimeGenerated >= ago(hitWindow + 1h)
| project EventTime = TimeGenerated, Caller, CallerIpAddress, CorrelationId,
          Resource = _ResourceId, OperationNameValue,
          OperationName, ActivityStatusValue, ActivitySubstatusValue,
          Authorization, Claims, Properties
| join kind=inner Hits on $left.Caller == $right.HitCaller
| where EventTime between (HitTime - 30m .. HitTime + 30m)
| where CorrelationId == HitCorrelationId or Resource == HitResource or CallerIpAddress == HitIP
| project HitTime, HitPath, EventTime, Caller, CallerIpAddress, OperationNameValue,
          OperationName, ActivityStatusValue, ActivitySubstatusValue,
          CorrelationId, Resource, Authorization, Claims, Properties
| order by HitTime desc, EventTime asc

Needs: Sentinel; AzureActivity from the Azure Activity connector. Guest process logs are optional but important when Custom Script Extension protected settings mask command details.

Expected noise: Approved automation, VM maintenance, break-glass administration, Cloud Shell onboarding, and extension deployment pipelines; baseline service principals and managed egress separately from human administrators.

Source YAML paths:

Storage reconnaissance followed by transfer

Purpose: Find anonymous, touch-once storage enumeration with at least 10 objects and a 50% failure ratio, followed from the same public IP by a successful read, upload, or delete.

Run first

Storage reconnaissance followed by transfer
let lookback = 7d;
let sessionWindow = 10m;
let minDistinctObjects = 10;
let minFailureRatio = 0.5;
let Storage = materialize(
    union isfuzzy=true StorageBlobLogs, StorageFileLogs
    | where TimeGenerated >= ago(lookback)
    | extend ObjectUri = tostring(split(Uri, "?", 0)[0]),
             CallerIP = tostring(split(CallerIpAddress, ":", 0)[0]),
             StatusCodeInt = toint(StatusCode)
    | project TimeGenerated, AccountName, ObjectUri, CallerIP, UserAgentHeader,
              AuthenticationType, Category, OperationName, StatusText, StatusCodeInt, ResponseMd5
);
let Recon = Storage
| where AuthenticationType =~ "Anonymous" and Category =~ "StorageRead"
| where not(ipv4_is_private(CallerIP)) and ObjectUri !endswith "/favicon.ico"
| summarize ReconStart = min(TimeGenerated), ReconEnd = max(TimeGenerated),
            DistinctObjects = dcount(ObjectUri), Requests = count(),
            Failures = countif(StatusCodeInt >= 400), ReconObjects = make_set(ObjectUri, 50)
    by AccountName, CallerIP, UserAgentHeader, bin(TimeGenerated, sessionWindow)
| extend FailureRatio = todouble(Failures) / Requests
| where DistinctObjects >= minDistinctObjects and FailureRatio >= minFailureRatio
| where DistinctObjects == Requests;
let Transfers = Storage
| where StatusText =~ "Success"
| where OperationName in~ ("GetBlob", "GetFile", "PutBlob", "PutRange", "DeleteBlob", "DeleteFile")
| summarize TransferStart = min(TimeGenerated), TransferEnd = max(TimeGenerated),
            TransferOperations = make_set(OperationName, 20),
            TransferObjects = make_set(ObjectUri, 50), TransferUserAgents = make_set(UserAgentHeader, 20)
    by AccountName, CallerIP, bin(TimeGenerated, sessionWindow);
Recon
| join kind=inner Transfers on AccountName, CallerIP
| where TransferStart > ReconEnd and TransferStart <= ReconEnd + 1h
| project ReconStart, ReconEnd, TransferStart, TransferEnd, AccountName, CallerIP,
          ReconUserAgent = UserAgentHeader, DistinctObjects, Requests, Failures,
          FailureRatio, ReconObjects, TransferOperations, TransferObjects, TransferUserAgents
| order by ReconStart desc

Pay attention to

  • Exact AuthenticationType == "Anonymous", Category == "StorageRead", at least 10 distinct objects, FailureRatio >= 0.5, and the touch-once relationship DistinctObjects == Requests.
  • Successful follow-on GetBlob, GetFile, PutBlob, PutRange, DeleteBlob, or DeleteFile from the same public IP and storage account.
  • An unusual or changing UserAgentHeader, rapid IP rotation, guessed object names, and transfer objects that overlap ReconObjects.
  • Whether anonymous access is expected for that account/container; an account name is not proof of an authenticated user identity.

Then pivot

Pivot from storage reconnaissance into object operations
let lookback = 7d;
let Storage = materialize(
    union isfuzzy=true StorageBlobLogs, StorageFileLogs
    | where TimeGenerated >= ago(lookback)
    | extend ObjectUri = tostring(split(Uri, "?", 0)[0]), CallerIP = tostring(split(CallerIpAddress, ":", 0)[0]),
             StatusCodeInt = toint(StatusCode)
);
let Suspects = Storage
| where AuthenticationType =~ "Anonymous" and Category =~ "StorageRead"
| where not(ipv4_is_private(CallerIP)) and ObjectUri !endswith "/favicon.ico"
| summarize ReconStart = min(TimeGenerated), ReconEnd = max(TimeGenerated),
            DistinctObjects = dcount(ObjectUri), Requests = count(),
            Failures = countif(StatusCodeInt >= 400)
    by AccountName, CallerIP, UserAgentHeader, bin(TimeGenerated, 10m)
| extend FailureRatio = todouble(Failures) / Requests
| where DistinctObjects >= 10 and FailureRatio >= 0.5 and DistinctObjects == Requests;
Storage
| join kind=inner Suspects on AccountName, CallerIP
| where TimeGenerated between (ReconStart - 15m .. ReconEnd + 1h)
| project TimeGenerated, ReconStart, ReconEnd, AccountName, CallerIP, AuthenticationType,
          OperationName, Category, StatusText, StatusCode, ObjectUri,
          UserAgentHeader, RequesterObjectId, RequesterAppId, ResponseMd5, DurationMs
| order by ReconStart desc, TimeGenerated asc

Needs: Sentinel; Azure Storage diagnostic resource logs in StorageBlobLogs and/or StorageFileLogs, including read/write/delete categories.

Expected noise: Public websites, scanners, vulnerability testing, CDN/proxy traffic, storage browsers, backup/sync clients, and applications that probe object existence before transfer.

Source YAML paths:

Staged cloud-object create, retrieve, and delete lifecycle

Purpose: Find an object successfully created, retrieved, and deleted in that order within 30 minutes, while preserving each stage's IP and user-agent context.

Run first

Staged cloud-object lifecycle
let lookback = 7d;
let lifecycleWindow = 30m;
let Storage = materialize(
    union isfuzzy=true StorageBlobLogs, StorageFileLogs
    | where TimeGenerated >= ago(lookback)
    | where StatusText =~ "Success"
    | extend ObjectUri = tostring(split(Uri, "?", 0)[0]), CallerIP = tostring(split(CallerIpAddress, ":", 0)[0])
    | project TimeGenerated, AccountName, ObjectUri, CallerIP, UserAgentHeader,
              AuthenticationType, OperationName, Category, ResponseMd5, RequesterObjectId, RequesterAppId
);
let Creates = Storage
| where OperationName in~ ("PutBlob", "PutRange")
| project AccountName, ObjectUri, CreateTime = TimeGenerated, CreateOperation = OperationName,
          CreatorIP = CallerIP, CreatorUA = UserAgentHeader, CreatorObjectId = RequesterObjectId,
          CreatorAppId = RequesterAppId, ResponseMd5;
let Reads = Storage
| where OperationName in~ ("GetBlob", "GetFile")
| project AccountName, ObjectUri, ReadTime = TimeGenerated,
          ReaderIP = CallerIP, ReaderUA = UserAgentHeader,
          ReaderObjectId = RequesterObjectId, ReaderAppId = RequesterAppId;
let Deletes = Storage
| where OperationName in~ ("DeleteBlob", "DeleteFile")
| project AccountName, ObjectUri, DeleteTime = TimeGenerated,
          DeleterIP = CallerIP, DeleterUA = UserAgentHeader,
          DeleterObjectId = RequesterObjectId, DeleterAppId = RequesterAppId;
Creates
| join kind=inner Reads on AccountName, ObjectUri
| where ReadTime between (CreateTime .. CreateTime + lifecycleWindow)
| join kind=inner Deletes on AccountName, ObjectUri
| where DeleteTime between (ReadTime .. CreateTime + lifecycleWindow)
| extend CrossContext = CreatorIP != ReaderIP or CreatorIP != DeleterIP
    or CreatorUA != ReaderUA or CreatorUA != DeleterUA
| project CreateTime, ReadTime, DeleteTime, AccountName, ObjectUri, ResponseMd5,
          CreateOperation, CreatorIP, CreatorUA, CreatorObjectId, CreatorAppId,
          ReaderIP, ReaderUA, ReaderObjectId, ReaderAppId,
          DeleterIP, DeleterUA, DeleterObjectId, DeleterAppId, CrossContext
| order by CreateTime desc

Pay attention to

  • Exact ordered operations: PutBlob or PutRange, then GetBlob or GetFile, then DeleteBlob or DeleteFile.
  • CrossContext == true, especially a different reader IP, user agent, RequesterObjectId, or RequesterAppId from the creator.
  • Short create-to-delete timing, one-time retrieval, suspicious object names, and ResponseMd5 reuse elsewhere.
  • Multiple PutRange records are normal for chunked file uploads; group and tune around complete application transactions when necessary.

Then pivot

Pivot across a staged object's complete history
let lookback = 7d;
let lifecycleWindow = 30m;
let Storage = materialize(
    union isfuzzy=true StorageBlobLogs, StorageFileLogs
    | where TimeGenerated >= ago(lookback)
    | extend ObjectUri = tostring(split(Uri, "?", 0)[0]), CallerIP = tostring(split(CallerIpAddress, ":", 0)[0])
);
let Creates = Storage
| where StatusText =~ "Success" and OperationName in~ ("PutBlob", "PutRange")
| project AccountName, ObjectUri, CreateTime = TimeGenerated;
let Reads = Storage
| where StatusText =~ "Success" and OperationName in~ ("GetBlob", "GetFile")
| project AccountName, ObjectUri, ReadTime = TimeGenerated;
let Deletes = Storage
| where StatusText =~ "Success" and OperationName in~ ("DeleteBlob", "DeleteFile")
| project AccountName, ObjectUri, DeleteTime = TimeGenerated;
let LifecycleObjects = Creates
| join kind=inner Reads on AccountName, ObjectUri
| where ReadTime between (CreateTime .. CreateTime + lifecycleWindow)
| join kind=inner Deletes on AccountName, ObjectUri
| where DeleteTime between (ReadTime .. CreateTime + lifecycleWindow)
| project AccountName, ObjectUri, CreateTime, DeleteTime;
Storage
| join kind=inner LifecycleObjects on AccountName, ObjectUri
| where TimeGenerated between (CreateTime - 15m .. DeleteTime + 15m)
| project TimeGenerated, CreateTime, DeleteTime, AccountName, OperationName, Category,
          StatusText, StatusCode, ObjectUri, CallerIP, UserAgentHeader,
          AuthenticationType, RequesterObjectId, RequesterAppId, ResponseMd5, Etag, DurationMs
| order by CreateTime desc, TimeGenerated asc

Needs: Sentinel; Azure Storage diagnostic resource logs in StorageBlobLogs and/or StorageFileLogs with successful read, write, and delete operations.

Expected noise: Temporary-object workflows, build artifacts, application scratch space, ETL, malware scanning, backup validation, and chunked uploads; tune by account, container/share, application ID, and normal lifecycle duration.

Source YAML paths:

Destructive cloud or security-control administrative sequence

Purpose: Prioritize callers that perform multiple destructive or security-control operation families in one hour, or at least five distinct delete operations, and expose adjacent alerts and changes.

Run first

Destructive cloud or security-control administrative sequence
let lookback = 7d;
let sequenceWindow = 1h;
let SensitiveActivity = materialize(
    AzureActivity
    | where TimeGenerated >= ago(lookback)
    | where ActivityStatusValue in~ ("Success", "Succeeded") or ActivitySubstatusValue in~ ("OK", "Created", "Accepted", "No Content")
    | extend OperationFamily = case(
        OperationNameValue in~ ("Microsoft.SecurityInsights/alertRules/write", "Microsoft.SecurityInsights/alertRules/delete"), "Sentinel analytics rule",
        OperationNameValue in~ ("Microsoft.SecurityInsights/dataConnectors/write", "Microsoft.SecurityInsights/dataConnectors/delete"), "Sentinel data connector",
        OperationNameValue in~ ("Microsoft.Network/networkSecurityGroups/write", "Microsoft.Network/networkSecurityGroups/delete"), "Network security group",
        OperationNameValue endswith "/delete", "Cloud resource deletion",
        "")
    | where isnotempty(OperationFamily)
    | project TimeGenerated, Caller, CallerIpAddress, CorrelationId, OperationId,
              OperationFamily, OperationNameValue, ActivityStatusValue, ActivitySubstatusValue,
              Resource = _ResourceId, ResourceGroup, Properties, Authorization
);
SensitiveActivity
| summarize SequenceStart = min(TimeGenerated), SequenceEnd = max(TimeGenerated),
            OperationCount = dcount(CorrelationId),
            DeleteCount = dcountif(CorrelationId, OperationNameValue endswith "/delete"),
            FamilyCount = dcount(OperationFamily), Families = make_set(OperationFamily, 10),
            Operations = make_set(OperationNameValue, 50), Resources = make_set(Resource, 50),
            CorrelationIds = make_set(CorrelationId, 50)
    by Caller, CallerIpAddress, bin(TimeGenerated, sequenceWindow)
| where (FamilyCount >= 2 and OperationCount >= 2) or DeleteCount >= 5
| project SequenceStart, SequenceEnd, Caller, CallerIpAddress, OperationCount,
          DeleteCount, FamilyCount, Families, Operations, Resources, CorrelationIds
| order by SequenceStart desc

Pay attention to

  • Exact control operations: Microsoft.SecurityInsights/alertRules/write, Microsoft.SecurityInsights/alertRules/delete, Microsoft.SecurityInsights/dataConnectors/write, Microsoft.SecurityInsights/dataConnectors/delete, Microsoft.Network/networkSecurityGroups/write, and Microsoft.Network/networkSecurityGroups/delete.
  • Any successful OperationNameValue ending in /delete, especially several resource types or subscriptions under one caller.
  • A sequence that removes a connector or analytics rule before resource deletion, changes an NSG before impact, or uses a new caller/IP.
  • Properties, Authorization, resource IDs, and status transitions. Operation names indicate an attempted change; confirm the effective before/after state.

Then pivot

Pivot around destructive administration and adjacent alerts
let lookback = 7d;
let SensitiveActivity = materialize(
    AzureActivity
    | where TimeGenerated >= ago(lookback)
    | where ActivityStatusValue in~ ("Success", "Succeeded") or ActivitySubstatusValue in~ ("OK", "Created", "Accepted", "No Content")
    | extend OperationFamily = case(
        OperationNameValue in~ ("Microsoft.SecurityInsights/alertRules/write", "Microsoft.SecurityInsights/alertRules/delete"), "Sentinel analytics rule",
        OperationNameValue in~ ("Microsoft.SecurityInsights/dataConnectors/write", "Microsoft.SecurityInsights/dataConnectors/delete"), "Sentinel data connector",
        OperationNameValue in~ ("Microsoft.Network/networkSecurityGroups/write", "Microsoft.Network/networkSecurityGroups/delete"), "Network security group",
        OperationNameValue endswith "/delete", "Cloud resource deletion",
        "")
    | where isnotempty(OperationFamily)
);
let Candidates = materialize(
    SensitiveActivity
    | summarize SequenceStart = min(TimeGenerated), SequenceEnd = max(TimeGenerated),
                OperationCount = dcount(CorrelationId),
                DeleteCount = dcountif(CorrelationId, OperationNameValue endswith "/delete"),
                FamilyCount = dcount(OperationFamily)
        by Caller, CallerIpAddress, bin(TimeGenerated, 1h)
    | where (FamilyCount >= 2 and OperationCount >= 2) or DeleteCount >= 5
);
let CandidateIPs = toscalar(Candidates | summarize make_set(CallerIpAddress, 500));
let AdministrativeEvents = AzureActivity
| where TimeGenerated >= ago(lookback)
| join kind=inner Candidates on Caller, CallerIpAddress
| where TimeGenerated between (SequenceStart - 1h .. SequenceEnd + 1h)
| project TimeGenerated, RecordType = "AzureActivity", Name = coalesce(OperationNameValue, OperationName),
          Severity = "", Caller, CallerIpAddress, CorrelationId,
          Resource = _ResourceId, ActivityStatusValue,
          Details = tostring(pack("substatus", ActivitySubstatusValue, "properties", Properties, "authorization", Authorization));
let AdjacentAlerts = SecurityAlert
| where TimeGenerated >= ago(lookback)
| where tostring(Entities) has_any (CandidateIPs) or tostring(ExtendedProperties) has_any (CandidateIPs) or CompromisedEntity has_any (CandidateIPs)
| project TimeGenerated, RecordType = "SecurityAlert", Name = AlertName,
          Severity = AlertSeverity, Caller = "", CallerIpAddress = "", CorrelationId = SystemAlertId,
          Resource = CompromisedEntity, ActivityStatusValue = "",
          Details = tostring(pack("provider", ProviderName, "entities", Entities, "extendedProperties", ExtendedProperties));
union AdministrativeEvents, AdjacentAlerts
| order by TimeGenerated desc

Needs: Sentinel; AzureActivity. The pivot additionally uses SecurityAlert from Microsoft Defender for Cloud or another connected alert provider.

Expected noise: Infrastructure-as-code deployments, planned decommissioning, connector/rule releases, NSG maintenance, disaster-recovery tests, and bulk cleanup; compare with change records and immutable caller/service-principal IDs.

Source YAML paths: