Detection rules › Kusto
Linked Malicious Storage Artifacts
'This query identifies the additional files uploaded by the same IP address which triggered a malware alert for malicious content upload on Azure Blob or File Storage Container.'
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control | T1071 Application Layer Protocol |
| Exfiltration | T1567 Exfiltration Over Web Service |
Rule body kusto
id: b9e3b9f8-a406-4151-9891-e5ff1ddd8c1d
name: Linked Malicious Storage Artifacts
description: |
'This query identifies the additional files uploaded by the same IP address which triggered a malware alert for malicious content upload on Azure Blob or File Storage Container.'
severity: Medium
status: Available
requiredDataConnectors:
- connectorId: MicrosoftCloudAppSecurity
dataTypes:
- SecurityAlert
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
- CommandAndControl
- Exfiltration
relevantTechniques:
- T1071
- T1567
query: |
//Collect the alert events
let alertData = SecurityAlert
| where DisplayName has "Potential malware uploaded to"
| extend Entities = parse_json(Entities)
| mv-expand Entities;
//Parse the IP address data
let ipData = alertData
| where Entities['Type'] =~ "ip"
| extend AttackerIP = tostring(Entities['Address']), AttackerCountry = tostring(Entities['Location']['CountryName']);
//Parse the file data
let FileData = alertData
| where Entities['Type'] =~ "file"
| extend MaliciousFileDirectory = tostring(Entities['Directory']), MaliciousFileName = tostring(Entities['Name']), MaliciousFileHashes = tostring(Entities['FileHashes']);
//Combine the File and IP data together
ipData
| join (FileData) on VendorOriginalId
| summarize by TimeGenerated, AttackerIP, AttackerCountry, DisplayName, ResourceId, AlertType, MaliciousFileDirectory, MaliciousFileName, MaliciousFileHashes
//Create a type column so we can track if it was a File storage or blobl storage upload
| extend type = iff(DisplayName has "file", "File", "Blob")
| join (
union
StorageFileLogs,
StorageBlobLogs
//File upload operations
| where OperationName =~ "PutBlob" or OperationName =~ "PutRange"
//Parse out the uploader IP
| extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
//Extract the filename from the Uri
| extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
//Base64 decode the MD5 filehash, we will encounter non-ascii hex so string operations don't work
//We can work around this by making it an array then converting it to hex from an int
| extend base64Char = base64_decode_toarray(ResponseMd5)
| mv-expand base64Char
| extend hexChar = tohex(toint(base64Char))
| extend hexChar = iff(strlen(hexChar) < 2, strcat("0", hexChar), hexChar)
| extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
| summarize make_list(hexChar, 1000) by CorrelationId, ResponseMd5, FileName, AccountName, TimeGenerated, RequestBodySize, ClientIP, SourceTable
| extend Md5Hash = strcat_array(list_hexChar, "")
//Pack the file information the summarise into a ClientIP row
| extend p = pack("FileName", FileName, "FileSize", RequestBodySize, "Md5Hash", Md5Hash, "Time", TimeGenerated, "SourceTable", SourceTable)
| summarize UploadedFileInfo=make_list(p, 10000), FilesUploaded=count() by ClientIP
| join kind=leftouter (
union
StorageFileLogs,
StorageBlobLogs
| where OperationName =~ "DeleteFile" or OperationName =~ "DeleteBlob"
| extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
| extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
| extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
| extend p = pack("FileName", FileName, "Time", TimeGenerated, "SourceTable", SourceTable)
| summarize DeletedFileInfo=make_list(p, 10000), FilesDeleted=count() by ClientIP
) on ClientIP
) on $left.AttackerIP == $right.ClientIP
| mvexpand UploadedFileInfo
| extend LinkedMaliciousFileName = tostring(UploadedFileInfo.FileName)
| extend LinkedMaliciousFileHash = tostring(UploadedFileInfo.Md5Hash)
| extend HashAlgorithm = "MD5"
| project AlertTimeGenerated = TimeGenerated, LinkedMaliciousFileName, LinkedMaliciousFileHash, HashAlgorithm, AlertType, AttackerIP, AttackerCountry, MaliciousFileDirectory, MaliciousFileName, FilesUploaded, UploadedFileInfo
entityMappings:
- entityType: IP
fieldMappings:
- identifier: Address
columnName: AttackerIP
- entityType: FileHash
fieldMappings:
- identifier: Algorithm
columnName: HashAlgorithm
- identifier: Value
columnName: LinkedMaliciousFileHash
version: 1.0.3
kind: Scheduled
Stages and Predicates
Let binding: FileData
let FileData = alertData
| where Entities['Type'] =~ "file"
| extend MaliciousFileDirectory = tostring(Entities['Directory']), MaliciousFileName = tostring(Entities['Name']), MaliciousFileHashes = tostring(Entities['FileHashes']);
Derived from alertData.
The stages below define let ipData (the rule's main pipeline source).
Stage 1: source
SecurityAlert
Stage 2: where
| where DisplayName has "Potential malware uploaded to"
Stage 3: extend
| extend Entities = parse_json(Entities)
Stage 4: mv-expand
| mv-expand Entities
Stage 5: where
| where Entities['Type'] =~ "ip"
Stage 6: extend
| extend AttackerIP = tostring(Entities['Address']), AttackerCountry = tostring(Entities['Location']['CountryName'])
The stages below run on ipData (the outer pipeline).
Stage 7: join
ipData
| join (FileData) on VendorOriginalId
Stage 8: summarize
| summarize by TimeGenerated, AttackerIP, AttackerCountry, DisplayName, ResourceId, AlertType, MaliciousFileDirectory, MaliciousFileName, MaliciousFileHashes
Stage 9: extend
| extend type = iff(DisplayName has "file", "File", "Blob")
type =DisplayName has "file""File""Blob"Stage 10: join
| join (
union
StorageFileLogs,
StorageBlobLogs
| where OperationName =~ "PutBlob" or OperationName =~ "PutRange"
| extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
| extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
| extend base64Char = base64_decode_toarray(ResponseMd5)
| mv-expand base64Char
| extend hexChar = tohex(toint(base64Char))
| extend hexChar = iff(strlen(hexChar) < 2, strcat("0", hexChar), hexChar)
| extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
| summarize make_list(hexChar, 1000) by CorrelationId, ResponseMd5, FileName, AccountName, TimeGenerated, RequestBodySize, ClientIP, SourceTable
| extend Md5Hash = strcat_array(list_hexChar, "")
| extend p = pack("FileName", FileName, "FileSize", RequestBodySize, "Md5Hash", Md5Hash, "Time", TimeGenerated, "SourceTable", SourceTable)
| summarize UploadedFileInfo=make_list(p, 10000), FilesUploaded=count() by ClientIP
| join kind=leftouter (
union
StorageFileLogs,
StorageBlobLogs
| where OperationName =~ "DeleteFile" or OperationName =~ "DeleteBlob"
| extend ClientIP = tostring(split(CallerIpAddress, ":", 0)[0])
| extend FileName = extract(@"\/([\w\-. ]+)\?", 1, Uri)
| extend SourceTable = iff(OperationName has "range", "StorageFileLogs", "StorageBlobLogs")
| extend p = pack("FileName", FileName, "Time", TimeGenerated, "SourceTable", SourceTable)
| summarize DeletedFileInfo=make_list(p, 10000), FilesDeleted=count() by ClientIP
) on ClientIP
) on $left.AttackerIP == $right.ClientIP
Stage 11: mv-expand
| mvexpand UploadedFileInfo
Stage 12: extend (3 consecutive steps)
| extend LinkedMaliciousFileName = tostring(UploadedFileInfo.FileName)
| extend LinkedMaliciousFileHash = tostring(UploadedFileInfo.Md5Hash)
| extend HashAlgorithm = "MD5"
Stage 13: project
| project AlertTimeGenerated = TimeGenerated, LinkedMaliciousFileName, LinkedMaliciousFileHash, HashAlgorithm, AlertType, AttackerIP, AttackerCountry, MaliciousFileDirectory, MaliciousFileName, FilesUploaded, UploadedFileInfo
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.
| Field | Kind | Values |
|---|---|---|
DisplayName | match |
|
OperationName | eq |
|
Type | eq |
|
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.
| Field | Source |
|---|---|
AlertTimeGenerated | project |
AlertType | project |
AttackerCountry | project |
AttackerIP | project |
FilesUploaded | project |
HashAlgorithm | project |
LinkedMaliciousFileHash | project |
LinkedMaliciousFileName | project |
MaliciousFileDirectory | project |
MaliciousFileName | project |
UploadedFileInfo | project |