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¶
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, andMicrosoft.Storage/storageAccounts/listKeys/actionin a resource group containingcloud-shell. - A new
Caller+CallerIpAddress+ resource combination, a successful status, or several accepted/failed attempts followed by success. - Literal values in
FileUrisandCommandToExecute. 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¶
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:
- Rare_Custom_Script_Extension.yaml —
Solutions/Azure Activity/Hunting Queries/Rare_Custom_Script_Extension.yaml - AzureRunCommandFromAzureIP.yaml —
Solutions/Azure Activity/Hunting Queries/AzureRunCommandFromAzureIP.yaml - Azure-CloudShell-Usage.yaml —
Hunting Queries/AzureActivity/Azure-CloudShell-Usage.yaml
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¶
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 relationshipDistinctObjects == Requests. - Successful follow-on
GetBlob,GetFile,PutBlob,PutRange,DeleteBlob, orDeleteFilefrom the same public IP and storage account. - An unusual or changing
UserAgentHeader, rapid IP rotation, guessed object names, and transfer objects that overlapReconObjects. - Whether anonymous access is expected for that account/container; an account name is not proof of an authenticated user identity.
Then pivot¶
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:
- AzureStorageEnumerationPerUserSessino.yaml —
Hunting Queries/AzureStorage/AzureStorageEnumerationPerUserSessino.yaml - AzureStorageFileCreateAccessDelete.yaml —
Hunting Queries/AzureStorage/AzureStorageFileCreateAccessDelete.yaml - AzureStorageUploadFromVPS.yaml —
Hunting Queries/AzureStorage/AzureStorageUploadFromVPS.yaml
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¶
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:
PutBloborPutRange, thenGetBloborGetFile, thenDeleteBloborDeleteFile. CrossContext == true, especially a different reader IP, user agent,RequesterObjectId, orRequesterAppIdfrom the creator.- Short create-to-delete timing, one-time retrieval, suspicious object names, and
ResponseMd5reuse elsewhere. - Multiple
PutRangerecords are normal for chunked file uploads; group and tune around complete application transactions when necessary.
Then pivot¶
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:
- AzureStorageFileCreateAccessDelete.yaml —
Hunting Queries/AzureStorage/AzureStorageFileCreateAccessDelete.yaml - AzureStorageFileCreatedQuicklyDeleted.yaml —
Hunting Queries/AzureStorage/AzureStorageFileCreatedQuicklyDeleted.yaml - AzureStorageMassDeletion.yaml —
Hunting Queries/AzureStorage/AzureStorageMassDeletion.yaml
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¶
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, andMicrosoft.Network/networkSecurityGroups/delete. - Any successful
OperationNameValueending 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¶
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:
- AzureNSG_AdministrativeOperations.yaml —
Solutions/Azure Activity/Hunting Queries/AzureNSG_AdministrativeOperations.yaml - AnalyticsRulesAdministrativeOperations.yaml —
Solutions/Azure Activity/Hunting Queries/AnalyticsRulesAdministrativeOperations.yaml - AzureSentinelConnectors_AdministrativeOperations.yaml —
Solutions/Azure Activity/Hunting Queries/AzureSentinelConnectors_AdministrativeOperations.yaml - TimeSeriesAnomaly_Mass_Cloud_Resource_Deletions.yaml —
Solutions/Azure Activity/Analytic Rules/TimeSeriesAnomaly_Mass_Cloud_Resource_Deletions.yaml - CoreBackupDeletionwithSecurityAlert.yaml —
Solutions/Microsoft Defender for Cloud/Analytic Rules/CoreBackupDeletionwithSecurityAlert.yaml